mirror of
https://github.com/filecoin-project/slate.git
synced 2024-11-22 12:24:02 +03:00
Merge pull request #972 from filecoin-project/@aminejv/new-carousel
Feat: New global carousel design
This commit is contained in:
commit
84c25d0026
@ -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)",
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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 };
|
||||
};
|
||||
|
||||
|
@ -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/");
|
||||
};
|
||||
|
@ -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, "\\$&");
|
||||
|
@ -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
|
||||
|
@ -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 can’t 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);
|
@ -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 (
|
||||
|
@ -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} />
|
||||
|
@ -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
|
||||
|
@ -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 };
|
||||
|
19
components/core/LinkIcon.js
Normal file
19
components/core/LinkIcon.js
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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`
|
||||
|
48
components/core/ObjectBoxPreview.js
Normal file
48
components/core/ObjectBoxPreview.js
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
};
|
||||
|
@ -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 };
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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 }, []);
|
||||
};
|
||||
|
@ -16,8 +16,8 @@ const Root = ({ children, data }) => {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<ModalPortal>
|
||||
<Jumper data={data} />
|
||||
<ModalPortal>
|
||||
<Popup />
|
||||
<DropIndicator data={data} />
|
||||
</ModalPortal>
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
842
components/system/components/GlobalCarousel/index.js
Normal file
842
components/system/components/GlobalCarousel/index.js
Normal 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>
|
||||
);
|
||||
}
|
@ -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 don’t 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;
|
||||
}
|
189
components/system/components/GlobalCarousel/jumpers/EditInfo.js
Normal file
189
components/system/components/GlobalCarousel/jumpers/EditInfo.js
Normal 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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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 };
|
448
components/system/components/GlobalCarousel/jumpers/MoreInfo.js
Normal file
448
components/system/components/GlobalCarousel/jumpers/MoreInfo.js
Normal 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;
|
||||
}
|
179
components/system/components/GlobalCarousel/jumpers/Share.js
Normal file
179
components/system/components/GlobalCarousel/jumpers/Share.js
Normal 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;
|
||||
}
|
35
components/system/components/GlobalCarousel/jumpers/index.js
Normal file
35
components/system/components/GlobalCarousel/jumpers/index.js
Normal 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,
|
||||
};
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
@ -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]);
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user