Merge pull request #972 from filecoin-project/@aminejv/new-carousel

Feat: New global carousel design
This commit is contained in:
martinalong 2021-11-05 13:11:16 -07:00 committed by GitHub
commit 84c25d0026
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 2961 additions and 1897 deletions

View File

@ -96,6 +96,7 @@ export const semantic = {
bgWhite: system.white,
bgLight: system.grayLight6,
bgGrayLight: system.grayLight5,
bgGrayLight4: system.grayLight4,
bgBlurWhite: "rgba(255, 255, 255, 0.7)",
bgBlurWhiteOP: "rgba(255, 255, 255, 0.85)",
bgBlurWhiteTRN: "rgba(255, 255, 255, 0.3)",

View File

@ -4,6 +4,9 @@ import * as Actions from "~/common/actions";
import * as Events from "~/common/custom-events";
import * as Constants from "~/common/constants";
import { v4 as uuid } from "uuid";
import { last } from "lodash";
export const useMounted = (callback, depedencies) => {
const mountedRef = React.useRef(false);
useIsomorphicLayoutEffect(() => {
@ -395,12 +398,15 @@ export const useMediaQuery = () => {
};
};
export const useEventListener = (type, handler, dependencies) => {
export const useEventListener = ({ type, handler, ref }, dependencies) => {
React.useEffect(() => {
if (!window) return;
let element = window;
if (ref) element = ref.current;
window.addEventListener(type, handler);
return () => window.removeEventListener(type, handler);
if (!element) return;
element.addEventListener(type, handler);
return () => element.removeEventListener(type, handler);
}, dependencies);
};
@ -411,17 +417,28 @@ export const useTimeout = (callback, ms, dependencies) => {
}, dependencies);
};
let layers = [];
const removeLayer = (id) => (layers = layers.filter((layer) => layer !== id));
const isDeepestLayer = (id) => last(layers) === id;
export const useEscapeKey = (callback) => {
const layerIdRef = React.useRef();
React.useEffect(() => {
layerIdRef.current = uuid();
layers.push(layerIdRef.current);
return () => removeLayer(layerIdRef.current);
}, []);
const handleKeyUp = React.useCallback(
(e) => {
if (e.key === "Escape") callback();
if (e.key === "Escape" && isDeepestLayer(layerIdRef.current)) callback?.(e);
},
[callback]
);
useEventListener("keyup", handleKeyUp, [handleKeyUp]);
useEventListener({ type: "keyup", handler: handleKeyUp }, [handleKeyUp]);
};
export const useLockScroll = ({ lock = true } = {}) => {
export const useLockScroll = ({ lock = true } = { lock: true }) => {
React.useEffect(() => {
if (!lock) return;
document.body.style.overflow = "hidden";
@ -464,3 +481,51 @@ export const useHover = () => {
return [isHovered, { handleOnMouseEnter, handleOnMouseLeave }];
};
export const useImage = ({ src, maxWidth }) => {
const [imgState, setImgState] = React.useState({
loaded: false,
error: true,
overflow: false,
});
React.useEffect(() => {
if (!src) setImgState({ error: true, loaded: true });
const img = new Image();
img.src = src;
img.onload = () => {
if (maxWidth && img.naturalWidth < maxWidth) {
setImgState((prev) => ({ ...prev, loaded: true, error: false, overflow: true }));
} else {
setImgState({ loaded: true, error: false });
}
};
img.onerror = () => setImgState({ loaded: true, error: true });
}, []);
return imgState;
};
export const useDetectTextOverflow = ({ ref }, dependencies) => {
const [isTextOverflowing, setTextOverflow] = React.useState(false);
//SOURCE(amine): https://stackoverflow.com/a/60073230
const isEllipsisActive = (el) => {
const styles = getComputedStyle(el);
const widthEl = parseFloat(styles.width);
const ctx = document.createElement("canvas").getContext("2d");
ctx.font = `${styles.fontSize} ${styles.fontFamily}`;
const text = ctx.measureText(el.innerText);
return text.width > widthEl;
};
useIsomorphicLayoutEffect(() => {
if (!ref.current) return;
setTextOverflow(isEllipsisActive(ref.current));
}, dependencies);
return isTextOverflowing;
};

View File

@ -285,7 +285,7 @@ export const createSlug = (text, base = "untitled") => {
return base;
}
text = text.toString().toLowerCase().trim();
text = text.toString().toLowerCase();
const sets = [
{ to: "a", from: "[ÀÁÂÃÅÆĀĂĄẠẢẤẦẨẪẬẮẰẲẴẶ]" },
@ -315,7 +315,7 @@ export const createSlug = (text, base = "untitled") => {
{ to: "x", from: "[ẍ]" },
{ to: "y", from: "[ÝŶŸỲỴỶỸ]" },
{ to: "z", from: "[ŹŻŽ]" },
{ to: "-", from: "[·/_,:;']" },
{ to: "-", from: "[_]" },
];
sets.forEach((set) => {
@ -329,8 +329,8 @@ export const createSlug = (text, base = "untitled") => {
.replace(/&/g, "-and-") // Replace & with 'and'
.replace(/[^a-zA-Z0-9_\u3400-\u9FBF\s-]/g, "") // Remove all non-word chars
.replace(/\--+/g, "-") // Replace multiple - with single -
.replace(/^-+/, "") // Trim - from start of text
.replace(/-+$/, ""); // Trim - from end of text
.replace(/^-+/, "")
.trim(); // Trim - from start of text
return text;
};

View File

@ -31,8 +31,7 @@ export const Link = (props) => {
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
height={props.height}
style={props.style}
{...props}
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
@ -119,6 +118,8 @@ export const Undo = (props) => {
export const Edit = (props) => {
return (
<svg
height={16}
width={16}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
@ -126,8 +127,7 @@ export const Edit = (props) => {
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
height={props.height}
style={props.style}
{...props}
>
<path d="M17 3C17.2626 2.73735 17.5744 2.52901 17.9176 2.38687C18.2608 2.24473 18.6286 2.17157 19 2.17157C19.3714 2.17157 19.7392 2.24473 20.0824 2.38687C20.4256 2.52901 20.7374 2.73735 21 3C21.2626 3.26264 21.471 3.57444 21.6131 3.9176C21.7553 4.26077 21.8284 4.62856 21.8284 5C21.8284 5.37143 21.7553 5.73923 21.6131 6.08239C21.471 6.42555 21.2626 6.73735 21 7L7.5 20.5L2 22L3.5 16.5L17 3Z" />
</svg>
@ -979,6 +979,8 @@ export const EyeOff = (props) => (
export const Dismiss = (props) => (
<svg
height={16}
width={16}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
@ -987,7 +989,6 @@ export const Dismiss = (props) => (
strokeLinecap="round"
strokeLinejoin="round"
// tabIndex="0"
height={props.height}
style={props.style}
{...props}
>
@ -1063,6 +1064,8 @@ export const Information = (props) => (
export const InfoCircle = (props) => (
<svg
height={16}
width={16}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
@ -1658,6 +1661,8 @@ export const ArrowDownLeft = (props) => (
export const Hash = (props) => (
<svg
height={16}
width={16}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="none"
@ -2264,17 +2269,24 @@ export const Instagram = (props) => (
</svg>
);
export const CheckCircle = (props) => (
export const UploadCloud = (props) => (
<svg width={16} height={16} fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M14.667 7.387V8a6.666 6.666 0 11-3.954-6.093"
d="M10.667 10.667L8 8l-2.667 2.667M8 8v6"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14.667 2.667L8 9.34l-2-2"
d="M13.593 12.26A3.333 3.333 0 0012 6h-.84A5.333 5.333 0 102 10.867"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10.667 10.667L8 8l-2.667 2.667"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
@ -2295,6 +2307,25 @@ export const XCircle = (props) => (
</svg>
);
export const CheckCircle = (props) => (
<svg width={16} height={17} fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M14.667 7.887V8.5a6.666 6.666 0 11-3.954-6.093"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14.667 3.167L8 9.84l-2-2"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export const AlertTriangle = (props) => (
<svg width={16} height={16} fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
@ -2306,3 +2337,15 @@ export const AlertTriangle = (props) => (
/>
</svg>
);
export const Hexagon = (props) => (
<svg width={16} height={16} fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M14 10.666V5.333a1.333 1.333 0 00-.667-1.153L8.667 1.513a1.333 1.333 0 00-1.334 0L2.667 4.18A1.333 1.333 0 002 5.333v5.333a1.333 1.333 0 00.667 1.154l4.666 2.666a1.333 1.333 0 001.334 0l4.666-2.666A1.333 1.333 0 0014 10.666z"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);

View File

@ -197,24 +197,24 @@ export const removeFromSlate = async ({ slate, ids }) => {
// };
//NOTE(martina): save copy includes add to slate now. If it's already in the user's files but not in that slate, it'll skip the adding to files and just add to slate
export const saveCopy = async ({ files, slate }) => {
export const saveCopy = async ({ files, slate, showAlerts = true }) => {
let response = await Actions.saveCopy({ files, slate });
if (Events.hasError(response)) {
return false;
}
let message = Strings.formatAsUploadMessage(response.data.added, response.data.skipped, slate);
Events.dispatchMessage({ message, status: !response.data.added ? null : "INFO" });
if (showAlerts) Events.dispatchMessage({ message, status: !response.data.added ? null : "INFO" });
return response;
};
export const download = async (file) => {
export const download = async (file, rootRef) => {
Actions.createDownloadActivity({ file });
if (file.isLink) return;
if (Validations.isUnityType(file.type)) {
return await downloadZip(file);
}
let uri = Strings.getURLfromCID(file.cid);
Window.saveAs(uri, file.filename);
await Window.saveAs(uri, file.filename, rootRef);
return { data: true };
};

View File

@ -222,6 +222,8 @@ export const isPreviewableImage = (type = "") => {
return type.startsWith("image/");
};
export const isGif = (type) => isPreviewableImage && type.startsWith("image/gif");
export const isImageType = (type = "") => {
return type.startsWith("image/");
};

View File

@ -37,7 +37,7 @@ export const getViewportSize = () => {
};
// NOTE(martina): Works in most cases, except some where the type of the file is jumbled (not an issue specific to this function)
export const saveAs = (uri, filename) => {
export const saveAs = (uri, filename, rootRef) =>
fetch(uri, {
headers: new Headers({
Origin: location.origin,
@ -46,17 +46,16 @@ export const saveAs = (uri, filename) => {
})
.then((response) => response.blob())
.then((blob) => {
const element = rootRef?.current || document.body;
let blobUrl = window.URL.createObjectURL(blob);
var link = document.createElement("a");
document.body.appendChild(link);
element.appendChild(link);
link.download = filename;
link.href = blobUrl;
link.click();
document.body.removeChild(link);
element.removeChild(link);
})
.catch((e) => console.error(e));
};
export const getQueryParameterByName = (name) => {
let url = window.location.href;
name = name.replace(/[\[\]]/g, "\\$&");

View File

@ -51,7 +51,6 @@ import CTATransition from "~/components/core/CTATransition";
import { GlobalModal } from "~/components/system/components/GlobalModal";
import { OnboardingModal } from "~/components/core/OnboardingModal";
import { SearchModal } from "~/components/core/SearchModal";
import { CollectionSharingModal } from "~/components/core/ShareModals/CollectionSharingModal";
import { Alert } from "~/components/core/Alert";
import { announcements } from "~/components/core/OnboardingModal";
import { Logo } from "~/common/logo";
@ -519,7 +518,6 @@ export default class ApplicationPage extends React.Component {
onAction={this._handleAction}
isMobile={this.props.isMobile}
/>
<CollectionSharingModal />
<CTATransition onAction={this._handleAction} />
{/* {!this.state.loaded ? (
<div

View File

@ -1,853 +0,0 @@
import * as React from "react";
import * as Constants from "~/common/constants";
import * as Strings from "~/common/strings";
import * as Validations from "~/common/validations";
import * as Actions from "~/common/actions";
import * as System from "~/components/system";
import * as UserBehaviors from "~/common/user-behaviors";
import * as SVG from "~/common/svg";
import * as Events from "~/common/custom-events";
import * as Window from "~/common/window";
import { css, withTheme } from "@emotion/react";
import { RadioGroup } from "~/components/system/components/RadioGroup";
import { LoaderSpinner } from "~/components/system/components/Loaders";
import { SlatePicker } from "~/components/core/SlatePicker";
import { Input } from "~/components/system/components/Input";
import { Toggle } from "~/components/system/components/Toggle";
import { Textarea } from "~/components/system/components/Textarea";
import { Tag } from "~/components/system/components/Tag";
import { Link } from "~/components/core/Link";
import LinkTag from "~/components/core/Link/LinkTag";
import isEqual from "lodash/isEqual";
import cloneDeep from "lodash/cloneDeep";
import ProcessedText from "~/components/core/ProcessedText";
import { ConfirmationModal } from "~/components/core/ConfirmationModal";
const DEFAULT_BOOK =
"https://slate.textile.io/ipfs/bafkreibk32sw7arspy5kw3p5gkuidfcwjbwqyjdktd5wkqqxahvkm2qlyi";
const DEFAULT_DATA =
"https://slate.textile.io/ipfs/bafkreid6bnjxz6fq2deuhehtxkcesjnjsa2itcdgyn754fddc7u72oks2m";
const DEFAULT_DOCUMENT =
"https://slate.textile.io/ipfs/bafkreiecdiepww52i5q3luvp4ki2n34o6z3qkjmbk7pfhx4q654a4wxeam";
const DEFAULT_VIDEO =
"https://slate.textile.io/ipfs/bafkreibesdtut4j5arclrxd2hmkfrv4js4cile7ajnndn3dcn5va6wzoaa";
const DEFAULT_AUDIO =
"https://slate.textile.io/ipfs/bafkreig2hijckpamesp4nawrhd6vlfvrtzt7yau5wad4mzpm3kie5omv4e";
const STYLES_NO_VISIBLE_SCROLL = css`
overflow-y: scroll;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: -ms-autohiding-scrollbar;
::-webkit-scrollbar {
width: 0px;
display: none;
}
::-webkit-scrollbar-track {
background: ${Constants.semantic.bgLight};
}
::-webkit-scrollbar-thumb {
background: ${Constants.system.grayLight2};
}
`;
const STYLES_BODY = css`
font-size: 16px;
line-height: 1.225;
overflow-wrap: break-word;
white-space: pre-wrap;
margin-bottom: 32px;
`;
const STYLES_SIDEBAR_INPUT_LABEL = css`
font-size: 16px;
font-family: ${Constants.font.semiBold};
color: ${Constants.system.grayLight2};
margin-bottom: 8px;
`;
const STYLES_SIDEBAR = css`
width: 420px;
padding: 48px 24px 0px 24px;
flex-shrink: 0;
height: 100vh;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
background-color: rgba(20, 20, 20, 0.8);
${STYLES_NO_VISIBLE_SCROLL}
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
-webkit-backdrop-filter: blur(75px);
backdrop-filter: blur(75px);
background-color: rgba(150, 150, 150, 0.2);
}
@media (max-width: ${Constants.sizes.mobile}px) {
display: none;
}
`;
const STYLES_DISMISS_BOX = css`
position: absolute;
top: 16px;
right: 16px;
color: ${Constants.system.grayLight2};
cursor: pointer;
:hover {
color: ${Constants.system.white};
}
`;
const STYLES_HEADING = css`
font-family: ${Constants.font.semiBold};
font-size: 20px;
font-weight: 400;
overflow-wrap: break-word;
white-space: pre-wrap;
margin-bottom: 32px;
`;
const STYLES_META = css`
text-align: start;
padding: 14px 0px 8px 0px;
overflow-wrap: break-word;
`;
const STYLES_META_TITLE = css`
font-family: ${Constants.font.semiBold};
color: ${Constants.system.white};
font-size: ${Constants.typescale.lvl2};
text-decoration: none;
word-break: break-all;
overflow-wrap: anywhere;
:hover {
color: ${Constants.system.blue};
}
`;
const STYLES_TAG = css`
margin-right: 24px;
padding: 0px 2px;
border-radius: 2px;
border: 1px solid ${Constants.system.grayLight2};
`;
const STYLES_OPTIONS_SECTION = css`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin: 16px 0 16px 0;
`;
const STYLES_META_DETAILS = css`
color: ${Constants.system.grayLight2};
text-transform: uppercase;
margin: 24px 0px;
font-family: ${Constants.font.medium};
font-size: 0.9rem;
`;
const STYLES_SIDEBAR_SECTION = css`
flex-shrink: 0;
width: 100%;
margin-bottom: 16px;
`;
const STYLES_ACTIONS = css`
color: ${Constants.system.white};
border: 1px solid #3c3c3c;
border-radius: 4px;
background-color: transparent;
margin-bottom: 48px;
margin-top: 36px;
`;
const STYLES_ACTION = css`
cursor: pointer;
padding: 12px 16px;
border-bottom: 1px solid #3c3c3c;
display: flex;
align-items: center;
:hover {
color: ${Constants.system.blue};
}
:last-child {
border: none;
}
`;
const STYLES_SECTION_HEADER = css`
font-family: ${Constants.font.semiBold};
font-size: 1.1rem;
margin-top: 24px;
display: flex;
align-items: center;
`;
const STYLES_HIDDEN = css`
position: absolute;
opacity: 0;
pointer-events: none;
`;
const STYLES_IMAGE_BOX = css`
max-width: 100%;
max-height: 368px;
display: flex;
align-items: center;
justify-content: center;
background-color: ${Constants.system.black};
overflow: hidden;
${"" /* box-shadow: 0 0 0 1px ${Constants.semantic.borderGrayLight} inset; */}
border-radius: 4px;
`;
const STYLES_FILE_HIDDEN = css`
height: 1px;
width: 1px;
opacity: 0;
visibility: hidden;
position: fixed;
top: -1px;
left: -1px;
`;
const STYLES_TEXT = css`
color: ${Constants.system.grayLight2};
line-height: 1.5;
`;
const STYLES_INPUT = {
marginBottom: 8,
backgroundColor: "transparent",
boxShadow: "0 0 0 1px #3c3c3c inset",
color: Constants.system.white,
height: 48,
};
const STYLES_AUTOSAVE = css`
font-size: 12px;
line-height: 1.225;
display: flex;
justify-content: baseline;
color: ${Constants.system.yellow};
opacity: 0;
${"" /* margin: 26px 24px; */}
position: absolute;
top: 24px;
left: 16px;
@keyframes slate-animations-autosave {
0% {
opacity: 0;
transform: translateX(0);
}
10% {
opacity: 1;
transform: translateX(12px);
}
90% {
opacity: 1;
transform: translateX(12px);
}
100% {
opacity: 0;
}
}
animation: slate-animations-autosave 4000ms ease;
`;
const STYLES_SPINNER = css`
width: 24px;
height: 24px;
`;
export const FileTypeDefaultPreview = (props) => {
if (props.type) {
if (Validations.isVideoType(type)) {
return DEFAULT_VIDEO;
} else if (Validations.isAudioType(type)) {
return DEFAULT_AUDIO;
} else if (Validations.isPdfType(type)) {
return DEFAULT_DOCUMENT;
} else if (Validations.isEpubType(type)) {
return DEFAULT_BOOK;
}
}
return DEFAULT_DATA;
};
class CarouselSidebar extends React.Component {
state = {
name: this.props.file.name || this.props.file.filename || "",
body: this.props.file.body || "",
source: this.props.file.source || "",
author: this.props.file.author || "",
// tags: this.props.file.data.tags || [],
// suggestions: this.props.viewer?.tags || [],
selected: {},
isUploading: false,
isDownloading: false,
showSavedMessage: false,
showConnectedSection: false,
showFileSection: true,
modalShow: false,
};
componentDidMount = () => {
const editingAllowed = !this.props.external && this.props.isOwner && !this.props.isRepost;
if (editingAllowed) {
this.debounceInstance = Window.debounce(() => this._handleSave(), 3000);
this.calculateSelected();
}
};
// componentDidUpdate = (prevProps, prevState) => {
// if (!isEqual(prevState.tags, this.state.tags)) {
// this.updateSuggestions();
// }
// };
// updateSuggestions = () => {
// let newSuggestions = new Set([...this.state.suggestions, ...this.state.tags]);
// this.setState({ suggestions: Array.from(newSuggestions) });
// };
calculateSelected = () => {
if (!this.props.viewer) {
this.setState({ selected: {} });
return;
}
let selected = {};
const id = this.props.file.id;
for (let slate of this.props.viewer.slates) {
if (slate.objects.some((obj) => obj.id === id)) {
selected[slate.id] = true;
}
}
this.setState({ selected });
};
_handleToggleAccordion = (tab) => {
this.setState({ [tab]: !this.state[tab] });
};
_handleDarkMode = async (e) => {
Events.dispatchCustomEvent({
name: "set-slate-theme",
detail: { darkmode: e.target.value },
});
};
_handleChange = (e) => {
if (this.props.external || !this.props.isOwner) return;
this.debounceInstance();
this.setState(
{
[e.target.name]: e.target.value,
showSavedMessage: false,
}
// () => {
// if (e.target.name === "Tags") {
// this.updateSuggestions();
// }
// }
);
};
_handleCapitalization(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
_handleSave = async () => {
if (this.props.external || !this.props.isOwner) return;
// this.props.onAction({ type: "UPDATE_VIEWER", viewer: { tags: this.state.suggestions } });
const response = await Actions.updateFile({
id: this.props.file.id,
name: this.state.name,
body: this.state.body,
source: this.state.source,
author: this.state.author,
});
Events.hasError(response);
this.setState({ showSavedMessage: true });
};
_handleSaveCopy = async (data) => {
if (!this.props.viewer) {
Events.dispatchCustomEvent({ name: "slate-global-open-cta", detail: {} });
return;
}
this.setState({ loading: "savingCopy" }, async () => {
let response = await UserBehaviors.saveCopy({ files: [data] });
Events.hasError(response);
this.setState({ loading: false });
});
};
_handleUpload = async (e) => {
if (this.props.external || !this.props.isOwner || !this.props.viewer) return;
e.persist();
this.setState({ isUploading: true });
let previousCoverId = this.props.file.coverImage?.id;
if (!e || !e.target) {
this.setState({ isUploading: false });
return;
}
let file = await UserBehaviors.uploadImage(e.target.files[0]);
if (!file) {
this.setState({ isUploading: false });
return;
}
let coverImage = file;
//TODO(martina): create an endpoint specifically for cover images instead of this, which will delete original cover image etc
let updateReponse = await Actions.updateFile({
id: this.props.file.id,
coverImage,
});
if (previousCoverId) {
if (!this.props.viewer.library.some((obj) => obj.id === previousCoverId)) {
await UserBehaviors.deleteFiles(previousCoverId, true);
}
}
Events.hasError(updateReponse);
this.setState({ isUploading: false });
};
_handleDownload = () => {
if (!this.props.viewer) {
Events.dispatchCustomEvent({ name: "slate-global-open-cta", detail: {} });
return;
}
this.setState({ isDownloading: true }, async () => {
const response = await UserBehaviors.download(this.props.file);
this.setState({ isDownloading: false });
Events.hasError(response);
});
};
_handleCreateSlate = async () => {
if (this.props.external) return;
this.props.onClose();
this.props.onAction({
type: "SIDEBAR",
value: "SIDEBAR_CREATE_SLATE",
data: { files: [this.props.file] },
});
};
_handleDelete = (res) => {
if (this.props.external || !this.props.isOwner || !this.props.viewer) return;
if (!res) {
this.setState({ modalShow: false });
return;
}
const id = this.props.file.id;
let updatedLibrary = this.props.viewer.library.filter((obj) => obj.id !== id);
if (this.props.carouselType === "SLATE") {
const slateId = this.props.data.id;
let slates = this.props.viewer.slates;
for (let slate of slates) {
if (slate.id === slateId) {
slate.objects = slate.objects.filter((obj) => obj.id !== id);
break;
}
}
this.props.onAction({ type: "UPDATE_VIEWER", viewer: { library: updatedLibrary, slates } });
} else {
this.props.onAction({ type: "UPDATE_VIEWER", viewer: { library: updatedLibrary } });
}
UserBehaviors.deleteFiles(id);
this.props.onNext();
};
_handleAdd = async (slate) => {
if (this.state.selected[slate.id]) {
UserBehaviors.removeFromSlate({ slate, ids: [this.props.file.id] });
} else {
UserBehaviors.saveCopy({
slate,
files: [this.props.file],
});
}
this.setState({
selected: {
...this.state.selected,
[slate.id]: !this.state.selected[slate.id],
},
});
};
_handleRemove = async () => {
if (
!this.props.carouselType === "SLATE" ||
this.props.external ||
!this.props.isOwner ||
!this.props.viewer
) {
return;
}
const id = this.props.file.id;
const slateId = this.props.data.id;
let slates = this.props.viewer.slates;
for (let slate of slates) {
if (slate.id === slateId) {
slate.objects = slate.objects.filter((obj) => obj.id !== id);
break;
}
}
this.props.onAction({ type: "UPDATE_VIEWER", viewer: { slates } });
UserBehaviors.removeFromSlate({ slate: this.props.data, ids: [this.props.file.id] });
this.props.onNext();
};
render() {
const file = this.props.file;
const { type, coverImage } = file;
const editingAllowed = this.props.isOwner && !this.props.isRepost && !this.props.external;
const isUnityGame = Validations.isUnityType(type);
const isLink = file.isLink;
const showPreviewImageSection =
editingAllowed && type && !isLink && !Validations.isPreviewableImage(type);
const elements = [];
if (editingAllowed && !isUnityGame) {
elements.push(
<div key="sidebar-media-object-info" style={{ marginTop: 8 }}>
{isLink && (
<LinkTag
fillWidth={true}
url={file.url}
containerStyle={{
backgroundColor: Constants.system.grayDark4,
padding: "8px 16px",
borderRadius: 8,
marginBottom: 16,
}}
/>
)}
<Input
full
value={this.state.name}
name="name"
onChange={this._handleChange}
autoHighlight
id={`sidebar-label-name`}
style={{
fontSize: Constants.typescale.lvl1,
...STYLES_INPUT,
}}
textStyle={{ color: Constants.system.white }}
maxLength="255"
/>
<Textarea
name="body"
placeholder="Add notes or a description..."
value={this.state.body}
onChange={this._handleChange}
style={STYLES_INPUT}
maxLength="2000"
/>
<Input
full
value={this.state.source}
name="source"
placeholder="Source"
onChange={this._handleChange}
id={`sidebar-label-source`}
style={STYLES_INPUT}
textStyle={{ color: Constants.system.white }}
maxLength="255"
/>
<Input
full
value={this.state.author}
name="author"
placeholder="Author"
onChange={this._handleChange}
id={`sidebar-label-author`}
style={{ ...STYLES_INPUT, marginBottom: 12 }}
textStyle={{ color: Constants.system.white }}
maxLength="255"
/>
{/* <div css={STYLES_OPTIONS_SECTION}>
<Tag
type="dark"
tags={this.state.tags}
suggestions={this.state.suggestions}
style={STYLES_INPUT}
textStyle={{ color: Constants.system.white }}
// dropdownStyles={{ top: "50px" }}
onChange={this._handleChange}
/>
</div> */}
</div>
);
} else {
const hasName = !Strings.isEmpty(file.name || file.filename);
const hasBody = !Strings.isEmpty(file.body);
const hasSource = !Strings.isEmpty(file.source);
const hasAuthor = !Strings.isEmpty(file.author);
if (hasName) {
elements.push(
<div key="sidebar-media-info-name" css={STYLES_SIDEBAR_SECTION}>
<div css={STYLES_HEADING}>
<ProcessedText dark text={file.name || file.filename} />
</div>
</div>
);
}
if (isLink) {
elements.push(
<LinkTag
url={file.url}
fillWidth={true}
containerStyle={{
backgroundColor: Constants.system.grayDark4,
padding: "8px 16px",
borderRadius: 8,
marginBottom: 24,
}}
/>
);
}
if (hasBody) {
elements.push(
<div key="sidebar-media-info-body" css={STYLES_SIDEBAR_SECTION}>
<div css={STYLES_BODY}>
<ProcessedText dark text={file.body} />
</div>
</div>
);
}
if (hasSource) {
elements.push(
<div key="sidebar-media-info-source" css={STYLES_SIDEBAR_SECTION}>
<div css={STYLES_SIDEBAR_INPUT_LABEL} style={{ position: "relative" }}>
Source:
</div>
<p css={STYLES_BODY} style={{ color: Constants.system.grayLight2 }}>
<ProcessedText dark text={file.source} />
</p>
</div>
);
}
if (hasAuthor) {
elements.push(
<div key="sidebar-media-info-author" css={STYLES_SIDEBAR_SECTION}>
<div css={STYLES_SIDEBAR_INPUT_LABEL} style={{ position: "relative" }}>
Author:
</div>
<p css={STYLES_BODY} style={{ color: Constants.system.grayLight2 }}>
<ProcessedText dark text={file.author} />
</p>
</div>
);
}
}
let actions = [];
{
this.props.carouselType === "ACTIVITY"
? actions.push(
<div key="go-to-slate" style={{ borderBottom: "1px solid #3c3c3c" }}>
<Link href={`/$/slate/${file.slateId}`} onAction={this.props.onAction}>
<div
key="go-to-slate"
css={STYLES_ACTION}
// onClick={() =>
// this.props.onAction({
// type: "NAVIGATE",
// value: "NAV_SLATE",
// data: file.slate,
// })
// }
>
<SVG.Slate height="24px" />
<span style={{ marginLeft: 16 }}>Go to collection</span>
</div>
</Link>
</div>
)
: null;
}
if (!isLink) {
actions.push(
<div key="download" css={STYLES_ACTION} onClick={this._handleDownload}>
{this.state.isDownloading ? (
<>
<LoaderSpinner css={STYLES_SPINNER} />
<span style={{ marginLeft: 16 }}>Downloading</span>
</>
) : (
<>
<SVG.Download height="24px" />
<span style={{ marginLeft: 16 }}>Download</span>
</>
)}
</div>
);
}
if (!this.props.isOwner || this.props.isRepost) {
actions.push(
<div key="save-copy" css={STYLES_ACTION} onClick={() => this._handleSaveCopy(file)}>
<SVG.Save height="24px" />
<span style={{ marginLeft: 16 }}>
{this.state.loading === "savingCopy" ? (
<LoaderSpinner style={{ height: 16, width: 16 }} />
) : (
<span>Save</span>
)}
</span>
</div>
);
}
if (this.props.carouselType === "SLATE" && !this.props.external && this.props.isOwner) {
actions.push(
<div key="remove" css={STYLES_ACTION} onClick={this._handleRemove}>
<SVG.DismissCircle height="24px" />
<span style={{ marginLeft: 16 }}>Remove from collection</span>
</div>
);
}
if (editingAllowed) {
actions.push(
<div key="delete" css={STYLES_ACTION} onClick={() => this.setState({ modalShow: true })}>
<SVG.Trash height="24px" />
<span style={{ marginLeft: 16 }}>Delete</span>
</div>
);
}
let uploadCoverImage;
if (showPreviewImageSection) {
uploadCoverImage = (
<div style={{ marginBottom: 48 }}>
<System.P1 css={STYLES_SECTION_HEADER} style={{ margin: "48px 0px 8px 0px" }}>
Preview image
</System.P1>
{coverImage ? (
<>
<System.P1 css={STYLES_TEXT}>This is the preview image of your file.</System.P1>
<div css={STYLES_IMAGE_BOX} style={{ marginTop: 24 }}>
<img
src={Strings.getURLfromCID(coverImage.cid)}
alt=""
style={{ maxWidth: "368px", maxHeight: "368px" }}
/>
</div>
</>
) : (
<System.P1 css={STYLES_TEXT}>Add a preview image for your file.</System.P1>
)}
<div style={{ marginTop: 16 }}>
<input css={STYLES_FILE_HIDDEN} type="file" id="file" onChange={this._handleUpload} />
<System.ButtonPrimary full type="label" htmlFor="file" loading={this.state.isUploading}>
Upload preview image
</System.ButtonPrimary>
</div>
</div>
);
}
return (
<>
{this.state.modalShow && (
<ConfirmationModal
type={"DELETE"}
withValidation={false}
callback={this._handleDelete}
header={`Are you sure you want to delete the file “${this.state.name}”?`}
subHeader={`This file will be deleted from all connected collections and your file library. You cant undo this action.`}
/>
)}
<div css={STYLES_SIDEBAR} style={{ display: this.props.display, paddingBottom: 96 }}>
{this.state.showSavedMessage && (
<div css={STYLES_AUTOSAVE}>
<SVG.Check height="14px" style={{ marginRight: 4 }} />
Changes saved
</div>
)}
<div key="s-1" css={STYLES_DISMISS_BOX} onClick={this.props.onClose}>
<SVG.Dismiss height="24px" />
</div>
{elements}
<div css={STYLES_ACTIONS}>{actions}</div>
{uploadCoverImage}
{!this.props.external && this.props.viewer && (
<>
<div
css={STYLES_SECTION_HEADER}
style={{ cursor: "pointer", marginTop: 48 }}
onClick={() => this._handleToggleAccordion("showConnectedSection")}
>
<span
style={{
marginRight: 8,
transform: this.state.showConnectedSection ? "none" : "rotate(-90deg)",
transition: "100ms ease transform",
}}
>
<SVG.ChevronDown height="24px" display="block" />
</span>
<span>Add to collection</span>
</div>
{this.state.showConnectedSection && (
<div style={{ width: "100%", margin: "24px 0 44px 0" }}>
<SlatePicker
dark
slates={this.props.viewer.slates || []}
onCreateSlate={this._handleCreateSlate}
selectedColor={Constants.system.white}
files={[this.props.file]}
selected={this.state.selected}
onAdd={this._handleAdd}
/>
</div>
)}
</>
)}
{this.props.file.filename.endsWith(".md") ? (
<>
<div css={STYLES_SECTION_HEADER} style={{ margin: "48px 0px 8px 0px" }}>
Settings
</div>
<div css={STYLES_OPTIONS_SECTION}>
<div css={STYLES_TEXT}>Dark mode</div>
<Toggle dark active={this.props?.theme?.darkmode} onChange={this._handleDarkMode} />
</div>
</>
) : null}
</div>
</>
);
}
}
export default withTheme(CarouselSidebar);

View File

@ -3,7 +3,6 @@ import * as Styles from "~/common/styles";
import * as SVG from "~/common/svg";
import { css } from "@emotion/react";
import { useCollectionSharingModal } from "~/components/core/ShareModals/CollectionSharingModal";
const STYLES_BUTTON = (theme) => css`
${Styles.BUTTON_RESET};
@ -28,15 +27,14 @@ const STYLES_BUTTON = (theme) => css`
`;
export default function ShareButton({ user, collection, preview, ...props }) {
const { openModal } = useCollectionSharingModal();
const handleOnClick = (e) => {
e.preventDefault();
e.stopPropagation();
openModal({
user,
collection,
preview,
});
// openModal({
// user,
// collection,
// preview,
// });
};
return (

View File

@ -53,7 +53,8 @@ const CONTROLS_DARKMODE_WRAPPER = (theme) => css`
}
`;
const CONTROLS_SETTINGS_BUTTON = (isActive) => (theme) => css`
const CONTROLS_SETTINGS_BUTTON = (isActive) => (theme) =>
css`
padding: 8px 12px;
margin: 0;
border-radius: 4px;
@ -73,14 +74,22 @@ const CONTROLS_SETTINGS_BUTTON = (isActive) => (theme) => css`
stroke: ${theme.fontPreviewDarkMode ? theme.system.white : theme.system.black};
`
: css`
stroke: ${theme.fontPreviewDarkMode ? theme.system.grayLight2 : theme.semantic.textGray};
stroke: ${theme.fontPreviewDarkMode
? theme.system.grayLight2
: theme.semantic.textGray};
`}
}
`;
export const FixedControls = ({ onDarkMode, onLightMode, onToggleSettings, isSettingsVisible }) => {
export const FixedControls = ({
onDarkMode,
onLightMode,
onToggleSettings,
isSettingsVisible,
...props
}) => {
return (
<div css={CONTROLS_STYLES_WRAPPER}>
<div css={CONTROLS_STYLES_WRAPPER} {...props}>
<div>
<button css={CONTROLS_SETTINGS_BUTTON(isSettingsVisible)} onClick={onToggleSettings}>
<SVG.Sliders height={16} width={16} />

View File

@ -77,15 +77,6 @@ export default function FontFrame({ cid, fallback, ...props }) {
return (
<div css={GET_STYLES_CONTAINER} style={{ fontFamily: fontName }} {...props}>
<div css={STYLES_MOBILE_HIDDEN}>
<FixedControls
onDarkMode={setDarkMode}
onLightMode={setLightMode}
onToggleSettings={toggleSettings}
isDarkMode={currentState.context.darkmode}
isSettingsVisible={currentState.context.showSettings}
/>
</div>
<div style={{ position: "relative", flexGrow: 1, overflowY: "auto" }}>
{isFontLoading && <FontLoader />}
<FontView
@ -100,6 +91,16 @@ export default function FontFrame({ cid, fallback, ...props }) {
updateCustomView={updateCustomView}
/>
</div>
<div css={STYLES_MOBILE_HIDDEN}>
<FixedControls
style={{ marginBottom: 12 }}
onDarkMode={setDarkMode}
onLightMode={setLightMode}
onToggleSettings={toggleSettings}
isDarkMode={currentState.context.darkmode}
isSettingsVisible={currentState.context.showSettings}
/>
</div>
<div css={STYLES_MOBILE_HIDDEN}>
{currentState.context.showSettings && (
<Controls

View File

@ -1,10 +1,20 @@
import * as React from "react";
import * as System from "~/components/system";
import * as Styles from "~/common/styles";
import * as SVG from "~/common/svg";
import * as Constants from "~/common/constants";
import { ModalPortal } from "~/components/core/ModalPortal";
import { css } from "@emotion/react";
import { AnimatePresence, motion } from "framer-motion";
import {
AnimateSharedLayout,
AnimatePresence as FramerAnimatePresence,
motion,
} from "framer-motion";
import { useEscapeKey } from "~/common/hooks";
import { Show } from "~/components/utility/Show";
import ObjectBoxPreview from "~/components/core/ObjectBoxPreview";
/* -------------------------------------------------------------------------------------------------
* Root
@ -14,14 +24,16 @@ const JUMPER_WIDTH = 640;
const JUMPER_HEIGHT = 400;
const STYLES_JUMPER_ROOT = (theme) => css`
${Styles.VERTICAL_CONTAINER};
position: fixed;
top: calc(50% - ${JUMPER_HEIGHT / 2}px);
left: calc(50% - ${JUMPER_WIDTH / 2}px);
width: ${JUMPER_WIDTH}px;
height: ${JUMPER_HEIGHT}px;
min-height: ${JUMPER_HEIGHT}px;
z-index: ${theme.zindex.jumper};
border-radius: 16px;
border: 1px solid ${theme.semantic.borderGrayLight};
border: 1px solid ${theme.semantic.borderGrayLight4};
overflow: hidden;
background-color: ${theme.semantic.bgWhite};
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
-webkit-backdrop-filter: blur(75px);
@ -30,27 +42,84 @@ const STYLES_JUMPER_ROOT = (theme) => css`
}
`;
function Root({ children, isOpen, onClose, ...props }) {
const STYLES_JUMPER_OVERLAY = (theme) => css`
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: ${theme.zindex.jumper};
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
-webkit-backdrop-filter: blur(75px);
backdrop-filter: blur(75px);
background-color: ${theme.semantic.bgBlurLightTRN};
}
`;
const JumperContext = React.createContext({});
const useJumperContext = () => React.useContext(JumperContext);
function AnimatePresence({ children, ...props }) {
return <FramerAnimatePresence {...props}>{children}</FramerAnimatePresence>;
}
function Root({ children, onClose, ...props }) {
useEscapeKey(onClose);
return (
<AnimatePresence>
{isOpen ? (
<ModalPortal>
<System.Boundary enabled={true} onOutsideRectEvent={onClose}>
<div>
<motion.div
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 10, opacity: 0 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
transition={{ duration: 0.25, ease: "easeInOut" }}
css={STYLES_JUMPER_OVERLAY}
/>
<System.Boundary enabled={true} onOutsideRectEvent={onClose}>
<JumperContext.Provider value={{ onClose }}>
<motion.div
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 10, opacity: 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
css={STYLES_JUMPER_ROOT}
{...props}
>
{children}
</motion.div>
</JumperContext.Provider>
</System.Boundary>
</div>
</ModalPortal>
) : null}
</AnimatePresence>
);
}
/* -------------------------------------------------------------------------------------------------
* Header
* -----------------------------------------------------------------------------------------------*/
const STYLES_JUMPER_HEADER = css`
${Styles.HORIZONTAL_CONTAINER_CENTERED};
justify-content: space-between;
padding: 17px 20px 15px;
`;
function Header({ children, style, ...props }) {
const { onClose } = useJumperContext();
return (
<div css={STYLES_JUMPER_HEADER} style={style}>
<div style={{ width: "100%" }} {...props}>
{children}
</div>
<button
css={Styles.BUTTON_RESET}
style={{ width: 24, height: 24, marginLeft: 12 }}
onClick={onClose}
>
<SVG.Dismiss width={20} height={20} style={{ display: "block" }} />
</button>
</div>
);
}
@ -58,8 +127,16 @@ function Root({ children, isOpen, onClose, ...props }) {
* Item
* -----------------------------------------------------------------------------------------------*/
const STYLES_JUMPER_ITEM = css`
padding: 13px 20px 12px;
`;
function Item({ children, ...props }) {
return <div {...props}>{children}</div>;
return (
<div css={[STYLES_JUMPER_ITEM, css]} {...props}>
{children}
</div>
);
}
/* -------------------------------------------------------------------------------------------------
@ -73,4 +150,38 @@ function Divider({ children, ...props }) {
);
}
export { Root, Item, Divider };
/* -------------------------------------------------------------------------------------------------
* ObjectPreview
* -----------------------------------------------------------------------------------------------*/
function ObjectPreview({ file }) {
return (
<div
css={Styles.HORIZONTAL_CONTAINER_CENTERED}
style={{ color: Constants.system.green, width: "100%" }}
>
<div>
<SVG.CheckCircle />
</div>
<div style={{ marginLeft: 12, marginRight: 12 }}>
<AnimateSharedLayout>
<motion.div layoutId={`${file.id}-title`} key={`${file.id}-title`}>
<System.H5 nbrOflines={1} as="h1" style={{ wordBreak: "break-all" }} color="textBlack">
{file?.name || file?.filename}
</System.H5>
</motion.div>
</AnimateSharedLayout>
<Show when={file?.source}>
<System.P3 nbrOflines={1} color="textBlack" style={{ marginTop: 3 }}>
{file?.source}
</System.P3>
</Show>
</div>
<div style={{ marginLeft: "auto" }}>
<ObjectBoxPreview file={file} placeholderRatio={2} style={{ width: 28, height: 39 }} />
</div>
</div>
);
}
export { AnimatePresence, Root, Header, Item, Divider, ObjectPreview };

View File

@ -0,0 +1,19 @@
import * as React from "react";
import * as SVG from "~/common/svg";
import { useImage } from "~/common/hooks";
export default function LinkIcon({ file, width = 16, height = 16, style, ...props }) {
const { linkFavicon } = file;
const faviconImgState = useImage({ src: linkFavicon });
return faviconImgState.error ? (
<SVG.ExternalLink height={16} width={16} style={style} {...props} />
) : (
<img
src={linkFavicon}
alt="Link source logo"
style={{ borderRadius: "4px", height, width, ...style }}
{...props}
/>
);
}

View File

@ -16,9 +16,12 @@ const STYLES_ASSET = (theme) => css`
will-change: transform;
color: ${theme.darkmode ? theme.system.grayLight6 : theme.system.black};
background-color: ${theme.darkmode ? theme.system.black : theme.system.grayLight6};
@media (max-width: ${theme.sizes.mobile}px) {
padding: 64px 16px;
}
`;
const STYLES_BODY = css`
const STYLES_BODY = (theme) => css`
width: 100%;
/* 687px to ensure we have maximum 70ch per line */
max-width: 687px;
@ -60,6 +63,25 @@ const STYLES_BODY = css`
h4 + * {
margin-top: 0px;
}
@media (max-width: ${theme.sizes.mobile}px) {
p,
ul,
ol,
code,
pre,
div {
margin-top: 16px;
}
h1,
h2,
h3,
h4 {
margin-top: 28px;
margin-bottom: 16px;
}
}
`;
const STYLES_IMG = css`
@ -89,6 +111,9 @@ const STYLES_DIVIDER = (theme) => css`
margin-bottom: 58px;
background-color: ${theme.system.grayLight2};
transition: height 0.3s;
@media (max-width: ${theme.sizes.mobile}px) {
top: -64px;
}
`;
const STYLE_PROGRESS = (theme) => css`

View File

@ -0,0 +1,48 @@
import * as React from "react";
import * as Validations from "~/common/validations";
import * as Strings from "~/common/strings";
import { css } from "@emotion/react";
import ObjectPlaceholder from "~/components/core/ObjectPreview/placeholders";
const STYLES_PLACEHOLDER_CONTAINER = css`
height: 100%;
width: 100%;
min-width: auto;
`;
const STYLES_PREVIEW = css`
height: 100%;
width: 100%;
background-size: cover;
overflow: hidden;
img {
height: 100%;
width: 100%;
object-fit: cover;
}
`;
export default function BlobObjectPreview({ file, css, placeholderRatio = 1, ...props }) {
const isImage = Validations.isPreviewableImage(file.type);
const url = Strings.getURLfromCID(file.cid);
if (isImage) {
return (
<div css={[STYLES_PREVIEW, css]} {...props}>
<img src={url} alt="File preview" />
</div>
);
}
return (
<div css={[STYLES_PREVIEW, css]} {...props}>
<ObjectPlaceholder
ratio={placeholderRatio}
containerCss={STYLES_PLACEHOLDER_CONTAINER}
file={file}
/>
</div>
);
}

View File

@ -5,6 +5,7 @@ import * as Constants from "~/common/constants";
import { P3 } from "~/components/system/components/Typography";
import { css } from "@emotion/react";
import { useImage } from "~/common/hooks";
import ObjectPreviewPrimitive from "~/components/core/ObjectPreview/ObjectPreviewPrimitive";
import LinkPlaceholder from "~/components/core/ObjectPreview/placeholders/Link";
@ -120,29 +121,3 @@ export default function LinkObjectPreview({ file, ratio, ...props }) {
</ObjectPreviewPrimitive>
);
}
const useImage = ({ src, maxWidth }) => {
const [imgState, setImgState] = React.useState({
loaded: false,
error: true,
overflow: false,
});
React.useEffect(() => {
if (!src) setImgState({ error: true, loaded: true });
const img = new Image();
img.src = src;
img.onload = () => {
if (maxWidth && img.naturalWidth < maxWidth) {
setImgState((prev) => ({ ...prev, loaded: true, error: false, overflow: true }));
} else {
setImgState({ loaded: true, error: false });
}
};
img.onerror = () => setImgState({ loaded: true, error: true });
}, []);
return imgState;
};

View File

@ -1,155 +0,0 @@
import * as React from "react";
import * as Events from "~/common/custom-events";
import * as Styles from "~/common/styles";
import * as SVG from "~/common/svg";
import * as Utilities from "~/common/utilities";
import { css } from "@emotion/react";
import { P3 } from "~/components/system/components/Typography";
import { Divider } from "~/components/system";
import { motion } from "framer-motion";
import { useEventListener, useTimeout } from "~/common/hooks";
import { ShareModalPrimitive } from "~/components/core/ShareModals/ShareModalPrimitive";
const STYLES_COPY_ACTIONS_WRAPPER = css`
border-radius: 12px;
overflow: hidden;
`;
const STYLES_COPY_BUTTON = (theme) => css`
${Styles.BUTTON_RESET};
background-color: ${theme.system.white};
width: 100%;
padding: 9px 12px 11px;
`;
const formatDataSafely = ({ collection, user }) => {
if (typeof window === "undefined" || !collection || !user) return {};
const rootUrl = window?.location?.origin;
return {
id: collection.id,
title: collection?.name || collection?.slatename,
link: `${rootUrl}/${user.username}/${collection.slatename}`,
};
};
export const CollectionSharingModal = () => {
const [state, handlers] = useModalState();
const { open, user, collection, view, preview } = state;
const { closeModal, changeView, handleModalVisibility } = handlers;
const { id, title, link } = React.useMemo(() => formatDataSafely({ collection, user }), [
user,
collection,
]);
useEventListener("collection-sharing-modal", handleModalVisibility, []);
const handleTwitterSharing = () =>
window.open(
`https://twitter.com/intent/tweet?text=${title} by ${user.username} on Slate%0D&url=${link}`,
"_blank"
);
const handleEmailSharing = () => {
window.open(`mailto: ?subject=${title} by ${user.username} on Slate&body=${link}`, "_b");
};
const handleLinkCopy = () => (Utilities.copyToClipboard(link), changeView("LINK_COPIED"));
const handleIdCopy = () => (Utilities.copyToClipboard(id), changeView("ID_COPIED"));
return (
<ShareModalPrimitive
isOpen={open}
closeModal={closeModal}
title={title}
preview={preview}
description={`Collection @${user.username}`}
onEmailSharing={handleEmailSharing}
includeSocialSharing={!(view === "LINK_COPIED" || view === "ID_COPIED")}
onTwitterSharing={handleTwitterSharing}
>
{view === "initial" ? (
<div css={STYLES_COPY_ACTIONS_WRAPPER}>
<button css={STYLES_COPY_BUTTON} onClick={handleLinkCopy}>
<span css={Styles.HORIZONTAL_CONTAINER}>
<SVG.Link width={16} height={16} />
<P3 style={{ marginLeft: 8 }}>Copy Link</P3>
</span>
</button>
<Divider height={1} color="bgGrayLight" />
<button css={STYLES_COPY_BUTTON} onClick={handleIdCopy}>
<span css={Styles.HORIZONTAL_CONTAINER}>
<SVG.Hash width={16} height={16} />
<P3 style={{ marginLeft: 8 }}>Copy ID</P3>
</span>
</button>
</div>
) : (
<LinkCopiedScene closeModal={closeModal}>
{view === "LINK_COPIED" ? "Link copied to clipboard" : "ID copied to clipboard"}
</LinkCopiedScene>
)}
</ShareModalPrimitive>
);
};
const LinkCopiedScene = ({ closeModal, children }) => {
useTimeout(closeModal, 3000);
return (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ease: "easeOut", duration: 0.3, delay: 0.4 }}
style={{ marginTop: 24 }}
css={Styles.CONTAINER_CENTERED}
>
<SVG.Clipboard />
<P3 style={{ marginLeft: 8 }} color="textBlack">
{children}
</P3>
</motion.div>
);
};
const useModalState = () => {
const [state, setState] = React.useState({
open: false,
// NOTE(amine): INITIAL || LINK_COPIED || ID_COPIED
view: "initial",
user: {},
data: {},
preview: {},
});
const handlers = React.useMemo(
() => ({
closeModal: () => setState((prev) => ({ ...prev, open: false })),
changeView: (view) => setState((prev) => ({ ...prev, view })),
handleModalVisibility: (e) =>
setState(() => ({
view: "initial",
open: e.detail.open,
user: e.detail.user,
collection: e.detail.collection,
preview: e.detail.preview,
})),
}),
[]
);
return [state, handlers];
};
export const useCollectionSharingModal = () => {
const openModal = ({ user, collection, preview }) =>
Events.dispatchCustomEvent({
name: "collection-sharing-modal",
detail: { open: true, user, collection, preview },
});
const closeModal = () =>
Events.dispatchCustomEvent({
name: "collection-sharing-modal",
detail: { open: false },
});
return { openModal, closeModal };
};

View File

@ -1,185 +0,0 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as SVG from "~/common/svg";
import * as Constants from "~/common/constants";
import { css } from "@emotion/react";
import { H5, P3 } from "~/components/system/components/Typography";
import { AnimatePresence, motion } from "framer-motion";
import { useEscapeKey, useLockScroll } from "~/common/hooks";
import { Preview } from "~/components/core/CollectionPreviewBlock/components";
import { Symbol } from "~/common/logo";
const STYLES_OVERLAY = (theme) => css`
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 100%;
background-color: ${theme.semantic.bgBlurBlackTRN};
${Styles.CONTAINER_CENTERED};
z-index: ${theme.zindex.modal};
`;
const STYLES_MODAL_WRAPPER = (theme) => css`
min-width: 400px;
padding: 24px;
border: 1px solid ${theme.semantic.bgGrayLight};
border-radius: 16px;
background: ${theme.system.white};
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
-webkit-backdrop-filter: blur(75px);
backdrop-filter: blur(75px);
background: linear-gradient(
120.84deg,
rgba(255, 255, 255, 0.85) 5.24%,
rgba(255, 255, 255, 0.544) 98.51%
);
}
@media (max-width: ${theme.sizes.mobile}px) {
min-width: auto;
width: calc(100% - 32px);
}
`;
const STYLES_PROFILE = css`
display: block;
${Styles.HORIZONTAL_CONTAINER};
align-items: flex-start;
`;
const STYLES_DISMISS_BUTTON = (theme) => css`
${Styles.BUTTON_RESET};
color: ${theme.semantic.textGray};
`;
// NOTE(amine): social buttons styles
const STYLES_SOCIAL_BUTTON = (theme) => css`
${Styles.VERTICAL_CONTAINER_CENTERED}
padding: 16px;
border-radius: 12px;
box-shadow: ${theme.shadow.lightLarge};
`;
const STYLES_TWITTER_BUTTON = css`
background-color: #58aee7;
`;
const STYLES_EMAIL_BUTTON = (theme) => css`
background-color: ${theme.system.white};
`;
const STYLES_TEXT_CENTER = css`
text-align: center;
`;
const STYLES_PREVIEW = (theme) => css`
width: 48px;
max-width: 48px;
height: 48px;
border-radius: 12px;
background-color: ${theme.semantic.bgLight};
${Styles.CONTAINER_CENTERED}
`;
const EmptyFallbackPreview = () => {
return (
<div css={STYLES_PREVIEW}>
<Symbol style={{ height: 24, color: Constants.system.grayLight2 }} />
</div>
);
};
// NOTE(amine): This modal will be a building block for both Object and Collection sharing modals
export const ShareModalPrimitive = ({
isOpen,
closeModal,
title,
description,
includeSocialSharing = true,
onTwitterSharing,
onEmailSharing,
preview,
children,
}) => {
useEscapeKey(closeModal);
useLockScroll({ lock: isOpen });
const handleModalClick = (e) => (e.preventDefault(), e.stopPropagation());
return (
<AnimatePresence>
{isOpen ? (
<motion.div
exit={{ opacity: 0 }}
transition={{ duration: 0.35 }}
css={STYLES_OVERLAY}
onClick={closeModal}
>
<motion.div
layoutId
exit={{ y: 50 }}
transition={{ duration: 0.5, easings: "easeInOut" }}
css={STYLES_MODAL_WRAPPER}
onClick={handleModalClick}
>
<motion.div layout css={STYLES_PROFILE} style={{ alignItems: "flex-start" }}>
{/**TODO(amine): add dynamic preview to support collection and object previews */}
<Preview
file={preview.object}
type={preview.type}
EmptyFallback={EmptyFallbackPreview}
css={STYLES_PREVIEW}
placeholderRatio={2}
/>
<div style={{ marginLeft: 12, flexGrow: 1 }}>
<H5 color="textBlack" nbrOflines={1}>
{title}
</H5>
<P3 style={{ marginTop: 3 }} color="textGrayDark" nbrOflines={1}>
{description}
</P3>
</div>
<button css={STYLES_DISMISS_BUTTON} style={{ marginLeft: 13 }} onClick={closeModal}>
<span>
<SVG.Dismiss height={20} />
</span>
</button>
</motion.div>
{includeSocialSharing && (
<div css={Styles.CONTAINER_CENTERED} style={{ marginTop: 24 }}>
<button css={Styles.BUTTON_RESET} onClick={onTwitterSharing}>
<span>
<div css={[STYLES_SOCIAL_BUTTON, STYLES_TWITTER_BUTTON]}>
<SVG.TwitterWhiteLogo style={{ display: "block" }} />
</div>
<P3 style={{ marginTop: 4 }} css={STYLES_TEXT_CENTER}>
Twitter
</P3>
</span>
</button>
<button
css={Styles.BUTTON_RESET}
style={{ marginLeft: 24 }}
onClick={onEmailSharing}
>
<span>
<div css={[STYLES_SOCIAL_BUTTON, STYLES_EMAIL_BUTTON]}>
<SVG.Mail style={{ display: "block" }} />
</div>
<P3 style={{ marginTop: 4 }} css={STYLES_TEXT_CENTER}>
Email
</P3>
</span>
</button>
</div>
)}
<div style={{ marginTop: 24 }}>{children}</div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
};

View File

@ -11,6 +11,7 @@ import MarkdownFrame from "~/components/core/MarkdownFrame";
import SlateLinkObject from "~/components/core/SlateLinkObject";
import { css } from "@emotion/react";
import { Show } from "~/components/utility/Show";
const STYLES_FAILURE = css`
color: ${Constants.system.white};
@ -20,8 +21,6 @@ const STYLES_FAILURE = css`
font-size: 24px;
margin: 0;
padding: 24px 36px;
height: 100px;
border-radius: 4px;
width: 100%;
min-height: 10%;
height: 100%;
@ -39,7 +38,8 @@ const STYLES_OBJECT = css`
user-select: none;
`;
const STYLES_ASSET = css`
const STYLES_ASSET = (theme) => css`
position: relative;
user-select: none;
width: 100%;
margin: 0;
@ -50,6 +50,11 @@ const STYLES_ASSET = css`
align-items: center;
justify-content: center;
position: relative;
@supports ((-webkit-backdrop-filter: blur(500px)) or (backdrop-filter: blur(500px))) {
background-color: ${theme.semantic.bgBlurWhiteTRN};
-webkit-backdrop-filter: blur(500px);
backdrop-filter: blur(500px);
}
`;
const STYLES_IMAGE = css`
@ -59,6 +64,21 @@ const STYLES_IMAGE = css`
max-height: 100%;
`;
const STYLES_IMAGE_WRAPPER = css`
position: relative;
width: 100%;
height: 100%;
`;
const STYLES_IMAGE_BLUR = css`
top: 0;
left: 0;
position: absolute;
background-size: cover;
width: 100%;
height: 100%;
`;
const STYLES_IFRAME = (theme) => css`
display: block;
width: 100%;
@ -103,7 +123,7 @@ export default class SlateMediaObject extends React.Component {
return (
<>
{isMobile ? (
<a href={url} target="_blank" style={{ textDecoration: "none" }}>
<a href={url} target="_blank" style={{ textDecoration: "none", height: "100%" }}>
<div css={STYLES_FAILURE}>Tap to open PDF in new tab</div>
</a>
) : (
@ -183,6 +203,10 @@ export default class SlateMediaObject extends React.Component {
if (Validations.isPreviewableImage(type)) {
return (
<div css={STYLES_IMAGE_WRAPPER}>
<Show when={!Validations.isGif(type)}>
<div style={{ backgroundImage: `url(${url})` }} css={STYLES_IMAGE_BLUR} />
</Show>
<div css={STYLES_ASSET}>
<img
css={STYLES_IMAGE}
@ -192,6 +216,7 @@ export default class SlateMediaObject extends React.Component {
}}
/>
</div>
</div>
);
}

View File

@ -66,8 +66,8 @@ export default function DropIndicator({ data }) {
setDroppingState({ totalFilesDropped: files.length || undefined, isDroppingFiles: true });
};
useEventListener("dragenter", handleDragEnter, []);
useEventListener("dragover", handleDragOver, []);
useEventListener({ type: "dragenter", handler: handleDragEnter }, []);
useEventListener({ type: "dragover", handler: handleDragOver }, []);
return (
<AnimatePresence>

View File

@ -12,11 +12,6 @@ import { css } from "@emotion/react";
import { useUploadContext } from "~/components/core/Upload/Provider";
import { AnimatePresence, motion } from "framer-motion";
const STYLES_JUMPER_HEADER = css`
${Styles.HORIZONTAL_CONTAINER_CENTERED};
padding: 17px 20px 15px;
`;
const STYLES_LINK_INPUT = (theme) => css`
width: 392px;
border-radius: 12;
@ -62,11 +57,6 @@ const STYLES_FILES_UPLOAD_WRAPPER = css`
padding-bottom: 55px;
`;
const STYLES_JUMPER_DISMISS_BUTTON = (theme) => css`
${Styles.BUTTON_RESET};
color: ${theme.semantic.textGray};
`;
export function UploadJumper({ data }) {
const [{ isUploadJumperVisible }, { upload, uploadLink, hideUploadJumper }] = useUploadContext();
@ -104,29 +94,12 @@ export function UploadJumper({ data }) {
};
return (
<>
<AnimatePresence>
{isUploadJumperVisible && (
<motion.div
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 10, opacity: 0 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
css={STYLES_JUMPER_OVERLAY}
/>
)}
</AnimatePresence>
<Jumper.Root isOpen={isUploadJumperVisible} onClose={hideUploadJumper}>
<Jumper.Item css={STYLES_JUMPER_HEADER}>
<Jumper.AnimatePresence>
{isUploadJumperVisible ? (
<Jumper.Root onClose={hideUploadJumper}>
<Jumper.Header>
<System.H5 color="textBlack">Upload</System.H5>
<button
style={{ marginLeft: "auto" }}
css={STYLES_JUMPER_DISMISS_BUTTON}
onClick={hideUploadJumper}
>
<SVG.Dismiss width={20} style={{ display: "block" }} />
</button>
</Jumper.Item>
</Jumper.Header>
<Jumper.Divider />
<Jumper.Item css={STYLES_LINK_UPLOAD_WRAPPER}>
<div css={Styles.HORIZONTAL_CONTAINER}>
@ -153,7 +126,13 @@ export function UploadJumper({ data }) {
</Jumper.Item>
<Jumper.Divider />
<Jumper.Item css={STYLES_FILES_UPLOAD_WRAPPER}>
<input css={STYLES_FILE_HIDDEN} multiple type="file" id="file" onChange={handleUpload} />
<input
css={STYLES_FILE_HIDDEN}
multiple
type="file"
id="file"
onChange={handleUpload}
/>
<System.H5 color="textGrayDark" as="p" style={{ textAlign: "center" }}>
Drop or select files to save to Slate
<br />
@ -173,6 +152,7 @@ export function UploadJumper({ data }) {
</System.ButtonTertiary>
</Jumper.Item>
</Jumper.Root>
</>
) : null}
</Jumper.AnimatePresence>
);
}

View File

@ -169,11 +169,14 @@ export function Popup() {
{ hideUploadPopup, expandUploadSummary, collapseUploadSummary, cancelAutoCollapseOnMouseEnter },
] = useUploadPopup({ totalFilesSummary });
if (!popupState.isVisible) return null;
return (
<div
<AnimatePresence>
{popupState.isVisible ? (
<motion.div
css={STYLES_POPUP_WRAPPER}
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
onMouseEnter={handleOnMouseEnter}
onMouseLeave={handleOnMouseLeave}
>
@ -199,11 +202,16 @@ export function Popup() {
/>
</div>
<Show when={isHovered && isFinished}>
<button css={STYLES_DISMISS_BUTTON} onClick={() => (hideUploadPopup(), resetUploadState())}>
<button
css={STYLES_DISMISS_BUTTON}
onClick={() => (hideUploadPopup(), resetUploadState())}
>
<SVG.Dismiss width={16} />
</button>
</Show>
</div>
</motion.div>
) : null}
</AnimatePresence>
);
}

View File

@ -23,7 +23,7 @@ export const Provider = ({ children, page, data, viewer }) => {
viewer,
});
useEventListener("open-upload-jumper", showUploadJumper);
useEventListener({ type: "open-upload-jumper", handler: showUploadJumper });
const providerValue = React.useMemo(
() => [
@ -223,10 +223,10 @@ const useUploadOnDrop = ({ upload, page, data, viewer }) => {
upload({ files, slate });
};
useEventListener("dragenter", handleDragEnter, []);
useEventListener("dragleave", handleDragLeave, []);
useEventListener("dragover", handleDragOver, []);
useEventListener("drop", handleDrop, []);
useEventListener({ type: "dragenter", handler: handleDragEnter }, []);
useEventListener({ type: "dragleave", handler: handleDragLeave }, []);
useEventListener({ type: "dragover", handler: handleDragOver }, []);
useEventListener({ type: "drop", handler: handleDrop }, []);
};
const useUploadFromClipboard = ({ upload, uploadLink, page, data, viewer }) => {
@ -259,5 +259,5 @@ const useUploadFromClipboard = ({ upload, uploadLink, page, data, viewer }) => {
upload({ files, slate });
};
useEventListener("paste", handlePaste, []);
useEventListener({ type: "paste", handler: handlePaste }, []);
};

View File

@ -16,8 +16,8 @@ const Root = ({ children, data }) => {
return (
<>
{children}
<ModalPortal>
<Jumper data={data} />
<ModalPortal>
<Popup />
<DropIndicator data={data} />
</ModalPortal>

View File

@ -59,6 +59,7 @@ export const ButtonPrimary = (props) => {
<button
css={props.transparent ? STYLES_BUTTON_PRIMARY_TRANSPARENT : STYLES_BUTTON_PRIMARY}
style={{ width: props.full ? "100%" : "auto", ...props.style }}
type={props.type}
>
<LoaderSpinner style={{ height: 16, width: 16, color: Constants.system.white }} />
</button>
@ -85,6 +86,7 @@ export const ButtonPrimary = (props) => {
style={{ width: props.full ? "100%" : "auto", ...props.style }}
onClick={props.onClick}
children={props.children}
type={props.type}
/>
);
}
@ -95,6 +97,7 @@ export const ButtonPrimary = (props) => {
style={{ width: props.full ? "100%" : "auto", ...props.style }}
onClick={props.onClick}
children={props.children}
type={props.type}
/>
);
};
@ -133,6 +136,7 @@ export const ButtonSecondary = (props) => {
<button
css={props.transparent ? STYLES_BUTTON_SECONDARY_TRANSPARENT : STYLES_BUTTON_SECONDARY}
style={{ width: props.full ? "100%" : "auto", ...props.style }}
type={props.type}
>
<LoaderSpinner style={{ height: 16, width: 16 }} />
</button>
@ -157,6 +161,7 @@ export const ButtonSecondary = (props) => {
css={props.transparent ? STYLES_BUTTON_SECONDARY_TRANSPARENT : STYLES_BUTTON_SECONDARY}
onClick={props.onClick}
children={props.children}
type={props.type}
style={{ width: props.full ? "100%" : "auto", ...props.style }}
/>
);
@ -172,6 +177,7 @@ const STYLES_BUTTON_TERTIARY = css`
color: ${Constants.system.black};
background-color: ${Constants.system.white};
box-shadow: 0 0 0 1px ${Constants.semantic.borderGrayLight} inset;
text-decoration: none;
:hover {
background-color: #fcfcfc;
@ -187,41 +193,57 @@ const STYLES_BUTTON_TERTIARY_TRANSPARENT = css`
${STYLES_BUTTON}
cursor: pointer;
background-color: transparent;
text-decoration: none;
color: ${Constants.system.grayLight2};
`;
export const ButtonTertiary = (props) => {
if (props.loading) {
export const ButtonTertiary = ({ loading, type, transparent, full, style, children, ...props }) => {
if (loading) {
return (
<button
css={props.transparent ? STYLES_BUTTON_TERTIARY_TRANSPARENT : STYLES_BUTTON_TERTIARY}
style={{ width: props.full ? "100%" : "auto", ...props.style }}
css={transparent ? STYLES_BUTTON_TERTIARY_TRANSPARENT : STYLES_BUTTON_TERTIARY}
style={{ width: full ? "100%" : "auto", ...style }}
type={type}
{...props}
>
<LoaderSpinner style={{ height: 16, width: 16 }} />
</button>
);
}
if (props.type === "label") {
if (type === "label") {
return (
<label
css={props.transparent ? STYLES_BUTTON_TERTIARY_TRANSPARENT : STYLES_BUTTON_TERTIARY}
style={{ width: props.full ? "100%" : "auto", ...props.style }}
onClick={props.onClick}
children={props.children}
type={props.label}
htmlFor={props.htmlFor}
/>
css={transparent ? STYLES_BUTTON_TERTIARY_TRANSPARENT : STYLES_BUTTON_TERTIARY}
style={{ width: full ? "100%" : "auto", ...style }}
{...props}
>
{children}
</label>
);
}
if (type === "link") {
return (
<a
css={transparent ? STYLES_BUTTON_TERTIARY_TRANSPARENT : STYLES_BUTTON_TERTIARY}
style={{ width: full ? "100%" : "auto", ...style }}
{...props}
>
{children}
</a>
);
}
return (
<button
css={props.transparent ? STYLES_BUTTON_TERTIARY_TRANSPARENT : STYLES_BUTTON_TERTIARY}
onClick={props.onClick}
children={props.children}
style={{ width: props.full ? "100%" : "auto", ...props.style }}
/>
css={transparent ? STYLES_BUTTON_TERTIARY_TRANSPARENT : STYLES_BUTTON_TERTIARY}
style={{ width: full ? "100%" : "auto", ...style }}
type={type}
{...props}
>
{children}
</button>
);
};
@ -303,6 +325,7 @@ export const ButtonWarning = (props) => {
<button
css={props.transparent ? STYLES_BUTTON_WARNING_TRANSPARENT : STYLES_BUTTON_WARNING}
style={{ width: props.full ? "100%" : "auto", ...props.style }}
type={props.type}
>
<LoaderSpinner style={{ height: 16, width: 16, color: Constants.system.white }} />
</button>
@ -327,6 +350,7 @@ export const ButtonWarning = (props) => {
<button
css={STYLES_BUTTON_WARNING_DISABLED}
style={{ width: props.full ? "100%" : "auto", ...props.style }}
type={props.type}
onClick={props.onClick}
children={props.children}
/>
@ -337,6 +361,7 @@ export const ButtonWarning = (props) => {
<button
css={props.transparent ? STYLES_BUTTON_WARNING_TRANSPARENT : STYLES_BUTTON_WARNING}
style={{ width: props.full ? "100%" : "auto", ...props.style }}
type={props.type}
onClick={props.onClick}
children={props.children}
/>

View File

@ -1,421 +0,0 @@
import * as React from "react";
import * as Constants from "~/common/constants";
import * as SVG from "~/common/svg";
import * as Strings from "~/common/strings";
import * as Actions from "~/common/actions";
import * as Events from "~/common/custom-events";
import { css } from "@emotion/react";
import { Alert } from "~/components/core/Alert";
import CarouselSidebar from "~/components/core/CarouselSidebar";
import SlateMediaObject from "~/components/core/SlateMediaObject";
const STYLES_ROOT = css`
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: 0;
display: flex;
align-items: center;
justify-content: space-between;
color: ${Constants.system.white};
z-index: ${Constants.zindex.modal};
background-color: rgba(0, 0, 0, 0.8);
// Note(Amine): we're using the blur filter to fix a weird backdrop-filter's bug in chrome
filter: blur(0px);
@supports ((-webkit-backdrop-filter: blur(15px)) or (backdrop-filter: blur(15px))) {
-webkit-backdrop-filter: blur(15px);
backdrop-filter: blur(15px);
}
@keyframes global-carousel-fade-in {
from {
transform: translateX(8px);
opacity: 0;
}
to {
transform: translateX(0px);
opacity: 1;
}
}
animation: global-carousel-fade-in 400ms ease;
`;
const STYLES_ROOT_CONTENT = css`
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
`;
const STYLES_BOX = css`
user-select: none;
height: 32px;
width: 32px;
border-radius: 32px;
position: absolute;
display: inline-flex;
align-items: center;
justify-content: center;
z-index: ${Constants.zindex.modal};
background: ${Constants.system.black};
color: ${Constants.system.white};
cursor: pointer;
margin: auto;
:hover {
background-color: ${Constants.system.black};
}
`;
const STYLES_EXPANDER = css`
color: ${Constants.system.grayLight2};
position: absolute;
padding: 4px;
top: 16px;
left: 16px;
cursor: pointer;
:hover {
color: ${Constants.system.white};
}
`;
const STYLES_DISMISS_BOX = css`
position: absolute;
top: 16px;
right: 16px;
color: ${Constants.system.grayLight2};
cursor: pointer;
:hover {
color: ${Constants.system.white};
}
`;
const STYLES_MOBILE_ONLY = css`
@media (min-width: ${Constants.sizes.mobile}px) {
display: none;
}
`;
const STYLES_MOBILE_HIDDEN = css`
@media (max-width: ${Constants.sizes.mobile}px) {
display: none;
}
`;
export class GlobalCarousel extends React.Component {
state = {
showSidebar: true,
};
componentDidMount = () => {
window.addEventListener("keydown", this._handleKeyDown);
if (this.props.params?.cid) {
const index = this.findSelectedIndex();
this.props.onChange(index);
}
// window.addEventListener("slate-global-open-carousel", this._handleOpen);
// window.addEventListener("slate-global-close-carousel", this._handleClose);
};
componentWillUnmount = () => {
window.removeEventListener("keydown", this._handleKeyDown);
// window.removeEventListener("slate-global-open-carousel", this._handleOpen);
// window.removeEventListener("slate-global-close-carousel", this._handleClose);
};
componentDidUpdate = (prevProps) => {
if (this.props.index === -1 && this.props.params?.cid !== prevProps.params?.cid) {
const index = this.findSelectedIndex();
if (index !== this.props.index) {
this.props.onChange(index);
}
}
};
findSelectedIndex = () => {
const cid = this.props.params?.cid;
if (!cid) {
return -1;
}
let index = this.props.objects.findIndex((elem) => elem.cid === cid);
return index;
};
_handleKeyDown = (e) => {
const inputs = document.querySelectorAll("input");
for (let elem of inputs) {
if (document.activeElement === elem) {
return;
}
}
const textareas = document.querySelectorAll("textarea");
for (let elem of textareas) {
if (document.activeElement === elem) {
return;
}
}
switch (e.key) {
case "Escape":
this._handleClose();
break;
case "Right":
case "ArrowRight":
this._handleNext();
break;
case "Left":
case "ArrowLeft":
this._handlePrevious();
break;
}
};
// setWindowState = (data = {}) => {
// const cid = data?.cid;
// if (this.props.carouselType === "ACTIVITY") {
// window.history.replaceState(
// { ...window.history.state, cid: cid },
// null,
// cid ? `/${data.owner}/${data.slate.slatename}/cid:${cid}` : `/_?scene=NAV_ACTIVITY`
// );
// return;
// }
// let baseURL = window.location.pathname.split("/");
// if (this.props.carouselType === "SLATE") {
// baseURL.length = 3;
// } else if (this.props.carouselType === "PROFILE") {
// baseURL.length = 2;
// } else if (this.props.carouselType === "DATA") {
// baseURL.length = 2;
// if (cid) {
// baseURL[1] = this.props.viewer.username;
// } else {
// baseURL[1] = "_?scene=NAV_DATA";
// }
// }
// baseURL = baseURL.join("/");
// window.history.replaceState(
// { ...window.history.state, cid: cid },
// null,
// cid ? `${baseURL}/cid:${cid}` : baseURL
// );
// };
// _handleOpen = (e) => {
// let index = e.detail.index;
// const objects = this.props.objects;
// if (e.detail.index === null) {
// if (e.detail.id !== null) {
// index = objects.findIndex((obj) => obj.id === e.detail.id);
// }
// }
// if (index === null || index < 0 || index >= objects.length) {
// return;
// }
// this.setState({
// visible: true,
// index: e.detail.index,
// });
// const data = objects[e.detail.index];
// this.setWindowState(data);
// };
_handleClose = (e) => {
if (e) {
e.stopPropagation();
e.preventDefault();
}
// if (this.props.onChange) {
// this.props.onChange(-1);
// } else {
let params = { ...this.props.params };
delete params.cid;
this.props.onAction({
type: "UPDATE_PARAMS",
params,
redirect: true,
});
this.props.onChange(-1);
// }
// this.setState({ visible: false, index: 0 });
// this.setWindowState();
};
_handleNext = (e) => {
if (e) {
e.stopPropagation();
}
// if (this.props.onChange) {
// let index = this.props.index + 1;
// if (index >= this.props.objects.length) {
// this._handleClose();
// return;
// }
// this.props.onChange(index);
// } else {
let index = this.props.index + 1;
if (index >= this.props.objects.length) {
// this._handleClose();
return;
}
let cid = this.props.objects[index].cid;
this.props.onChange(index);
this.props.onAction({
type: "UPDATE_PARAMS",
params: { ...this.props.params, cid },
redirect: true,
});
// }
// const data = this.props.objects[index];
// this.setWindowState(data);
};
//it uses the initial cid to set which index it is, then it goes off its internal index from there and sets apge still but doesn't get from it?
//though that
//maybe the initial open is triggered by page, combined with index?
//or mayube
_handlePrevious = (e) => {
if (e) {
e.stopPropagation();
}
// if (this.props.onChange) {
// let index = this.props.index - 1;
// if (index < 0) {
// this._handleClose();
// return;
// }
// this.props.onChange(index);
// } else {
let index = this.props.index - 1;
if (index < 0) {
// this._handleClose();
return;
}
let cid = this.props.objects[index].cid;
this.props.onChange(index);
this.props.onAction({
type: "UPDATE_PARAMS",
params: { ...this.props.params, cid },
redirect: true,
});
// }
// const data = this.props.objects[index];
// this.setWindowState(data);
};
_handleToggleSidebar = (e) => {
if (e) {
e.stopPropagation();
}
this.setState({ showSidebar: !this.state.showSidebar });
};
render() {
let index = this.props.index;
if (!this.props.carouselType || index < 0 || index >= this.props.objects.length) {
return null;
}
let file = this.props.objects[index];
if (!file) {
return null;
}
let { isMobile } = this.props;
let isRepost = false;
if (this.props.carouselType === "SLATE") {
isRepost = this.props.data?.ownerId !== file.ownerId;
}
let slide = <SlateMediaObject file={file} isMobile={isMobile} />;
return (
<div css={STYLES_ROOT}>
<Alert
viewer={this.props.viewer}
noWarning
id={isMobile ? "slate-mobile-alert" : null}
style={
isMobile
? null
: {
bottom: 0,
top: "auto",
paddingRight: this.props.sidebar
? `calc(${Constants.sizes.sidebar}px + 48px)`
: "auto",
}
}
/>
<div css={STYLES_ROOT_CONTENT} style={this.props.style} onClick={this._handleClose}>
{index > 0 && (
<span
css={STYLES_BOX}
onClick={this._handlePrevious}
style={{ top: 0, left: 16, bottom: 0 }}
>
<SVG.ChevronLeft height="20px" />
</span>
)}
{index < this.props.objects.length - 1 && (
<span
css={STYLES_BOX}
onClick={this._handleNext}
style={{ top: 0, right: 16, bottom: 0 }}
>
<SVG.ChevronRight height="20px" />
</span>
)}
{slide}
<span css={STYLES_MOBILE_ONLY}>
<div css={STYLES_DISMISS_BOX} onClick={this._handleClose}>
<SVG.Dismiss height="24px" />
</div>
</span>
<span css={STYLES_MOBILE_HIDDEN}>
{this.state.showSidebar ? (
<div css={STYLES_EXPANDER} onClick={this._handleToggleSidebar}>
<SVG.Maximize height="24px" />
</div>
) : (
<div style={{ display: "flex", flexDirection: "row" }}>
<div css={STYLES_EXPANDER} onClick={this._handleToggleSidebar}>
<SVG.Minimize height="24px" />
</div>
<div css={STYLES_DISMISS_BOX} onClick={this._handleClose}>
<SVG.Dismiss height="24px" />
</div>
</div>
)}
</span>
</div>
<span css={STYLES_MOBILE_HIDDEN}>
<CarouselSidebar
key={file.id}
{...this.props}
file={file}
display={this.state.showSidebar ? "block" : "none"}
onClose={this._handleClose}
isRepost={isRepost}
onAction={this.props.onAction}
onNext={this._handleNext}
/>
</span>
</div>
);
}
}

View File

@ -0,0 +1,842 @@
import * as React from "react";
import * as Constants from "~/common/constants";
import * as SVG from "~/common/svg";
import * as System from "~/components/system";
import * as Styles from "~/common/styles";
import * as Jumpers from "~/components/system/components/GlobalCarousel/jumpers";
import { css } from "@emotion/react";
import { Alert } from "~/components/core/Alert";
import { motion, AnimateSharedLayout } from "framer-motion";
import {
useDetectTextOverflow,
useEscapeKey,
useEventListener,
useLockScroll,
} from "~/common/hooks";
import { Show } from "~/components/utility/Show";
import { ModalPortal } from "~/components/core/ModalPortal";
import SlateMediaObject from "~/components/core/SlateMediaObject";
import LinkIcon from "~/components/core/LinkIcon";
/* -------------------------------------------------------------------------------------------------
* Carousel Header
* -----------------------------------------------------------------------------------------------*/
const VisitLinkButton = ({ file }) => {
return (
<System.ButtonTertiary
onClick={(e) => e.stopPropagation()}
style={{
color: Constants.semantic.textGrayDark,
padding: "4px 8px 7px",
marginLeft: 4,
minHeight: 30,
}}
href={file.url}
target="_blank"
rel="noreferrer"
type="link"
>
<LinkIcon file={file} width={16} height={16} style={{ marginRight: 4 }} />
<span style={{ whiteSpace: "nowrap" }}>Visit site</span>
</System.ButtonTertiary>
);
};
/* -----------------------------------------------------------------------------------------------*/
const useCarouselJumperControls = () => {
const [isControlVisible, setControlVisibility] = React.useState(false);
const showControl = () => setControlVisibility(true);
const hideControl = () => setControlVisibility(false);
return [isControlVisible, { showControl, hideControl }];
};
const STYLES_HEADER_WRAPPER = (theme) => css`
${Styles.HORIZONTAL_CONTAINER_CENTERED};
position: absolute;
width: 100%;
min-height: 64px;
padding: 13px 24px 10px;
color: ${theme.semantic.textGrayDark};
border-bottom: 1px solid ${theme.semantic.borderGrayLight};
box-shadow: ${theme.shadow.lightSmall};
z-index: 1;
background-color: ${theme.semantic.bgWhite};
@supports ((-webkit-backdrop-filter: blur(15px)) or (backdrop-filter: blur(15px))) {
background-color: ${theme.semantic.bgBlurWhiteOP};
-webkit-backdrop-filter: blur(15px);
backdrop-filter: blur(15px);
}
`;
const STYLES_ACTION_BUTTON = css`
${Styles.BUTTON_RESET};
height: 32px;
width: 32px;
`;
function CarouselHeader({
viewer,
data,
external,
isOwner,
file,
current,
total,
onClose,
enableNextSlide,
enablePreviousSlide,
onNextSlide,
onPreviousSlide,
...props
}) {
// NOTE(amine): Detect if the text is overflowing to show the MORE button
const elementRef = React.useRef();
const isBodyOverflowing = useDetectTextOverflow({ ref: elementRef }, [file]);
// NOTE(amine): jumpers handlers
const [
isFileDescriptionVisible,
{ showControl: showFileDescription, hideControl: hideFileDescription },
] = useCarouselJumperControls();
const [isMoreInfoVisible, { showControl: showMoreInfo, hideControl: hideMoreInfo }] =
useCarouselJumperControls();
const [isEditInfoVisible, { showControl: showEditInfo, hideControl: hideEditInfo }] =
useCarouselJumperControls();
const [isShareFileVisible, { showControl: showShareFile, hideControl: hideShareFile }] =
useCarouselJumperControls();
const [isEditChannelsVisible, { showControl: showEditChannels, hideControl: hideEditChannels }] =
useCarouselJumperControls();
const isJumperOpen =
isFileDescriptionVisible ||
isMoreInfoVisible ||
isEditInfoVisible ||
isShareFileVisible ||
isEditChannelsVisible;
const [isHeaderVisible, setHeaderVisibility] = React.useState(true);
const timeoutRef = React.useRef();
const showHeader = () => {
clearTimeout(timeoutRef.current);
setHeaderVisibility(true);
};
const hideHeader = (ms = 1000) => {
timeoutRef.current = setTimeout(() => {
if (isJumperOpen) return;
setHeaderVisibility(false);
}, ms);
};
React.useEffect(() => {
hideHeader(3000);
return () => clearTimeout(timeoutRef.current);
}, []);
React.useLayoutEffect(() => {
if (isJumperOpen) {
return;
}
hideHeader();
}, [isJumperOpen]);
return (
<>
<ModalPortal>
{isOwner && (
<Jumpers.EditInfo file={file} isOpen={isEditInfoVisible} onClose={hideEditInfo} />
)}
{isOwner && (
<Jumpers.EditChannels
viewer={viewer}
file={file}
isOpen={isEditChannelsVisible}
onClose={hideEditChannels}
/>
)}
<Jumpers.FileDescription
file={file}
isOpen={isFileDescriptionVisible}
onClose={hideFileDescription}
/>
<Jumpers.MoreInfo
viewer={viewer}
external={external}
isOwner={isOwner}
file={file}
isOpen={isMoreInfoVisible}
onClose={hideMoreInfo}
/>
<Jumpers.Share
file={file}
data={data}
viewer={viewer}
isOpen={isShareFileVisible}
onClose={hideShareFile}
/>
</ModalPortal>
<CarouselControls
enableNextSlide={enableNextSlide}
enablePreviousSlide={enablePreviousSlide}
onNextSlide={onNextSlide}
onPreviousSlide={onPreviousSlide}
showControls={isHeaderVisible}
onClose={onClose}
onMouseEnter={showHeader}
onMouseOver={showHeader}
onMouseLeave={() => hideHeader()}
/>
<motion.nav
css={STYLES_HEADER_WRAPPER}
initial={{ opacity: 0 }}
animate={{ opacity: isHeaderVisible ? 1 : 0 }}
transition={{ ease: "easeInOut", duration: 0.25 }}
onMouseEnter={showHeader}
onMouseOver={showHeader}
onMouseLeave={() => hideHeader()}
{...props}
>
<div>
<div css={Styles.HORIZONTAL_CONTAINER}>
<System.H5 color="textBlack" as="h1">
{file?.name || file?.filename}
</System.H5>
<System.H5 color="textGray" as="p" style={{ marginLeft: 32 }}>
{current} / {total}
</System.H5>
</div>
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED} style={{ marginRight: 150 }}>
<System.P3
ref={elementRef}
style={{ marginTop: 1, wordBreak: "break-all" }}
nbrOflines={1}
color="textBlack"
>
{file.body}
</System.P3>
<Show when={isBodyOverflowing}>
<System.H6
css={Styles.BUTTON_RESET}
color="blue"
as="button"
onClick={showFileDescription}
>
MORE
</System.H6>
</Show>
</div>
</div>
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED} style={{ marginLeft: "auto" }}>
<AnimateSharedLayout>
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<Show when={isOwner}>
<motion.button
layoutId="jumper-desktop-edit"
onClick={showEditInfo}
css={STYLES_ACTION_BUTTON}
>
<SVG.Edit style={{ pointerEvents: "none" }} />
</motion.button>
</Show>
<Show when={isOwner}>
<motion.button
layoutId="jumper-desktop-channels"
onClick={showEditChannels}
style={{ marginLeft: 4 }}
css={STYLES_ACTION_BUTTON}
>
<SVG.Hash style={{ pointerEvents: "none" }} />
</motion.button>
</Show>
<Show when={file.isPublic}>
<motion.button
layoutId="jumper-desktop-share"
onClick={showShareFile}
style={{ marginLeft: 4 }}
css={STYLES_ACTION_BUTTON}
>
<SVG.Share style={{ pointerEvents: "none" }} />
</motion.button>
</Show>
<motion.button
layoutId="jumper-desktop-info"
onClick={showMoreInfo}
style={{ marginLeft: 4 }}
css={STYLES_ACTION_BUTTON}
>
<SVG.InfoCircle style={{ pointerEvents: "none" }} />
</motion.button>
{file.isLink ? <VisitLinkButton file={file} /> : null}
</div>
</AnimateSharedLayout>
<div style={{ marginLeft: 80 }}>
<button onClick={onClose} css={STYLES_ACTION_BUTTON}>
<SVG.Dismiss />
</button>
</div>
</div>
</motion.nav>
</>
);
}
const STYLES_CAROUSEL_MOBILE_HEADER = (theme) => css`
position: relative;
${Styles.HORIZONTAL_CONTAINER_CENTERED};
padding: 7px 8px 3px;
color: ${theme.semantic.textGrayDark};
border-bottom: 1px solid ${theme.semantic.borderGrayLight};
background-color: ${theme.semantic.bgWhite};
@supports ((-webkit-backdrop-filter: blur(15px)) or (backdrop-filter: blur(15px))) {
background-color: ${theme.semantic.bgBlurWhiteOP};
-webkit-backdrop-filter: blur(15px);
backdrop-filter: blur(15px);
}
`;
const STYLES_CAROUSEL_MOBILE_FOOTER = (theme) => css`
${Styles.HORIZONTAL_CONTAINER_CENTERED};
justify-content: space-between;
z-index: 1;
width: 100%;
padding: 8px 16px;
border-top: 1px solid ${theme.semantic.borderGrayLight};
color: ${theme.semantic.textGrayDark};
background-color: ${theme.semantic.bgWhite};
@supports ((-webkit-backdrop-filter: blur(15px)) or (backdrop-filter: blur(15px))) {
background-color: ${theme.semantic.bgBlurWhite};
-webkit-backdrop-filter: blur(15px);
backdrop-filter: blur(15px);
}
`;
const STYLES_CAROUSEL_MOBILE_SLIDE_COUNT = css`
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
`;
function CarouselHeaderMobile({ current, total, onClose, onNextSlide, onPreviousSlide }) {
return (
<nav css={STYLES_CAROUSEL_MOBILE_HEADER} style={{ justifyContent: "space-between" }}>
<div style={{ width: 76 }}>
<button css={STYLES_ACTION_BUTTON} onClick={onPreviousSlide}>
<SVG.ChevronLeft width={16} height={16} />
</button>
<button style={{ marginLeft: 12 }} css={STYLES_ACTION_BUTTON} onClick={onNextSlide}>
<SVG.ChevronRight width={16} height={16} />
</button>
</div>
<System.H5 color="textGray" as="p" css={STYLES_CAROUSEL_MOBILE_SLIDE_COUNT}>
{current} / {total}
</System.H5>
<div style={{ textAlign: "right" }}>
<button onClick={onClose} css={STYLES_ACTION_BUTTON}>
<SVG.Dismiss />
</button>
</div>
</nav>
);
}
function CarouselFooterMobile({ file, onAction, external, isOwner, data, viewer }) {
const [isEditInfoVisible, { showControl: showEditInfo, hideControl: hideEditInfo }] =
useCarouselJumperControls();
const [isShareFileVisible, { showControl: showShareFile, hideControl: hideShareFile }] =
useCarouselJumperControls();
const [isMoreInfoVisible, { showControl: showMoreInfo, hideControl: hideMoreInfo }] =
useCarouselJumperControls();
const [isEditChannelsVisible, { showControl: showEditChannels, hideControl: hideEditChannels }] =
useCarouselJumperControls();
return (
<>
<ModalPortal>
{isOwner && (
<Jumpers.EditInfoMobile file={file} isOpen={isEditInfoVisible} onClose={hideEditInfo} />
)}
{isOwner && (
<Jumpers.EditChannelsMobile
viewer={viewer}
file={file}
isOpen={isEditChannelsVisible}
onClose={hideEditChannels}
/>
)}
<Jumpers.ShareMobile
file={file}
isOpen={isShareFileVisible}
data={data}
viewer={viewer}
onClose={hideShareFile}
/>
<Jumpers.MoreInfoMobile
viewer={viewer}
external={external}
isOwner={isOwner}
file={file}
isOpen={isMoreInfoVisible}
onClose={hideMoreInfo}
/>
</ModalPortal>
<AnimateSharedLayout>
<nav css={STYLES_CAROUSEL_MOBILE_FOOTER}>
<Show when={isOwner}>
<motion.button
layoutId="jumper-mobile-edit"
css={STYLES_ACTION_BUTTON}
onClick={showEditInfo}
>
<SVG.Edit />
</motion.button>
</Show>
<Show when={isOwner}>
<motion.button
layoutId="jumper-mobile-channels"
style={{ marginLeft: 4 }}
css={STYLES_ACTION_BUTTON}
onClick={showEditChannels}
>
<SVG.Hash />
</motion.button>
</Show>
<Show when={file.isPublic}>
<motion.button
layoutId="jumper-mobile-share"
style={{ marginLeft: 4 }}
css={STYLES_ACTION_BUTTON}
onClick={showShareFile}
>
<SVG.Share />
</motion.button>
</Show>
<motion.button
layoutId="jumper-mobile-info"
style={{ marginLeft: 4 }}
css={STYLES_ACTION_BUTTON}
onClick={showMoreInfo}
>
<SVG.InfoCircle />
</motion.button>
{file.isLink ? <VisitLinkButton file={file} /> : null}
</nav>
</AnimateSharedLayout>
</>
);
}
/* -------------------------------------------------------------------------------------------------
* Carousel Controls
* -----------------------------------------------------------------------------------------------*/
const useCarouselKeyCommands = ({ handleNext, handlePrevious, handleClose }) => {
const handleKeyDown = (e) => {
const inputs = document.querySelectorAll("input");
for (let elem of inputs) {
if (document.activeElement === elem) {
return;
}
}
const textareas = document.querySelectorAll("textarea");
for (let elem of textareas) {
if (document.activeElement === elem) {
return;
}
}
switch (e.key) {
case "Right":
case "ArrowRight":
handleNext();
break;
case "Left":
case "ArrowLeft":
handlePrevious();
break;
}
};
useEscapeKey(handleClose);
useEventListener({ type: "keydown", handler: handleKeyDown });
};
const STYLES_CONTROLS_BUTTON = (theme) => css`
${Styles.BUTTON_RESET};
background-color: ${theme.semantic.bgGrayLight};
border-radius: 8px;
border: 1px solid ${theme.semantic.borderGrayLight};
padding: 10px;
box-shadow: ${theme.shadow.lightMedium};
svg {
display: block;
}
`;
const STYLES_CONTROLS_WRAPPER = css`
${Styles.CONTAINER_CENTERED};
position: absolute;
width: 122px;
height: 80%;
z-index: 1;
top: 50%;
transform: translateY(-50%);
padding-left: 24px;
padding-right: 24px;
`;
function CarouselControls({
enableNextSlide,
enablePreviousSlide,
onNextSlide,
onPreviousSlide,
showControls,
onClose,
...props
}) {
useCarouselKeyCommands({
handleNext: onNextSlide,
handlePrevious: onPreviousSlide,
handleClose: onClose,
});
return (
<>
<div
css={STYLES_CONTROLS_WRAPPER}
style={{ left: 0, justifyContent: "flex-start" }}
{...props}
>
{enablePreviousSlide ? (
<motion.button
onClick={onPreviousSlide}
initial={{ opacity: 0 }}
animate={{ opacity: showControls ? 1 : 0 }}
transition={{ ease: "easeInOut", duration: 0.25 }}
css={STYLES_CONTROLS_BUTTON}
>
<SVG.ChevronLeft width={16} />
</motion.button>
) : null}
</div>
<div
css={STYLES_CONTROLS_WRAPPER}
style={{ right: 0, justifyContent: "flex-end" }}
{...props}
>
{enableNextSlide ? (
<motion.button
onClick={onNextSlide}
initial={{ opacity: 0 }}
animate={{ opacity: showControls ? 1 : 0 }}
css={STYLES_CONTROLS_BUTTON}
>
<SVG.ChevronRight width={16} />
</motion.button>
) : null}
</div>
</>
);
}
/* -------------------------------------------------------------------------------------------------
* Carousel Content
* -----------------------------------------------------------------------------------------------*/
const STYLES_CONTENT = (theme) => css`
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
@media (max-width: ${theme.sizes.mobile}px) {
flex-direction: column;
overflow-y: auto;
justify-content: flex-start;
}
`;
const STYLES_PREVIEW_WRAPPER = (theme) => css`
${Styles.CONTAINER_CENTERED};
position: relative;
width: 100%;
height: 100%;
@media (max-width: ${theme.sizes.mobile}px) {
min-height: 75vh;
height: 75vh;
overflow: hidden;
}
`;
export function CarouselContent({
carouselType,
objects,
index,
data,
isMobile,
viewer,
sidebar,
style,
onClose,
}) {
const file = objects?.[index];
let isRepost = false;
if (carouselType === "SLATE") isRepost = data?.ownerId !== file.ownerId;
useLockScroll();
return (
<>
<Alert
viewer={viewer}
noWarning
id={isMobile ? "slate-mobile-alert" : null}
style={
isMobile
? null
: {
bottom: 0,
top: "auto",
paddingRight: sidebar ? `calc(${Constants.sizes.sidebar}px + 48px)` : "auto",
}
}
/>
<div css={STYLES_CONTENT} style={style} onClick={onClose}>
<div css={STYLES_PREVIEW_WRAPPER}>
<SlateMediaObject file={file} isMobile={isMobile} />
</div>
<div css={Styles.MOBILE_ONLY} style={{ padding: "13px 16px 44px", width: "100%" }}>
<System.H5 color="textBlack" as="h1">
{file?.name || file?.filename}
</System.H5>
<Show when={file?.body}>
<System.P2 color="textBlack" style={{ marginTop: 4 }}>
{file?.body}
</System.P2>
</Show>
<Show when={file.isLink}>
<div style={{ marginTop: 5 }} css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<LinkIcon file={file} width={12} height={12} />
<System.P2 as="a" nbrOflines={1} href={file.url} style={{ marginLeft: 5 }}>
{file.url}
</System.P2>
</div>
</Show>
</div>
</div>
</>
);
}
/* -------------------------------------------------------------------------------------------------
* Global Carousel
* -----------------------------------------------------------------------------------------------*/
const useCarouselViaParams = ({ index, params, objects, onChange }) => {
const findSelectedIndex = () => {
const cid = params?.cid;
if (!cid) return -1;
let index = objects.findIndex((elem) => elem.cid === cid);
return index;
};
React.useEffect(() => {
if (index !== -1) return;
const selectedIndex = findSelectedIndex();
if (selectedIndex !== index) onChange(index);
}, [params?.cid]);
React.useEffect(() => {
if (params?.cid) {
const index = findSelectedIndex();
onChange(index);
}
}, [params]);
};
const getCarouselHandlers = ({ index, objects, params, onChange, onAction }) => {
const handleNext = (e) => {
if (e) e.stopPropagation();
let nextIndex = index + 1;
if (nextIndex >= objects.length) return;
let { cid } = objects[nextIndex];
onChange(nextIndex);
onAction({ type: "UPDATE_PARAMS", params: { ...params, cid }, redirect: true });
};
const handlePrevious = (e) => {
if (e) e.stopPropagation();
let prevIndex = index - 1;
if (prevIndex < 0) return;
let { cid } = objects[prevIndex];
onChange(prevIndex);
onAction({ type: "UPDATE_PARAMS", params: { params, cid }, redirect: true });
};
const handleClose = (e) => {
if (e) e.stopPropagation(), e.preventDefault();
let params = { ...params };
delete params.cid;
onAction({ type: "UPDATE_PARAMS", params, redirect: true });
onChange(-1);
};
return { handleNext, handlePrevious, handleClose };
};
const STYLES_ROOT = (theme) => css`
${Styles.VERTICAL_CONTAINER};
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: 0;
height: 100vh;
color: ${Constants.system.white};
z-index: ${Constants.zindex.modal};
background-color: rgba(0, 0, 0, 0.8);
// Note(Amine): we're using the blur filter to fix a weird backdrop-filter's bug in chrome
filter: blur(0px);
@supports ((-webkit-backdrop-filter: blur(15px)) or (backdrop-filter: blur(15px))) {
-webkit-backdrop-filter: blur(15px);
backdrop-filter: blur(15px);
}
@media (max-width: ${Constants.sizes.mobile}px) {
background-color: ${theme.semantic.bgWhite};
}
@keyframes global-carousel-fade-in {
from {
transform: translateX(8px);
opacity: 0;
}
to {
transform: translateX(0px);
opacity: 1;
}
}
animation: global-carousel-fade-in 400ms ease;
`;
export function GlobalCarousel({
carouselType,
objects,
index,
params,
data,
isMobile,
onChange,
onAction,
viewer,
external,
isOwner,
sidebar,
style,
}) {
const file = objects?.[index];
const isCarouselOpen = (carouselType || index > 0 || index <= objects.length) && !!file;
useCarouselViaParams({ index, params, objects, onChange });
if (!isCarouselOpen) return null;
const { handleNext, handlePrevious, handleClose } = getCarouselHandlers({
index,
objects,
params,
onChange,
onAction,
});
return (
<div css={STYLES_ROOT}>
{isMobile ? (
<CarouselHeaderMobile
current={index + 1}
total={objects.length}
onPreviousSlide={handlePrevious}
onNextSlide={handleNext}
onClose={handleClose}
/>
) : (
<CarouselHeader
viewer={viewer}
external={external}
isOwner={isOwner}
data={data}
file={file}
current={index + 1}
total={objects.length}
onAction={onAction}
enableNextSlide={index < objects.length - 1}
enablePreviousSlide={index > 0}
onNextSlide={handleNext}
onPreviousSlide={handlePrevious}
onClose={handleClose}
/>
)}
<CarouselContent
carouselType={carouselType}
objects={objects}
index={index}
data={data}
isMobile={isMobile}
viewer={viewer}
sidebar={sidebar}
style={style}
onClose={handleClose}
/>
{isMobile ? (
<CarouselFooterMobile
file={file}
viewer={viewer}
data={data}
external={external}
onAction={onAction}
isOwner={isOwner}
/>
) : null}
</div>
);
}

View File

@ -0,0 +1,513 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as System from "~/components/system";
import * as Jumper from "~/components/core/Jumper";
import * as SVG from "~/common/svg";
import * as Actions from "~/common/actions";
import * as UserBehaviors from "~/common/user-behaviors";
import * as Constants from "~/common/constants";
import * as MobileJumper from "~/components/system/components/GlobalCarousel/jumpers/MobileLayout";
import * as Strings from "~/common/strings";
import * as Validations from "~/common/validations";
import * as Events from "~/common/custom-events";
import { Show } from "~/components/utility/Show";
import { css } from "@emotion/react";
import { AnimateSharedLayout, motion } from "framer-motion";
import { v4 as uuid } from "uuid";
import { useEventListener } from "~/common/hooks";
const STYLES_CHANNEL_BUTTON = (theme) => css`
position: relative;
${Styles.BUTTON_RESET};
padding: 5px 12px 7px;
border: 1px solid ${theme.semantic.borderGrayLight4};
border-radius: 12px;
color: ${theme.semantic.textBlack};
background-color: transparent;
transition: background-color 0.3 ease-in-out;
`;
const STYLES_CHANNEL_BUTTON_SELECTED = (theme) => css`
background-color: ${theme.semantic.bgGrayLight4};
`;
function ChannelButton({ children, isSelected, css, ...props }) {
return (
<button
{...props}
css={[STYLES_CHANNEL_BUTTON, isSelected && STYLES_CHANNEL_BUTTON_SELECTED, css]}
>
<System.P2 nbrOflines={1} as="span">
{children}
</System.P2>
</button>
);
}
/* -----------------------------------------------------------------------------------------------*/
const STYLES_RETURN_KEY = (theme) => css`
padding: 0px 2px;
border-radius: 6px;
background-color: ${theme.semantic.bgGrayLight};
`;
function ChannelKeyboardShortcut({ searchResults, searchQuery, onAddFileToChannel }) {
const [isFileAdded, setIsFileAdded] = React.useState(false);
React.useLayoutEffect(() => {
if (isFileAdded) {
setIsFileAdded(false);
}
}, [searchQuery]);
const { publicChannels, privateChannels } = searchResults;
const selectedChannel = [...publicChannels, ...privateChannels][0];
useEventListener({
type: "keyup",
handler: (e) => {
if (e.key === "Enter") {
onAddFileToChannel(selectedChannel, selectedChannel.doesContainFile);
setIsFileAdded(true);
}
},
});
// NOTE(amine): don't show the 'select channel ⏎' hint when the channel is created optimistically
if (isFileAdded || !selectedChannel.ownerId) return null;
return (
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<System.P3 color="textGray" style={{ display: "inline-flex" }}>
Select {selectedChannel.isPublic ? "public" : "private"} tag "
<System.P3 nbrOflines={1} as="span" style={{ maxWidth: 100 }}>
{selectedChannel.slatename}
</System.P3>
"
</System.P3>
<System.P3 css={STYLES_RETURN_KEY} style={{ marginLeft: 4 }}>
</System.P3>
</div>
);
}
const STYLES_SEARCH_CHANNELS_INPUT = (theme) => css`
background-color: transparent;
${theme.semantic.textGray};
box-shadow: none;
height: 52px;
padding: 0px;
::placeholder {
color: ${theme.semantic.textGray};
}
`;
function ChannelInput({ value, searchResults, onChange, onAddFileToChannel, ...props }) {
const { publicChannels, privateChannels } = searchResults;
const showShortcut = publicChannels.length + privateChannels.length === 1;
return (
<div style={{ position: "relative", width: "100%" }}>
<System.Input
full
value={value}
onChange={onChange}
name="search"
placeholder="Search or create a new channel"
inputCss={STYLES_SEARCH_CHANNELS_INPUT}
{...props}
/>
<div style={{ position: "absolute", top: "50%", transform: "translateY(-50%)", right: 20 }}>
{showShortcut ? (
<ChannelKeyboardShortcut
searchQuery={value}
searchResults={searchResults}
onAddFileToChannel={onAddFileToChannel}
/>
) : null}
</div>
</div>
);
}
/* -----------------------------------------------------------------------------------------------*/
const STYLES_TAG = (theme) => css`
padding: 7px 12px 9px;
border-radius: 12px;
background-color: ${theme.semantic.bgGrayLight4};
`;
function ChannelsEmpty() {
return (
<div css={Styles.VERTICAL_CONTAINER_CENTERED}>
<System.P2 color="textGrayDark" style={{ textAlign: "center" }}>
You dont have any tags yet. <br /> Start typing above to create one.
</System.P2>
<div css={STYLES_TAG} style={{ marginTop: 19 }}>
<SVG.Hash width={16} height={16} style={{ display: "block" }} />
</div>
</div>
);
}
/* -----------------------------------------------------------------------------------------------*/
const STYLES_CHANNEL_BUTTONS_WRAPPER = css`
display: flex;
flex-wrap: wrap;
margin: calc(-8px + 6px) 0 0 -8px;
width: calc(100% + 8px);
& > * {
margin: 8px 0 0 8px !important;
}
`;
function Channels({
header,
isPublic,
searchQuery,
channels,
isCreatingChannel,
onAddFileToChannel,
onCreateChannel,
}) {
const showChannel = !isCreatingChannel && channels.length === 0;
return !showChannel ? (
<div>
<System.H6 as="h2" color="textGray">
{isCreatingChannel ? `Create ${header.toLowerCase()} tag` : header}
</System.H6>
<Show when={isCreatingChannel && isPublic}>
<System.P3 color="textGray" style={{ marginTop: 2 }}>
Objects with a public tag will show up on your public profile.
</System.P3>
</Show>
<AnimateSharedLayout>
<div css={STYLES_CHANNEL_BUTTONS_WRAPPER}>
{channels.map((channel) => (
<motion.div layoutId={`jumper-${channel.id}`} initial={false} key={channel.id}>
<ChannelButton
isSelected={channel.doesContainFile}
onClick={() => onAddFileToChannel(channel, channel.doesContainFile)}
title={channel.slatename}
style={{ maxWidth: "48ch" }}
>
{channel.slatename}
</ChannelButton>
</motion.div>
))}
<Show when={isCreatingChannel}>
<motion.div initial={{ opacity: 0.5, y: 4 }} animate={{ opacity: 1, y: 0 }}>
<ChannelButton
css={Styles.HORIZONTAL_CONTAINER_CENTERED}
onClick={(e) => (e.stopPropagation(), onCreateChannel(searchQuery))}
title={searchQuery}
>
<SVG.Plus
width={16}
height={16}
style={{
position: "relative",
top: -1,
verticalAlign: "middle",
pointerEvents: "none",
}}
/>
<span style={{ marginLeft: 4 }}>{searchQuery}</span>
</ChannelButton>
</motion.div>
</Show>
</div>
</AnimateSharedLayout>
</div>
) : null;
}
/* -----------------------------------------------------------------------------------------------*/
const useChannels = ({ viewer, file }) => {
const [channels, setChannels] = React.useState(viewer.slates);
const handleAddFileToChannel = async (slate, isSelected) => {
const prevSlates = [...channels];
const resetViewerSlates = () => setChannels(prevSlates);
if (isSelected) {
const newChannels = channels.map((item) => {
if (slate.id === item.id) {
return { ...item, objects: item.objects.filter((object) => object.id !== file.id) };
}
return item;
});
setChannels(newChannels);
const response = await UserBehaviors.removeFromSlate({ slate, ids: [file.id] });
if (!response) resetViewerSlates();
return;
}
const newChannels = channels.map((item) => {
if (slate.id === item.id) return { ...item, objects: [...item.objects, file] };
return item;
});
setChannels(newChannels);
const response = await UserBehaviors.saveCopy({ slate, files: [file], showAlerts: false });
if (!response) resetViewerSlates();
};
const handleCreateChannel = (isPublic) => async (name) => {
const generatedId = uuid();
setChannels([...channels, { id: generatedId, slatename: name, isPublic, objects: [file] }]);
const response = await Actions.createSlate({
name: name,
isPublic,
hydrateViewer: false,
});
if (Events.hasError(response)) {
setChannels(channels.filter((channel) => channel.id !== generatedId));
return;
}
// NOTE(amine): replace generated id with response
const prevChannels = channels.filter((channel) => channel.id !== generatedId);
setChannels([...prevChannels, { ...response.slate, objects: [file] }]);
const saveResponse = await UserBehaviors.saveCopy({
slate: response.slate,
files: [file],
showAlerts: false,
});
if (Events.hasError(saveResponse)) {
setChannels([prevChannels, ...response.slate]);
}
};
return [channels, { handleCreateChannel, handleAddFileToChannel }];
};
const useGetPrivateAndPublicChannels = ({ slates, file }) =>
React.useMemo(() => {
const privateChannels = [];
const publicChannels = [];
slates.forEach((slate) => {
const doesContainFile = slate.objects.some((item) => item.id === file.id);
if (slate.isPublic) {
publicChannels.push({ ...slate, doesContainFile });
return;
}
privateChannels.push({ ...slate, doesContainFile });
});
privateChannels.sort((a, b) => a.createdAt - b.createdAt);
publicChannels.sort((a, b) => a.createdAt - b.createdAt);
return { privateChannels, publicChannels };
}, [slates, file.id]);
const useChannelsSearch = ({ privateChannels, publicChannels }) => {
const [query, setQuery] = React.useState("");
const { results, channelAlreadyExists } = React.useMemo(() => {
let channelAlreadyExists = false;
const results = { privateChannels: [], publicChannels: [] };
const searchRegex = new RegExp(query, "gi");
results.privateChannels = privateChannels.filter((channel) => {
if (channel.slatename === query) channelAlreadyExists = true;
return searchRegex.test(channel.slatename);
});
results.publicChannels = publicChannels.filter((channel) => {
if (channel.slatename === query) channelAlreadyExists = true;
return searchRegex.test(channel.slatename);
});
return { results, channelAlreadyExists };
}, [query, privateChannels, publicChannels]);
const handleQueryChange = (e) => {
const nextValue = e.target.value;
//NOTE(amine): allow input's value to be empty but keep other validations
if (Strings.isEmpty(nextValue) || Validations.slatename(nextValue)) {
setQuery(Strings.createSlug(nextValue, ""));
}
};
const clearQuery = () => setQuery("");
return [
{ searchQuery: query, searchResults: results, channelAlreadyExists },
{ handleQueryChange, clearQuery },
];
};
const STYLES_EDIT_CHANNELS_HEADER = (theme) => css`
color: ${theme.semantic.textGray};
`;
export function EditChannels({ file, viewer, isOpen, onClose }) {
const [channels, { handleAddFileToChannel, handleCreateChannel }] = useChannels({
viewer,
file,
});
const { privateChannels, publicChannels } = useGetPrivateAndPublicChannels({
slates: channels,
file,
});
const [{ searchQuery, searchResults, channelAlreadyExists }, { handleQueryChange, clearQuery }] =
useChannelsSearch({
privateChannels: privateChannels,
publicChannels: publicChannels,
});
const isSearching = searchQuery.length > 0;
const showEmptyState = !isSearching && channels.length === 0;
return (
<Jumper.AnimatePresence>
{isOpen ? (
<Jumper.Root onClose={() => (onClose(), clearQuery())}>
<Jumper.Header
css={[STYLES_EDIT_CHANNELS_HEADER, Styles.CONTAINER_CENTERED]}
style={{ paddingTop: 0, paddingBottom: 0 }}
>
<SVG.Hash width={16} />
<ChannelInput
value={searchQuery}
onChange={handleQueryChange}
searchResults={searchResults}
autoFocus={viewer?.slates?.length === 0}
onAddFileToChannel={handleAddFileToChannel}
/>
</Jumper.Header>
<Jumper.Divider />
<Jumper.Item>
<Jumper.ObjectPreview file={file} />
</Jumper.Item>
<Jumper.Divider />
{showEmptyState ? (
<Jumper.Item style={{ flexGrow: 1 }} css={Styles.CONTAINER_CENTERED}>
<ChannelsEmpty />
</Jumper.Item>
) : (
<Jumper.Item style={{ overflowY: "auto", flex: "1 0 0" }}>
<Channels
header="Private"
isCreatingChannel={isSearching && !channelAlreadyExists}
channels={isSearching ? searchResults.privateChannels : privateChannels}
searchQuery={searchQuery}
onAddFileToChannel={handleAddFileToChannel}
onCreateChannel={handleCreateChannel(false)}
file={file}
viewer={viewer}
/>
<div style={{ marginTop: 20 }}>
<Channels
header="Public"
isPublic
searchQuery={searchQuery}
isCreatingChannel={isSearching && !channelAlreadyExists}
channels={isSearching ? searchResults.publicChannels : publicChannels}
onAddFileToChannel={handleAddFileToChannel}
onCreateChannel={handleCreateChannel(true)}
/>
</div>
</Jumper.Item>
)}
</Jumper.Root>
) : null}
</Jumper.AnimatePresence>
);
}
export function EditChannelsMobile({ file, viewer, isOpen, onClose }) {
const [channels, { handleAddFileToChannel, handleCreateChannel }] = useChannels({
viewer,
file,
});
const { privateChannels, publicChannels } = useGetPrivateAndPublicChannels({
slates: channels,
file,
});
const [{ searchQuery, searchResults, channelAlreadyExists }, { handleQueryChange, clearQuery }] =
useChannelsSearch({
privateChannels: privateChannels,
publicChannels: publicChannels,
});
const isSearching = searchQuery.length > 0;
return isOpen ? (
<MobileJumper.Root>
<MobileJumper.Header
css={STYLES_EDIT_CHANNELS_HEADER}
style={{ paddingTop: 0, paddingBottom: 0, paddingRight: 0 }}
>
<SVG.Hash width={16} />
<ChannelInput
value={searchQuery}
onChange={handleQueryChange}
searchResults={searchResults}
onAddFileToChannel={handleAddFileToChannel}
autoFocus={viewer?.slates?.length === 0}
/>
</MobileJumper.Header>
<System.Divider height={1} color="borderGrayLight" />
<div style={{ padding: "13px 16px 11px" }}>
<Jumper.ObjectPreview file={file} />
</div>
<System.Divider height={1} color="borderGrayLight" />
<MobileJumper.Content>
<Channels
header="Private"
isCreatingChannel={isSearching && !channelAlreadyExists}
channels={isSearching ? searchResults.privateChannels : privateChannels}
searchQuery={searchQuery}
onAddFileToChannel={handleAddFileToChannel}
onCreateChannel={handleCreateChannel(false)}
/>
<div style={{ marginTop: 20 }}>
<Channels
header="Public"
isPublic
searchQuery={searchQuery}
isCreatingChannel={isSearching && !channelAlreadyExists}
channels={isSearching ? searchResults.publicChannels : publicChannels}
onAddFileToChannel={handleAddFileToChannel}
onCreateChannel={handleCreateChannel(true)}
/>
</div>
</MobileJumper.Content>
<MobileJumper.Footer css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<button
type="button"
css={Styles.BUTTON_RESET}
style={{ width: 32, height: 32 }}
onClick={() => (onClose(), clearQuery())}
>
<SVG.Hash width={16} height={16} style={{ color: Constants.system.blue }} />
</button>
</MobileJumper.Footer>
</MobileJumper.Root>
) : null;
}

View File

@ -0,0 +1,189 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as System from "~/components/system";
import * as Jumper from "~/components/core/Jumper";
import * as SVG from "~/common/svg";
import * as Actions from "~/common/actions";
import * as Events from "~/common/custom-events";
import * as Constants from "~/common/constants";
import * as MobileJumper from "~/components/system/components/GlobalCarousel/jumpers/MobileLayout";
import { css } from "@emotion/react";
import { useForm } from "~/common/hooks";
import Field from "~/components/core/Field";
const STYLES_EDIT_INFO_INPUT = (theme) => css`
width: 100%;
max-width: unset;
box-shadow: 0 0 0 1px ${theme.semantic.borderGrayLight4} inset;
height: 32px;
border-radius: 12px;
background-color: transparent;
padding-top: 5px;
padding-bottom: 7px;
color: ${theme.semantic.textBlack};
`;
const STYLES_EDIT_INFO_FOOTER = (theme) => css`
display: flex;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 50px;
background-color: ${theme.semantic.bgWhite};
`;
const STYLES_EDIT_INFO_FORM = css`
flex-grow: 1;
flex-basis: 0;
overflow-y: auto;
padding-bottom: 40;
`;
function UpdateFileForm({ file, isMobile, onClose }) {
const formRef = React.useRef();
const { getFieldProps, getFormProps, isSubmitting } = useForm({
initialValues: {
title: file?.name || "",
description: file?.body || "",
},
onSubmit: async ({ title, description }) => {
const response = await Actions.updateFile({
id: file.id,
name: title,
body: description,
});
Events.hasError(response);
},
});
//NOTE(amine): scroll to the bottom of the form every time the description's textarea resizes
const scrollToFormBottom = () => {
const form = formRef.current;
if (!form) return;
form.scrollTop = form.scrollHeight - form.clientHeight;
};
const JumperItem = isMobile ? MobileJumper.Content : Jumper.Item;
return (
<>
<form ref={formRef} css={STYLES_EDIT_INFO_FORM} {...getFormProps()}>
<JumperItem>
<div>
<System.H6 as="label" color="textGray">
Title
</System.H6>
<Field
full
inputCss={STYLES_EDIT_INFO_INPUT}
style={{ marginTop: 6 }}
{...getFieldProps("title")}
/>
</div>
<div>
<System.H6 as="label" color="textGray">
Description
</System.H6>
<System.Textarea
css={STYLES_EDIT_INFO_INPUT}
style={{ marginTop: 6 }}
maxLength="2000"
{...getFieldProps("description", { onChange: scrollToFormBottom })}
/>
</div>
</JumperItem>
{isMobile ? (
<MobileJumper.Footer css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<button
type="button"
css={Styles.BUTTON_RESET}
style={{ width: 32, height: 32 }}
onClick={onClose}
>
<SVG.Edit width={16} height={16} style={{ color: Constants.system.blue }} />
</button>
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED} style={{ marginLeft: "auto" }}>
<System.ButtonSecondary
type="button"
onClick={onClose}
style={{ marginLeft: "auto", minHeight: "32px" }}
>
Cancel
</System.ButtonSecondary>
<System.ButtonPrimary
type="submit"
style={{ marginLeft: "8px", minHeight: "32px" }}
loading={isSubmitting}
>
Save
</System.ButtonPrimary>
</div>
</MobileJumper.Footer>
) : (
<>
<Jumper.Item css={STYLES_EDIT_INFO_FOOTER}>
<System.ButtonSecondary
type="button"
onClick={onClose}
style={{ marginLeft: "auto", minHeight: "24px", padding: "1px 12px 3px" }}
>
Cancel
</System.ButtonSecondary>
<System.ButtonPrimary
type="submit"
style={{ marginLeft: "8px", minHeight: "24px", padding: "1px 12px 3px" }}
loading={isSubmitting}
>
Save
</System.ButtonPrimary>
</Jumper.Item>
</>
)}
</form>
<div style={{ height: 50 }} />
</>
);
}
/* -----------------------------------------------------------------------------------------------*/
export function EditInfo({ file, isOpen, onClose }) {
return (
<Jumper.AnimatePresence>
{isOpen ? (
<Jumper.Root onClose={onClose}>
<Jumper.Header>Edit info</Jumper.Header>
<Jumper.Divider />
<Jumper.Item>
<Jumper.ObjectPreview file={file} />
</Jumper.Item>
<Jumper.Divider />
<UpdateFileForm key={file.id} file={file} isMobile={false} onClose={onClose} />
</Jumper.Root>
) : null}
</Jumper.AnimatePresence>
);
}
export function EditInfoMobile({ file, isOpen, onClose }) {
return isOpen ? (
<MobileJumper.Root>
<MobileJumper.Header>
<System.H5 as="p" color="textBlack">
Edit Info
</System.H5>
</MobileJumper.Header>
<System.Divider height={1} color="borderGrayLight" />
<div style={{ padding: "13px 16px 11px" }}>
<Jumper.ObjectPreview file={file} />
</div>
<System.Divider height={1} color="borderGrayLight" />
<UpdateFileForm isMobile key={file.id} file={file} onClose={onClose} />
</MobileJumper.Root>
) : null;
}

View File

@ -0,0 +1,42 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as System from "~/components/system";
import * as Jumper from "~/components/core/Jumper";
import { Show } from "~/components/utility/Show";
import LinkIcon from "~/components/core/LinkIcon";
export function FileDescription({ file, isOpen, onClose }) {
return (
<Jumper.AnimatePresence>
{isOpen ? (
<Jumper.Root onClose={onClose}>
<Jumper.Header>
<System.H3 as="h1" nbrOflines={1} title={file.name || file.filename}>
{file.name || file.filename}
</System.H3>
<Show when={file.isLink}>
<div style={{ marginTop: 5 }} css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<LinkIcon file={file} width={12} height={12} />
<System.P2
as="p"
nbrOflines={1}
href={file.url}
css={Styles.LINK}
style={{ marginLeft: 5 }}
>
{file.url}
</System.P2>
</div>
</Show>
</Jumper.Header>
<Jumper.Divider />
<Jumper.Item>
<System.P2>{file.body}</System.P2>
</Jumper.Item>
</Jumper.Root>
) : null}
</Jumper.AnimatePresence>
);
}

View File

@ -0,0 +1,107 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import { css } from "@emotion/react";
import { motion } from "framer-motion";
/* -------------------------------------------------------------------------------------------------
* Root
* -----------------------------------------------------------------------------------------------*/
const STYLES_JUMPER_MOBILE_WRAPPER = (theme) => css`
${Styles.VERTICAL_CONTAINER};
position: fixed;
width: 100%;
height: 100vh;
top: 0;
left: 0;
z-index: ${theme.zindex.modal};
background-color: ${theme.semantic.bgWhite};
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
background-color: ${theme.semantic.bgBlurWhiteOP};
-webkit-backdrop-filter: blur(75px);
backdrop-filter: blur(75px);
}
`;
function Root({ children, ...props }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
css={[STYLES_JUMPER_MOBILE_WRAPPER, css]}
{...props}
>
{children}
</motion.div>
);
}
/* -------------------------------------------------------------------------------------------------
* Header
* -----------------------------------------------------------------------------------------------*/
const STYLES_JUMPER_MOBILE_HEADER = css`
${Styles.VERTICAL_CONTAINER};
padding: 13px 16px 11px;
`;
function Header({ children, ...props }) {
return (
<div css={[STYLES_JUMPER_MOBILE_HEADER, css]} {...props}>
{children}
</div>
);
}
/* -------------------------------------------------------------------------------------------------
* Content
* -----------------------------------------------------------------------------------------------*/
const STYLES_JUMPER_MOBILE_CONTENT = css`
flex-grow: 1;
overflow-y: auto;
padding: 12px 16px;
`;
function Content({ children, ...props }) {
return (
<div css={[STYLES_JUMPER_MOBILE_CONTENT, css]} {...props}>
{children}
</div>
);
}
const STYLES_JUMPER_MOBILE_FOOTER = (theme) => css`
position: fixed;
bottom: 0;
left: 0;
width: 100%;
padding: 8px 16px;
border-top: 1px solid ${theme.semantic.borderGrayLight};
background-color: ${theme.semantic.bgWhite};
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
background-color: ${theme.semantic.bgBlurWhite};
-webkit-backdrop-filter: blur(75px);
backdrop-filter: blur(75px);
}
`;
/* -------------------------------------------------------------------------------------------------
* Footer
* -----------------------------------------------------------------------------------------------*/
function Footer({ children, css, ...props }) {
return (
<div css={[STYLES_JUMPER_MOBILE_FOOTER, css]} {...props}>
{children}
</div>
);
}
export { Root, Header, Content, Footer };

View File

@ -0,0 +1,448 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as System from "~/components/system";
import * as Jumper from "~/components/core/Jumper";
import * as SVG from "~/common/svg";
import * as Strings from "~/common/strings";
import * as Utilities from "~/common/utilities";
import * as Actions from "~/common/actions";
import * as Events from "~/common/custom-events";
import * as UserBehaviors from "~/common/user-behaviors";
import * as Constants from "~/common/constants";
import * as FileUtilities from "~/common/file-utilities";
import * as Validations from "~/common/validations";
import * as MobileJumper from "~/components/system/components/GlobalCarousel/jumpers/MobileLayout";
import { LoaderSpinner } from "~/components/system/components/Loaders";
import { Show } from "~/components/utility/Show";
import { css } from "@emotion/react";
import { useEventListener } from "~/common/hooks";
import { AnimatePresence, motion } from "framer-motion";
const useCoverImgDrop = ({ onUpload, ref }) => {
const [isDropping, setDroppingState] = React.useState(false);
const handleDragEnter = (e) => (e.preventDefault(), e.stopPropagation(), setDroppingState(true));
const handleDragLeave = (e) => (e.preventDefault(), e.stopPropagation());
const timerRef = React.useRef();
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
// NOTE(amine): Hack to hide the indicator if the user drags files outside of the drop zone
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setDroppingState(false);
}, 100);
};
const handleDrop = async (e) => {
e.preventDefault();
e.stopPropagation();
const { files, error } = await FileUtilities.formatDroppedFiles({
dataTransfer: e.dataTransfer,
});
if (error) return null;
const coverImg = files[0];
onUpload(coverImg);
};
useEventListener({ type: "dragenter", handler: handleDragEnter, ref }, []);
useEventListener({ type: "dragleave", handler: handleDragLeave, ref }, []);
useEventListener({ type: "dragover", handler: handleDragOver, ref }, []);
useEventListener({ type: "drop", handler: handleDrop, ref }, []);
return { isDroppingCoverImg: isDropping };
};
const useCoverImgUpload = ({ file, viewer }) => {
const [isUploading, setUploadingState] = React.useState(false);
const handleCoverImgUpload = async (coverImg) => {
let previousCoverId = file.coverImage?.id;
setUploadingState(true);
let coverImage = await UserBehaviors.uploadImage(coverImg);
if (!coverImage) {
setUploadingState(false);
return;
}
//TODO(martina): create an endpoint specifically for cover images instead of this, which will delete original cover image etc
let updateReponse = await Actions.updateFile({
id: file.id,
coverImage,
});
setUploadingState(false);
if (Events.hasError(updateReponse)) return;
if (previousCoverId) {
if (!viewer.library.some((obj) => obj.id === previousCoverId)) {
await UserBehaviors.deleteFiles(previousCoverId, true);
}
}
};
return [{ isUploadingCoverImg: isUploading }, { handleCoverImgUpload: handleCoverImgUpload }];
};
const STYLES_FILE_HIDDEN = css`
height: 1px;
width: 1px;
opacity: 0;
visibility: hidden;
position: fixed;
top: -1px;
left: -1px;
`;
const STYLES_IMAGE_PREVIEW = (theme) => css`
width: 200px;
height: 200px;
border-radius: 16px;
margin-top: 8px;
box-shadow: ${theme.shadow.lightSmall};
border: 1px solid ${theme.semantic.borderGrayLight};
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (max-width: ${theme.sizes.mobile}px) {
width: 100%;
}
`;
const STYLES_COVER_IMG_DROP = (theme) => css`
${Styles.CONTAINER_CENTERED};
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 16px;
background-color: ${theme.semantic.bgWhite};
border: 1px solid ${theme.semantic.borderGrayLight};
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
-webkit-backdrop-filter: blur(75px);
backdrop-filter: blur(75px);
background-color: ${theme.semantic.bgBlurWhiteTRN};
}
`;
function CoverImageUpload({ file, viewer, isMobile, isFileOwner }) {
const { coverImage } = file;
const [{ isUploadingCoverImg }, { handleCoverImgUpload }] = useCoverImgUpload({
file,
viewer,
});
const coverImgDropzoneRef = React.useRef();
const { isDroppingCoverImg } = useCoverImgDrop({
onUpload: handleCoverImgUpload,
ref: coverImgDropzoneRef,
});
const handleInputChange = (e) => {
e.persist();
if (!e || !e.target) return;
handleCoverImgUpload(e.target.files[0]);
};
return (
<label
ref={coverImgDropzoneRef}
style={{
marginTop: 14,
cursor: !isUploadingCoverImg && isFileOwner ? "pointer" : "unset",
}}
>
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED} style={{ justifyContent: "space-between" }}>
<System.H6 color="textGray">Preview image</System.H6>
<Show when={isFileOwner}>
{isUploadingCoverImg ? (
<LoaderSpinner style={{ height: 16, width: 16 }} />
) : (
<div>
<input
css={STYLES_FILE_HIDDEN}
type="file"
id="file"
disabled={isUploadingCoverImg}
onChange={handleInputChange}
/>
<div
style={{
display: "block",
color: System.Constants.semantic.textBlack,
}}
>
<SVG.UploadCloud style={{ display: "block" }} width={16} height={16} />
</div>
</div>
)}
</Show>
</div>
<div style={{ position: "relative" }}>
{coverImage ? (
<>
<div css={STYLES_IMAGE_PREVIEW}>
<img src={Strings.getURLfromCID(coverImage.cid)} alt="" />
</div>
</>
) : (
<div
css={[STYLES_IMAGE_PREVIEW, Styles.CONTAINER_CENTERED]}
style={{ flexDirection: "column" }}
>
<SVG.UploadCloud width={16} />
<System.P3 style={{ maxWidth: 140, textAlign: "center", marginTop: 8 }}>
{isMobile
? "Select an image as object preview"
: "Drop or select an image as object preview"}
</System.P3>
</div>
)}
<AnimatePresence>
{isDroppingCoverImg ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
css={STYLES_COVER_IMG_DROP}
>
<System.P3 color="textGrayDark">Drop the image to upload</System.P3>
</motion.div>
) : null}
</AnimatePresence>
</div>
</label>
);
}
/* -----------------------------------------------------------------------------------------------*/
function ImageDimension({ file }) {
const [dimensions, setDimensions] = React.useState();
const url = Strings.getURLfromCID(file.cid);
React.useEffect(() => {
const img = new Image();
img.src = url;
img.onload = () => {
setDimensions({ width: img.naturalWidth, height: img.naturalHeight });
};
}, [url]);
return dimensions ? dimensions.width + " x " + dimensions.height : null;
}
function FileMetadata({ file, ...props }) {
return (
<div {...props}>
<System.H6 color="textGray">Object info</System.H6>
<div
css={Styles.HORIZONTAL_CONTAINER}
style={{ marginTop: 6, padding: "4px 0px", justifyContent: "space-between" }}
>
<System.P3 color="textGray">Type</System.P3>
<System.P3>{Strings.capitalize(file.type)}</System.P3>
</div>
<System.Divider
color="borderGrayLight"
height={1}
style={{ marginTop: 4, marginBottom: 4 }}
/>
<div
css={Styles.HORIZONTAL_CONTAINER}
style={{ padding: "4px 0px", justifyContent: "space-between" }}
>
<System.P3 color="textGray">Size</System.P3>
<System.P3>{Strings.bytesToSize(file.size, 0)}</System.P3>
</div>
{Validations.isPreviewableImage(file?.type || "") ? (
<>
<System.Divider
color="borderGrayLight"
height={1}
style={{ marginTop: 4, marginBottom: 4 }}
/>
<div
css={Styles.HORIZONTAL_CONTAINER}
style={{ padding: "4px 0px", justifyContent: "space-between" }}
>
<System.P3 color="textGray">Dimension</System.P3>
<System.P3>
<ImageDimension file={file} />
</System.P3>
</div>
</>
) : null}
<System.Divider
color="borderGrayLight"
height={1}
style={{ marginTop: 4, marginBottom: 4 }}
/>
<div
css={Styles.HORIZONTAL_CONTAINER}
style={{ padding: "4px 0px", justifyContent: "space-between" }}
>
<System.P3 color="textGray">Created</System.P3>
<System.P3>{Utilities.formatDateToString(file.createdAt)}</System.P3>
</div>
<System.Divider
color="borderGrayLight"
height={1}
style={{ marginTop: 4, marginBottom: 4 }}
/>
<div
css={Styles.HORIZONTAL_CONTAINER}
style={{
padding: "4px 0px",
justifyContent: "space-between",
}}
>
<System.P3 color="textGray">CID</System.P3>
<System.P3
style={{
wordBreak: "break-all",
textAlign: "right",
maxWidth: 249,
}}
>
{file.cid}
</System.P3>
</div>
</div>
);
}
/* -----------------------------------------------------------------------------------------------*/
const useFileDownload = ({ file, viewer, downloadRef }) => {
const [isDownloading, setDownloadingState] = React.useState(false);
const handleDownload = async () => {
if (!viewer) {
Events.dispatchCustomEvent({ name: "slate-global-open-cta", detail: {} });
return;
}
setDownloadingState(true);
const response = await UserBehaviors.download(file, downloadRef);
setDownloadingState(false);
Events.hasError(response);
};
return [isDownloading, handleDownload];
};
function DownloadButton({ file, viewer, ...props }) {
/**NOTE(amine): UserBehaviors.download creates a link and clicks it to trigger a download,
which triggers the Boundary component and closes the jumper.
To fix this we create the link inside the downloadRef element */
const downloadRef = React.useRef();
const [isDownloading, handleDownload] = useFileDownload({ file, viewer, downloadRef });
return !file.isLink ? (
<div ref={downloadRef}>
<System.ButtonSecondary onClick={handleDownload} loading={isDownloading} {...props}>
Download
</System.ButtonSecondary>
</div>
) : null;
}
/* -----------------------------------------------------------------------------------------------*/
const STYLES_DOWNLOAD_SECTION = (theme) => css`
${Styles.CONTAINER_CENTERED};
justify-content: flex-end;
background-color: ${theme.semantic.bgWhite};
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
-webkit-backdrop-filter: blur(75px);
backdrop-filter: blur(75px);
background-color: ${theme.semantic.bgBlurLight};
}
`;
export function MoreInfo({ external, viewer, isOwner, file, isOpen, onClose }) {
const isFileOwner = !external && isOwner && viewer;
return (
<Jumper.AnimatePresence>
{isOpen ? (
<Jumper.Root onClose={onClose}>
<Jumper.Header>More info</Jumper.Header>
<Jumper.Divider />
<Jumper.Item
css={Styles.HORIZONTAL_CONTAINER}
style={{ flexGrow: 1, paddingTop: 0, paddingBottom: 0 }}
>
<CoverImageUpload file={file} viewer={viewer} isFileOwner={isFileOwner} />
<System.Divider
style={{ marginLeft: 20, marginRight: 20 }}
color="borderGrayLight"
width={1}
height="unset"
/>
<FileMetadata file={file} style={{ width: "100%", marginTop: 14 }} />
</Jumper.Item>
<Jumper.Item css={STYLES_DOWNLOAD_SECTION}>
<DownloadButton
file={file}
viewer={viewer}
style={{ marginLeft: "auto", minHeight: "24px", padding: "1px 12px 3px" }}
/>
</Jumper.Item>
</Jumper.Root>
) : null}
</Jumper.AnimatePresence>
);
}
export function MoreInfoMobile({ external, viewer, isOwner, file, isOpen, onClose }) {
const isFileOwner = !external && isOwner && viewer;
return isOpen ? (
<MobileJumper.Root>
<MobileJumper.Header>
<System.H5 as="p" color="textBlack">
More Info
</System.H5>
</MobileJumper.Header>
<System.Divider height={1} color="borderGrayLight" />
<div style={{ padding: "13px 16px 11px" }}>
<Jumper.ObjectPreview file={file} />
</div>
<System.Divider height={1} color="borderGrayLight" />
<MobileJumper.Content>
<CoverImageUpload isMobile file={file} viewer={viewer} isFileOwner={isFileOwner} />
<FileMetadata file={file} style={{ marginTop: 22 }} />
</MobileJumper.Content>
<MobileJumper.Footer css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<button
type="button"
css={Styles.BUTTON_RESET}
style={{ width: 32, height: 32 }}
onClick={onClose}
>
<SVG.InfoCircle width={16} height={16} style={{ color: Constants.system.blue }} />
</button>
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED} style={{ marginLeft: "auto" }}>
<DownloadButton
file={file}
viewer={viewer}
style={{ marginLeft: "8px", minHeight: "32px" }}
/>
</div>
</MobileJumper.Footer>
</MobileJumper.Root>
) : null;
}

View File

@ -0,0 +1,179 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as System from "~/components/system";
import * as Jumper from "~/components/core/Jumper";
import * as SVG from "~/common/svg";
import * as Utilities from "~/common/utilities";
import * as Constants from "~/common/constants";
import * as MobileJumper from "~/components/system/components/GlobalCarousel/jumpers/MobileLayout";
import * as Strings from "~/common/strings";
import { css } from "@emotion/react";
const STYLES_SHARING_BUTTON = (theme) => css`
${Styles.BUTTON_RESET};
${Styles.HORIZONTAL_CONTAINER_CENTERED};
padding: 9px 8px 11px;
border-radius: 12px;
width: 100%;
:hover,
:active {
background-color: ${theme.semantic.bgGrayLight};
}
`;
const getSlateURLFromViewer = ({ viewer, file }) => {
const username = viewer?.username;
const rootUrl = window?.location?.origin;
const collection = viewer.slates.find(
(item) => item.isPublic && item.objects.some((object) => object.id === file.id)
);
return `${rootUrl}/${username}/${collection.slatename}?cid=${file.cid}`;
};
const getSlateURLFromData = ({ data, file }) => {
const username = data?.user?.username;
const rootUrl = window?.location?.origin;
return `${rootUrl}/${username}/${data.slatename}?cid=${file.cid}`;
};
function FileSharingButtons({ file, data, viewer }) {
const fileName = file?.name || file?.filename;
const username = data?.user?.username || viewer?.username;
const fileLink = data
? getSlateURLFromData({ data, file })
: getSlateURLFromViewer({ viewer, file });
const [copyState, setCopyState] = React.useState({ isCidCopied: false, isLinkCopied: false });
const handleTwitterSharing = () =>
window.open(
`https://twitter.com/intent/tweet?text=${fileName} by ${username} on Slate%0D&url=${fileLink}`,
"_blank"
);
const handleEmailSharing = () => {
window.open(`mailto: ?subject=${fileName} by ${username} on Slate&body=${fileLink}`, "_b");
};
const cidLink = Strings.getURLfromCID(file.cid);
const handleLinkCopy = () => (
Utilities.copyToClipboard(cidLink), setCopyState({ isLinkCopied: true })
);
const handleCidCopy = () => (
Utilities.copyToClipboard(file.cid), setCopyState({ isCidCopied: true })
);
return (
<>
<button css={STYLES_SHARING_BUTTON} onClick={handleTwitterSharing}>
<SVG.Twitter width={16} />
<System.P2 style={{ marginLeft: 12 }}>Share Via Twitter</System.P2>
</button>
<button css={STYLES_SHARING_BUTTON} onClick={handleEmailSharing}>
<SVG.Mail width={16} />
<System.P2 style={{ marginLeft: 12 }}>Share Via Email </System.P2>
</button>
<button css={STYLES_SHARING_BUTTON} onClick={handleLinkCopy}>
<SVG.Link width={16} />
<System.P2 style={{ marginLeft: 12 }}>
{copyState.isLinkCopied ? "Copied" : "Copy CID link"}
</System.P2>
</button>
<button css={STYLES_SHARING_BUTTON} onClick={handleCidCopy}>
<SVG.Hexagon width={16} />
<System.P2 style={{ marginLeft: 12 }}>
{copyState.isCidCopied ? "Copied" : "Copy CID "}
</System.P2>
</button>
</>
);
}
/* -----------------------------------------------------------------------------------------------*/
const STYLES_SHARE_FILE_FOOTER = (theme) => css`
${Styles.HORIZONTAL_CONTAINER_CENTERED};
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 50px;
color: ${theme.semantic.textGrayDark};
background-color: ${theme.semantic.bgWhite};
`;
const PROTO_SCHOOL_CID = "https://proto.school/anatomy-of-a-cid/01";
export function Share({ file, data, viewer, isOpen, onClose }) {
return (
<Jumper.AnimatePresence>
{isOpen ? (
<Jumper.Root onClose={onClose}>
<Jumper.Header>Share</Jumper.Header>
<Jumper.Divider />
<Jumper.Item>
<Jumper.ObjectPreview file={file} />
</Jumper.Item>
<Jumper.Divider />
<Jumper.Item style={{ padding: 12 }}>
<FileSharingButtons file={file} data={data} viewer={viewer} />
</Jumper.Item>
<Jumper.Item css={STYLES_SHARE_FILE_FOOTER}>
<a
css={[Styles.LINK, Styles.HORIZONTAL_CONTAINER_CENTERED]}
style={{ marginLeft: "auto", color: Constants.semantic.textGrayDark }}
href={PROTO_SCHOOL_CID}
target="_blank"
rel="noreferrer"
>
<SVG.InfoCircle width={16} />
<System.P2 style={{ marginLeft: 4 }}>What is CID?</System.P2>
</a>
</Jumper.Item>
</Jumper.Root>
) : null}
</Jumper.AnimatePresence>
);
}
export function ShareMobile({ file, data, viewer, isOpen, onClose }) {
return isOpen ? (
<MobileJumper.Root>
<MobileJumper.Header>
<System.H5 as="p" color="textBlack">
Share
</System.H5>
</MobileJumper.Header>
<System.Divider height={1} color="borderGrayLight" />
<div style={{ padding: "13px 16px 11px" }}>
<Jumper.ObjectPreview file={file} />
</div>
<System.Divider height={1} color="borderGrayLight" />
<MobileJumper.Content>
<FileSharingButtons file={file} data={data} viewer={viewer} />
</MobileJumper.Content>
<MobileJumper.Footer css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<button
type="button"
css={Styles.BUTTON_RESET}
style={{ width: 32, height: 32 }}
onClick={onClose}
>
<SVG.Share width={16} height={16} style={{ color: Constants.system.blue }} />
</button>
<a
css={[Styles.LINK, Styles.HORIZONTAL_CONTAINER_CENTERED]}
style={{ marginLeft: "auto", color: Constants.semantic.textGrayDark }}
href={PROTO_SCHOOL_CID}
target="_blank"
rel="noreferrer"
>
<SVG.InfoCircle width={16} />
<System.P2 style={{ marginLeft: 4 }}>What is CID?</System.P2>
</a>
</MobileJumper.Footer>
</MobileJumper.Root>
) : null;
}

View File

@ -0,0 +1,35 @@
import { FileDescription } from "~/components/system/components/GlobalCarousel/jumpers/FileDescription";
import {
MoreInfo,
MoreInfoMobile,
} from "~/components/system/components/GlobalCarousel/jumpers/MoreInfo";
import {
EditInfo,
EditInfoMobile,
} from "~/components/system/components/GlobalCarousel/jumpers/EditInfo";
import { Share, ShareMobile } from "~/components/system/components/GlobalCarousel/jumpers/Share";
import {
EditChannels,
EditChannelsMobile,
} from "~/components/system/components/GlobalCarousel/jumpers/EditChannels";
export {
//NOTE(amine): FileDescription jumper
FileDescription,
//NOTE(amine): MoreInfo jumper
MoreInfo,
MoreInfoMobile,
//NOTE(amine): EditInfo jumper
EditInfo,
EditInfoMobile,
//NOTE(amine): Share jumper
Share,
ShareMobile,
//NOTE(amine): EditChannels jumper
EditChannels,
EditChannelsMobile,
};

View File

@ -45,17 +45,7 @@ const STYLES_TEXTAREA = css`
export class Textarea extends React.Component {
render() {
return (
<TextareaAutoSize
css={STYLES_TEXTAREA}
style={this.props.style}
onChange={this.props.onChange}
placeholder={this.props.placeholder}
name={this.props.name}
value={this.props.value}
readOnly={this.props.readOnly}
maxLength={this.props.maxLength}
/>
);
const { css, ...props } = this.props;
return <TextareaAutoSize css={[STYLES_TEXTAREA, css]} {...props} />;
}
}

View File

@ -180,16 +180,16 @@ export const H4 = ({ as = "h4", nbrOflines, children, color, ...props }) => {
);
};
export const H5 = ({ as = "h5", nbrOflines, children, color, ...props }) => {
export const H5 = React.forwardRef(({ as = "h5", nbrOflines, children, color, ...props }, ref) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return jsx(
as,
{ ...props, css: [Styles.H5, TRUNCATE_STYLE, COLOR_STYLES, props?.css] },
{ ...props, css: [Styles.H5, TRUNCATE_STYLE, COLOR_STYLES, props?.css], ref },
children
);
};
});
export const H6 = ({ as = "h6", nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
@ -224,15 +224,15 @@ export const P2 = ({ as = "p", nbrOflines, children, color, ...props }) => {
);
};
export const P3 = ({ as = "p", nbrOflines, children, color, ...props }) => {
export const P3 = React.forwardRef(({ as = "p", nbrOflines, children, color, ...props }, ref) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return jsx(
as,
{ ...props, css: [Styles.P3, TRUNCATE_STYLE, COLOR_STYLES, props?.css] },
{ ...props, css: [Styles.P3, TRUNCATE_STYLE, COLOR_STYLES, props?.css], ref },
children
);
};
});
export const C1 = ({ as = "p", nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);

View File

@ -18,7 +18,7 @@ export default async (slate) => {
type: "CREATE_SLATE",
}).into("activity");
await Data.recalcUserSlatecount({ userId: ownerId });
await Data.recalcUserSlatecount({ userId: slate.ownerId });
} else {
const activityQuery = await DB.insert({
ownerId: slate.ownerId,

View File

@ -40,7 +40,8 @@ export default async (req, res) => {
return res.status(500).send({ decorator: "SERVER_CREATE_SLATE_FAILED", error: true });
}
ViewerManager.hydratePartial(id, { slates: true });
const { hydrateViewer = true } = req.body.data;
if (hydrateViewer) ViewerManager.hydratePartial(id, { slates: true });
SearchManager.indexSlate(slate);