Merge pull request #959 from filecoin-project/@aminejv/saving-changes

Additional changes for save
This commit is contained in:
martinalong 2021-10-15 14:16:26 -07:00 committed by GitHub
commit 3fee27424c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1162 additions and 739 deletions

View File

@ -8,7 +8,8 @@ export const sizes = {
navigation: 288,
sidebar: 416,
// NOTE(amine): header's height + filter navbar's height
header: 52 + 40,
header: 52,
filterNavbar: 40,
tablet: 960,
desktop: 1024,
topOffset: 0, //NOTE(martina): Pushes UI down. 16 when there is a persistent announcement banner, 0 otherwise
@ -98,6 +99,7 @@ export const semantic = {
bgBlurWhite: "rgba(255, 255, 255, 0.7)",
bgBlurWhiteOP: "rgba(255, 255, 255, 0.85)",
bgBlurWhiteTRN: "rgba(255, 255, 255, 0.3)",
bgBlurLightTRN: "rgba(247, 248, 249, 0.3)",
bgBlurLight6: "rgba(247, 248, 249, 0.7)",
bgBlurLight6OP: "rgba(247, 248, 249, 0.85)",
bgBlurLight6TRN: "rgba(247, 248, 249, 0.3)",
@ -139,11 +141,12 @@ export const zindex = {
body: 2,
sidebar: 5,
alert: 3,
uploadModal: 3,
header: 4,
intercom: 4,
modal: 6,
tooltip: 7,
cta: 8,
tooltip: 8,
jumper: 7,
cta: 9,
};
export const font = {

View File

@ -41,10 +41,12 @@ export const useForm = ({
});
const _hasError = (obj) => Object.keys(obj).some((name) => obj[name]);
const _mergeEventHandlers = (events = []) => (e) =>
events.forEach((event) => {
if (event) event(e);
});
const _mergeEventHandlers =
(events = []) =>
(e) =>
events.forEach((event) => {
if (event) event(e);
});
/** ---------- NOTE(amine): Input Handlers ---------- */
const handleFieldChange = (e) =>
@ -164,10 +166,12 @@ export const useField = ({
touched: undefined,
});
const _mergeEventHandlers = (events = []) => (e) =>
events.forEach((event) => {
if (event) event(e);
});
const _mergeEventHandlers =
(events = []) =>
(e) =>
events.forEach((event) => {
if (event) event(e);
});
const setFieldValue = (value) =>
setState((prev) => ({
@ -451,3 +455,12 @@ export const useWorker = ({ onStart, onMessage, onError } = {}, dependencies = [
return workerRef.current;
};
export const useHover = () => {
const [isHovered, setHoverState] = React.useState(false);
const handleOnMouseEnter = () => setHoverState(true);
const handleOnMouseLeave = () => setHoverState(false);
return [isHovered, { handleOnMouseEnter, handleOnMouseLeave }];
};

View File

@ -251,6 +251,7 @@ export const BUTTON_RESET = css`
margin: 0;
background-color: unset;
border: none;
cursor: pointer;
${HOVERABLE}
`;

View File

@ -92,6 +92,12 @@ export const injectGlobalStyles = () => css`
}
`;
export const injectIntercomStyles = () => css`
.intercom-lightweight-app {
z-index: ${Constants.zindex.intercom} !important;
}
`;
/* prettier-ignore */
export const injectCodeBlockStyles = () => css`
.language-javascript {

View File

@ -1160,6 +1160,20 @@ export const FilecoinLogo = (props) => (
</svg>
);
export const ChevronUp = (props) => {
return (
<svg width={16} height={16} fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M12 10L8 6l-4 4"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export const ChevronDown = (props) => {
return (
<svg
@ -1866,10 +1880,16 @@ export const RotateCcw = (props) => (
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M1 4v6h6" stroke="#000" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<path
d="M1 4v6h6"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3.51 15a9 9 0 102.13-9.36L1 10"
stroke="#000"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
@ -2243,3 +2263,46 @@ export const Instagram = (props) => (
/>
</svg>
);
export const CheckCircle = (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"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14.667 2.667L8 9.34l-2-2"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export const XCircle = (props) => (
<svg width={16} height={16} fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M8 14.667A6.667 6.667 0 108 1.333a6.667 6.667 0 000 13.334zM10 6l-4 4M6 6l4 4"
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
d="M6.86 2.573L1.213 12a1.333 1.333 0 001.14 2h11.294a1.333 1.333 0 001.14-2L9.14 2.573a1.333 1.333 0 00-2.28 0v0zM8 6v2.667M8 11.333h.007"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);

View File

@ -31,6 +31,7 @@ const storeFileInCache = ({ file, slate }) =>
(UploadStore.failedFilesCache[getFileKey(file)] = { file, slate });
const removeFileFromCache = ({ fileKey }) => delete UploadStore.failedFilesCache[fileKey];
const getFileFromCache = ({ fileKey }) => UploadStore.failedFilesCache[fileKey] || {};
const getFailedFilesCache = () => UploadStore.failedFilesCache;
// NOTE(amine): UploadAbort utilities
const registerFileUploading = ({ fileKey }) => (UploadAbort.currentUploadingFile = fileKey);
@ -133,11 +134,12 @@ export function createUploadProvider({
for (let i = 0; i < files.length; i++) {
const fileKey = getFileKey(files[i]);
const doesQueueIncludeFile = getUploadQueue().some(
({ file }) => getFileKey(files[i]) === getFileKey(file)
({ file }) => getFileKey(file) === fileKey
);
const isUploaded = fileKey in UploadStore.uploadedFiles;
const isUploading = UploadAbort.currentUploadingFile === fileKey;
// NOTE(amine): skip the file if already uploaded or is in queue
if (doesQueueIncludeFile || isUploaded) continue;
if (doesQueueIncludeFile || isUploaded || isUploading) continue;
// NOTE(amine): if the added file has failed before, remove it from failedFilesCache
if (fileKey in UploadStore.failedFilesCache) removeFileFromCache({ fileKey });
@ -162,6 +164,13 @@ export function createUploadProvider({
addToUploadQueue({ files: [file], slate });
};
const retryAll = () => {
const failedFilesCache = getFailedFilesCache();
Object.entries(failedFilesCache).forEach(([key]) => {
retry({ fileKey: key });
});
};
const cancel = ({ fileKey }) => {
if (onCancel) onCancel({ fileKeys: [fileKey] });
@ -194,8 +203,9 @@ export function createUploadProvider({
({ file }) => getFileKey(linkAsFile) === getFileKey(file)
);
const isUploaded = fileKey in UploadStore.uploadedFiles;
const isUploading = UploadAbort.currentUploadingFile === fileKey;
// NOTE(amine): skip the file if already uploaded or is in queue
if (doesQueueIncludeFile || isUploaded) return;
if (doesQueueIncludeFile || isUploaded || isUploading) return;
// NOTE(amine): if the added file has failed before, remove it from failedFilesCache
if (fileKey in UploadStore.failedFilesCache) removeFileFromCache({ fileKey });
@ -210,11 +220,18 @@ export function createUploadProvider({
}
};
const clearUploadCache = () => {
UploadStore.failedFilesCache = {};
UploadStore.uploadedFiles = {};
};
return {
upload: addToUploadQueue,
uploadLink: addLinkToUploadQueue,
retry,
retryAll,
cancel,
cancelAll,
clearUploadCache,
};
}

View File

@ -154,14 +154,14 @@ export function formatDateToString(date) {
const yesterday = moment().subtract(1, "day");
if (today.isSame(providedDate, "day")) {
return "Today at " + providedDate.format("h:mm:ssA");
return "Today at " + providedDate.format("h:mmA");
}
if (yesterday.isSame(providedDate, "day")) {
return "Yesterday at " + providedDate.format("h:mm:ssA");
return "Yesterday at " + providedDate.format("h:mmA");
}
return providedDate.format("MMM D, YYYY") + " at " + providedDate.format("h:mm:ssA");
return providedDate.format("MMM D, YYYY") + " at " + providedDate.format("h:mmA");
}
export const clamp = (value, min, max) => {

View File

@ -50,7 +50,6 @@ const STYLES_APPLICATION_HEADER_BACKGROUND = (theme) => css`
z-index: -1;
background-color: ${theme.system.white};
box-shadow: 0 0 0 1px ${theme.semantic.bgGrayLight};
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
-webkit-backdrop-filter: blur(75px);
backdrop-filter: blur(75px);
@ -61,7 +60,6 @@ const STYLES_APPLICATION_HEADER_BACKGROUND = (theme) => css`
const STYLES_APPLICATION_HEADER = css`
${Styles.HORIZONTAL_CONTAINER_CENTERED};
padding: 14px 24px;
@media (max-width: ${Constants.sizes.mobile}px) {
padding: 16px 16px 12px;
width: 100%;
@ -94,7 +92,6 @@ const STYLES_BACKGROUND = css`
height: 100vh;
background-color: ${Constants.semantic.bgBlurDark};
pointer-events: auto;
@keyframes fade-in {
from {
opacity: 50%;
@ -106,6 +103,22 @@ const STYLES_BACKGROUND = css`
animation: fade-in 200ms ease-out;
`;
const STYLES_HEADER = css`
z-index: ${Constants.zindex.header};
width: 100vw;
position: fixed;
right: 0;
top: 0;
`;
const STYLES_FILTER_NAVBAR = (theme) => css`
z-index: ${theme.zindex.body};
width: 100vw;
position: fixed;
right: 0;
top: ${theme.sizes.header};
`;
const STYLES_UPLOAD_BUTTON = css`
${Styles.CONTAINER_CENTERED};
background-color: ${Constants.semantic.bgGrayLight};
@ -157,79 +170,82 @@ export default function ApplicationHeader({ viewer, page, data, onAction }) {
const isSearching = searchQuery.length !== 0;
return (
<div>
<header style={{ position: "relative" }}>
<div css={STYLES_APPLICATION_HEADER}>
<div css={STYLES_LEFT}>
<Show
when={viewer}
fallback={
<Link onAction={onAction} href="/_/data" style={{ pointerEvents: "auto" }}>
<DarkSymbol style={{ height: 24, display: "block" }} />
</Link>
}
>
<ApplicationUserControls
popup={mobile ? false : state.popup}
onTogglePopup={_handleTogglePopup}
viewer={viewer}
onAction={onAction}
/>
</Show>
</div>
<div css={STYLES_MIDDLE}>
{/**TODO: update Search component */}
<Input
containerStyle={{ height: "100%" }}
full
placeholder={`Search ${!viewer ? "slate.host" : ""}`}
inputCss={STYLES_SEARCH_COMPONENT}
onSubmit={handleCreateSearch}
name="search"
{...getFieldProps()}
/>
</div>
<Upload.Provider page={page} data={data} viewer={viewer}>
<Upload.Root onAction={onAction} viewer={viewer}>
<div css={STYLES_RIGHT}>
<Actions
uploadAction={
<Upload.Trigger
enableMetrics
viewer={viewer}
aria-label="Upload"
css={STYLES_UPLOAD_BUTTON}
>
<SVG.Plus height="16px" />
</Upload.Trigger>
}
isSearching={isSearching}
isSignedOut={isSignedOut}
<>
<div css={STYLES_HEADER}>
<header style={{ position: "relative" }}>
<div css={STYLES_APPLICATION_HEADER}>
<div css={STYLES_LEFT}>
<Show
when={viewer}
fallback={
<Link onAction={onAction} href="/_/data" style={{ pointerEvents: "auto" }}>
<DarkSymbol style={{ height: 24, display: "block" }} />
</Link>
}
>
<ApplicationUserControls
popup={mobile ? false : state.popup}
onTogglePopup={_handleTogglePopup}
viewer={viewer}
onAction={onAction}
onDismissSearch={handleDismissSearch}
/>
</div>
</Upload.Root>
</Upload.Provider>
</div>
<Show when={mobile && state.popup === "profile"}>
<ApplicationUserControlsPopup
popup={state.popup}
onTogglePopup={_handleTogglePopup}
viewer={viewer}
onAction={onAction}
style={{ pointerEvents: "auto" }}
/>
<div css={STYLES_BACKGROUND} />
</Show>
</div>
<div css={STYLES_MIDDLE}>
{/**TODO: update Search component */}
<Input
containerStyle={{ height: "100%" }}
full
placeholder={`Search ${!viewer ? "slate.host" : ""}`}
inputCss={STYLES_SEARCH_COMPONENT}
onSubmit={handleCreateSearch}
name="search"
{...getFieldProps()}
/>
</div>
<Upload.Provider page={page} data={data} viewer={viewer}>
<Upload.Root data={data}>
<div css={STYLES_RIGHT}>
<Actions
uploadAction={
<Upload.Trigger
viewer={viewer}
aria-label="Upload"
css={STYLES_UPLOAD_BUTTON}
>
<SVG.Plus height="16px" />
</Upload.Trigger>
}
isSearching={isSearching}
isSignedOut={isSignedOut}
onAction={onAction}
onDismissSearch={handleDismissSearch}
/>
</div>
</Upload.Root>
</Upload.Provider>
</div>
<Show when={mobile && state.popup === "profile"}>
<ApplicationUserControlsPopup
popup={state.popup}
onTogglePopup={_handleTogglePopup}
viewer={viewer}
onAction={onAction}
style={{ pointerEvents: "auto" }}
/>
<div css={STYLES_BACKGROUND} />
</Show>
{/** NOTE(amine): a fix for a backdrop-filter bug where the filter doesn't take any effects.
* It happens when we have two elements using backdrop-filter with a parent-child relationship */}
<div css={STYLES_APPLICATION_HEADER_BACKGROUND} />
</header>
</div>
<div css={STYLES_FILTER_NAVBAR}>
<Show when={!!viewer}>
<Filter.Navbar />
</Show>
{/** NOTE(amine): a fix for a backdrop-filter bug where the filter doesn't take any effects.
* It happens when we have two elements using backdrop-filter with a parent-child relationship */}
<div css={STYLES_APPLICATION_HEADER_BACKGROUND} />
</header>
<Show when={!!viewer}>
<Filter.Navbar />
</Show>
</div>
</div>
</>
);
}

View File

@ -26,16 +26,6 @@ const STYLES_NO_VISIBLE_SCROLL = css`
}
`;
const STYLES_HEADER = css`
z-index: ${Constants.zindex.header};
height: ${Constants.sizes.header}px;
width: 100vw;
position: fixed;
right: 0;
top: 0;
transition: top 0.25s;
`;
const STYLES_CONTENT = css`
background: ${Constants.system.white};
width: 100%;
@ -220,7 +210,7 @@ export default class ApplicationLayout extends React.Component {
{this.props.header && (
<>
<div style={{ height: Constants.sizes.header }} />
<div css={STYLES_HEADER}>{this.props.header}</div>
<div>{this.props.header}</div>
</>
)}
<Alert

View File

@ -0,0 +1,54 @@
import * as React from "react";
import * as Validations from "~/common/validations";
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 [imgUrl, setImgUrl] = React.useState();
React.useLayoutEffect(() => {
if (isImage) {
const reader = new FileReader();
reader.addEventListener("load", () => setImgUrl(reader.result), false);
reader.readAsDataURL(file.blob);
}
}, []);
if (isImage) {
return (
<div css={[STYLES_PREVIEW, css]} {...props}>
{imgUrl && <img src={imgUrl} alt="File preview" />}
</div>
);
}
return (
<div css={[STYLES_PREVIEW, css]} {...props}>
<ObjectPlaceholder
ratio={placeholderRatio}
containerCss={STYLES_PLACEHOLDER_CONTAINER}
file={file}
/>
</div>
);
}

View File

@ -9,7 +9,8 @@ import { useFilterContext } from "~/components/core/Filter/Provider";
const STYLES_DATAVIEWER_WRAPPER = (theme) => css`
width: 100%;
padding: 20px 24px 44px;
min-height: 100vh;
padding: calc(20px + ${theme.sizes.filterNavbar}px) 24px 44px;
@media (max-width: ${theme.sizes.mobile}px) {
padding: 31px 16px 44px;
}

View File

@ -85,7 +85,7 @@ const useFilterWorker = ({ filterState, setFilterObjects, library }) => {
setFilterObjects(e.data);
},
},
[view, subview, type]
[view, subview, library, type]
);
return workerState;

View File

@ -59,10 +59,10 @@ export function Sidebar() {
const STYLES_SIDEBAR_FILTER_WRAPPER = (theme) => css`
position: sticky;
top: ${theme.sizes.header}px;
min-height: 100vh;
top: ${theme.sizes.header + theme.sizes.filterNavbar}px;
width: 236px;
max-height: calc(100vh - ${theme.sizes.header}px);
height: 100vh;
max-height: calc(100vh - ${theme.sizes.header + theme.sizes.filterNavbar}px);
padding: 20px 24px;
background-color: ${theme.semantic.bgLight};
`;

View File

@ -0,0 +1,76 @@
import * as React from "react";
import * as System from "~/components/system";
import { ModalPortal } from "~/components/core/ModalPortal";
import { css } from "@emotion/react";
import { AnimatePresence, motion } from "framer-motion";
import { useEscapeKey } from "~/common/hooks";
/* -------------------------------------------------------------------------------------------------
* Root
* -----------------------------------------------------------------------------------------------*/
const JUMPER_WIDTH = 640;
const JUMPER_HEIGHT = 400;
const STYLES_JUMPER_ROOT = (theme) => css`
position: fixed;
top: calc(50% - ${JUMPER_HEIGHT / 2}px);
left: calc(50% - ${JUMPER_WIDTH / 2}px);
width: ${JUMPER_WIDTH}px;
height: ${JUMPER_HEIGHT}px;
z-index: ${theme.zindex.jumper};
border-radius: 16px;
border: 1px solid ${theme.semantic.borderGrayLight};
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.bgBlurWhiteOP};
}
`;
function Root({ children, isOpen, onClose, ...props }) {
useEscapeKey(onClose);
return (
<AnimatePresence>
{isOpen ? (
<ModalPortal>
<System.Boundary enabled={true} onOutsideRectEvent={onClose}>
<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_ROOT}
{...props}
>
{children}
</motion.div>
</System.Boundary>
</ModalPortal>
) : null}
</AnimatePresence>
);
}
/* -------------------------------------------------------------------------------------------------
* Item
* -----------------------------------------------------------------------------------------------*/
function Item({ children, ...props }) {
return <div {...props}>{children}</div>;
}
/* -------------------------------------------------------------------------------------------------
* Divider
* -----------------------------------------------------------------------------------------------*/
function Divider({ children, ...props }) {
return (
<System.Divider height={1} color="bgGrayLight" {...props}>
{children}
</System.Divider>
);
}
export { Root, Item, Divider };

View File

@ -0,0 +1,126 @@
import * as React from "react";
import * as System from "~/components/system";
import * as Styles from "~/common/styles";
import * as FileUtilities from "~/common/file-utilities";
import { css } from "@emotion/react";
import { AnimatePresence, motion } from "framer-motion";
import { Show } from "~/components/utility/Show";
import FilePlaceholder from "~/components/core/ObjectPreview/placeholders/File";
import { clamp } from "lodash";
import { useEventListener } from "~/common/hooks";
const STYLES_INDICATOR_WRAPPER = (theme) => css`
${Styles.CONTAINER_CENTERED};
flex-direction: column;
position: fixed;
width: 100%;
height: 100vh;
top: 0px;
left: 0;
z-index: ${theme.zindex.cta};
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.bgBlurWhiteOP};
}
`;
const STYLES_PLACEHOLDER = css`
width: 64px;
height: 80px;
svg {
height: 100%;
width: 100%;
}
`;
export default function DropIndicator({ data }) {
const DEFAULT_DROPPING_STATE = {
isDroppingFiles: false,
totalFilesDropped: undefined,
};
const timerRef = React.useRef();
const [{ isDroppingFiles, totalFilesDropped }, setDroppingState] =
React.useState(DEFAULT_DROPPING_STATE);
const handleDragOver = (e) => {
e.preventDefault();
// NOTE(amine): Hack to hide the indicator if the user drags files outside of the app
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setDroppingState(DEFAULT_DROPPING_STATE);
}, 100);
};
const handleDragEnter = async (e) => {
e.preventDefault();
const { files } = await FileUtilities.formatDroppedFiles({
dataTransfer: e.dataTransfer,
});
setDroppingState({ totalFilesDropped: files.length || undefined, isDroppingFiles: true });
};
useEventListener("dragenter", handleDragEnter, []);
useEventListener("dragover", handleDragOver, []);
return (
<AnimatePresence>
{isDroppingFiles ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
css={STYLES_INDICATOR_WRAPPER}
>
<DroppedFilesPlaceholder totalFilesDropped={totalFilesDropped} />
<div style={{ marginTop: 64 }}>
<System.H3 as="p" style={{ textAlign: "center" }}>
{data?.name
? `Drag and drop files to save them to #${data.name}`
: "Drag and drop files to save them to Slate"}
</System.H3>
<Show when={!totalFilesDropped || totalFilesDropped > 200}>
<System.H5 as="p" color="textGrayDark">
(we recommend uploading 200 files at a time)
</System.H5>
</Show>
</div>
</motion.div>
) : null}
</AnimatePresence>
);
}
const DroppedFilesPlaceholder = ({ totalFilesDropped = 3 }) => {
const marginRight = clamp(totalFilesDropped - 1, 0, 2) * 8;
return (
<div style={{ position: "relative", right: marginRight }}>
<div css={STYLES_PLACEHOLDER}>
<FilePlaceholder />
</div>
<Show when={totalFilesDropped >= 2}>
<div
style={{ position: "absolute", top: -15, right: -16, zIndex: -1 }}
css={STYLES_PLACEHOLDER}
>
<FilePlaceholder />
</div>
</Show>
<Show when={totalFilesDropped >= 3}>
<div
style={{ position: "absolute", top: 2 * -15, right: 2 * -16, zIndex: -2 }}
css={STYLES_PLACEHOLDER}
>
<FilePlaceholder />
</div>
</Show>
</div>
);
};

View File

@ -0,0 +1,178 @@
import * as React from "react";
import * as Jumper from "~/components/core/Jumper";
import * as System from "~/components/system";
import * as FileUtilities from "~/common/file-utilities";
import * as Logging from "~/common/logging";
import * as Strings from "~/common/strings";
import * as Styles from "~/common/styles";
import * as Constants from "~/common/constants";
import * as SVG from "~/common/svg";
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;
background-color: ${theme.semantic.bgWhite};
@media (max-width: ${theme.sizes.mobile}px) {
width: 100%;
}
`;
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 STYLES_FILE_HIDDEN = css`
height: 1px;
width: 1px;
opacity: 0;
visibility: hidden;
position: fixed;
top: -1px;
left: -1px;
`;
const STYLES_LINK_UPLOAD_WRAPPER = css`
padding: 50px 72px;
`;
const STYLES_FILES_UPLOAD_WRAPPER = css`
${Styles.VERTICAL_CONTAINER_CENTERED};
padding-top: 55px;
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();
const [state, setState] = React.useState({
url: "",
urlError: false,
});
const handleUpload = (e) => {
const { files } = FileUtilities.formatUploadedFiles({ files: e.target.files });
upload({ files, slate: data });
hideUploadJumper();
};
const handleUploadLink = () => {
if (Strings.isEmpty(state.url)) {
setState((prev) => ({ ...prev, urlError: true }));
return;
}
try {
new URL(state.url);
} catch (e) {
Logging.error(e);
setState((prev) => ({ ...prev, urlError: true }));
return;
}
uploadLink({ url: state.url, slate: data });
setState({ url: "", urlError: false });
hideUploadJumper();
};
const handleChange = (e) => {
setState((prev) => ({ ...prev, [e.target.name]: e.target.value, urlError: false }));
};
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}>
<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.Divider />
<Jumper.Item css={STYLES_LINK_UPLOAD_WRAPPER}>
<div css={Styles.HORIZONTAL_CONTAINER}>
<System.Input
placeholder="Paste a link to save"
value={state.url}
inputCss={STYLES_LINK_INPUT}
style={{
boxShadow: state.urlError
? `0 0 0 1px ${Constants.system.red} inset`
: `${Constants.shadow.lightSmall}, 0 0 0 1px ${Constants.semantic.bgGrayLight} inset`,
}}
containerStyle={{ maxWidth: 600 }}
name="url"
type="url"
onChange={handleChange}
onSubmit={handleUploadLink}
autoFocus
/>
<System.ButtonPrimary style={{ marginLeft: 8, width: 96 }} onClick={handleUploadLink}>
Save
</System.ButtonPrimary>
</div>
</Jumper.Item>
<Jumper.Divider />
<Jumper.Item css={STYLES_FILES_UPLOAD_WRAPPER}>
<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 />
<System.P3 color="textGrayDark" as="span">
(we recommend uploading fewer than 200 files at a time)
</System.P3>
</System.H5>
<System.ButtonTertiary
type="label"
htmlFor="file"
style={{
marginTop: 23,
maxWidth: 122,
}}
>
Select files
</System.ButtonTertiary>
</Jumper.Item>
</Jumper.Root>
</>
);
}

View File

@ -1,514 +0,0 @@
/* eslint-disable jsx-a11y/no-autofocus */
import * as React from "react";
import * as Constants from "~/common/constants";
import * as Styles from "~/common/styles";
import * as SVG from "~/common/svg";
import * as Strings from "~/common/strings";
import * as System from "~/components/system";
import * as FileUtilities from "~/common/file-utilities";
import * as Logging from "~/common/logging";
import * as Utilities from "~/common/utilities";
import { css } from "@emotion/react";
import { Show } from "~/components/utility/Show";
import { useEscapeKey, useLockScroll } from "~/common/hooks";
import { useUploadContext, useUploadRemainingTime } from "~/components/core/Upload/Provider";
import { Table } from "~/components/system/components/Table";
import { Match, Switch } from "~/components/utility/Switch";
import { motion } from "framer-motion";
import { Link } from "~/components/core/Link";
import FilePlaceholder from "~/components/core/ObjectPreview/placeholders/File";
import DataMeter from "~/components/core/DataMeter";
/* -------------------------------------------------------------------------------------------------
* UploadModal
* -----------------------------------------------------------------------------------------------*/
const STYLES_SUMMARY_BUTTON = (theme) => css`
${Styles.BUTTON_RESET};
${Styles.HORIZONTAL_CONTAINER_CENTERED};
border-radius: 8px;
padding: 6px 8px;
background-color: ${theme.semantic.bgLight};
`;
const STYLES_MODAL = css`
z-index: ${Constants.zindex.uploadModal};
top: ${Constants.sizes.header}px;
right: 0;
bottom: 0;
position: fixed;
left: 0;
padding: 24px 24px 32px;
height: calc(100vh - ${Constants.sizes.header}px);
background-color: ${Constants.semantic.bgBlurWhiteOP};
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
-webkit-backdrop-filter: blur(75px);
backdrop-filter: blur(75px);
}
`;
const STYLES_MODAL_ELEMENTS = css`
width: 100%;
height: 100%;
`;
const STYLES_SIDEBAR_HEADER = css`
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
`;
const STYLES_DISMISS = css`
${Styles.ICON_CONTAINER}
color: ${Constants.semantic.textGray};
:focus {
outline: none;
}
:hover {
color: ${Constants.system.blue};
}
`;
const STYLES_MODAL_WRAPPER = css`
height: 100%;
width: 100%;
@keyframes global-carousel-fade-in {
from {
transform: translate(8px);
opacity: 0;
}
to {
transform: translateX(0px);
opacity: 1;
}
}
animation: global-carousel-fade-in 400ms ease;
`;
export default function UploadModal({ onAction, viewer }) {
const [{ isUploading }, { hideUploadModal }] = useUploadContext();
const [state, setState] = React.useState({
url: "",
urlError: false,
// NOTE(amine): initial || summary
view: isUploading ? "summary" : "initial",
});
const toggleSummaryView = () => {
setState((prev) => ({
...prev,
view: state.view === "initial" ? "summary" : "initial",
}));
};
const showUploadSummary = () => setState((prev) => ({ ...prev, view: "summary" }));
useEscapeKey(hideUploadModal);
useLockScroll();
return (
<div css={STYLES_MODAL}>
<div css={STYLES_MODAL_ELEMENTS}>
<div css={STYLES_SIDEBAR_HEADER} style={{ position: "absolute", right: 24 }}>
{/** TODO CLOSE */}
<button onClick={hideUploadModal} css={[Styles.BUTTON_RESET, STYLES_DISMISS]}>
<SVG.Dismiss height="24px" />
</button>
</div>
<div css={STYLES_MODAL_WRAPPER}>
<button
css={STYLES_SUMMARY_BUTTON}
onClick={toggleSummaryView}
style={{
backgroundColor:
state.view === "summary"
? Constants.semantic.bgGrayLight
: Constants.semantic.bgLight,
}}
>
<SVG.List />
<span style={{ marginLeft: 8 }}>Upload Summary</span>
</button>
<Show
when={state.view === "summary"}
fallback={<Controls showUploadSummary={showUploadSummary} />}
>
<Summary onAction={onAction} viewer={viewer} />
</Show>
</div>
</div>
</div>
);
}
/* -------------------------------------------------------------------------------------------------
* Controls
* -----------------------------------------------------------------------------------------------*/
const STYLES_FILE_HIDDEN = css`
height: 1px;
width: 1px;
opacity: 0;
visibility: hidden;
position: fixed;
top: -1px;
left: -1px;
`;
function Controls({ showUploadSummary }) {
const [, { upload, uploadLink }] = useUploadContext();
const [state, setState] = React.useState({
url: "",
urlError: false,
});
const handleUpload = (e) => {
const { files } = FileUtilities.formatUploadedFiles({ files: e.target.files });
upload({ files, slate: state.slate });
};
const handleUploadLink = () => {
if (Strings.isEmpty(state.url)) {
setState((prev) => ({ ...prev, urlError: true }));
return;
}
try {
new URL(state.url);
} catch (e) {
Logging.error(e);
setState((prev) => ({ ...prev, urlError: true }));
return;
}
uploadLink({ url: state.url, slate: state.slate });
showUploadSummary();
};
const handleChange = (e) => {
setState((prev) => ({ ...prev, [e.target.name]: e.target.value, urlError: false }));
};
return (
<div
css={Styles.VERTICAL_CONTAINER_CENTERED}
style={{ width: "100%", height: "100%", justifyContent: "center" }}
>
<input css={STYLES_FILE_HIDDEN} multiple type="file" id="file" onChange={handleUpload} />
<div css={Styles.HORIZONTAL_CONTAINER}>
<System.Input
placeholder="Paste a link to save"
value={state.url}
style={{
width: 392,
backgroundColor: Constants.semantic.bgWhite,
borderRadius: 12,
boxShadow: state.urlError
? `0 0 0 1px ${Constants.system.red} inset`
: `${Constants.shadow.lightSmall}, 0 0 0 1px ${Constants.semantic.bgGrayLight} inset`,
}}
containerStyle={{ maxWidth: 600 }}
name="url"
type="url"
onChange={handleChange}
onSubmit={handleUploadLink}
autoFocus
/>
<System.ButtonPrimary style={{ marginLeft: 8, width: 96 }} onClick={handleUploadLink}>
Save
</System.ButtonPrimary>
</div>
<System.Divider width="64px" style={{ margin: "41px 0px" }} />
<System.H5 color="textGrayDark" as="p" style={{ textAlign: "center" }}>
Drop or select files to save to Slate
<br />
(we recommend uploading fewer than 200 files at a time)
</System.H5>
<System.ButtonTertiary
type="label"
htmlFor="file"
style={{
marginTop: 23,
maxWidth: 122,
boxShadow: "0px 0px 40px rgba(15, 14, 18, 0.03)",
}}
>
Select files
</System.ButtonTertiary>
<br />
</div>
);
}
/* -------------------------------------------------------------------------------------------------
* Summary
* -----------------------------------------------------------------------------------------------*/
const STYLES_BAR_CONTAINER = (theme) => css`
border-radius: 16px;
margin-top: 24px;
padding: 24px;
box-shadow: ${theme.shadow.lightSmall};
border: 1px solid ${theme.semantic.borderGrayLight};
background-color: ${theme.semantic.bgWhite};
${Styles.HORIZONTAL_CONTAINER};
`;
const STYLES_PLACEHOLDER = css`
width: 64px;
height: 80px;
svg {
height: 100%;
width: 100%;
}
`;
const STYLES_TABLE = (theme) => css`
overflow: hidden;
overflow-y: auto;
border-radius: 12px;
box-shadow: ${theme.shadow.lightSmall};
border: 1px solid ${theme.semantic.borderGrayLight};
`;
function Summary({ onAction }) {
const [{ fileLoading, isUploading }, { retry, cancel }] = useUploadContext();
const uploadSummary = React.useMemo(() => {
const uploadSummary = Object.entries(fileLoading).map(([, file]) => file);
const statusOrder = {
failed: 1,
saving: 2,
duplicate: 3,
success: 4,
};
return uploadSummary.sort(
(a, b) => statusOrder[a.status] - statusOrder[b.status] || a.createdAt - b.createdAt
);
}, [fileLoading]);
return (
<div style={{ height: "100%", width: "100%" }} css={Styles.VERTICAL_CONTAINER}>
<Show when={isUploading}>
<SummaryBox />
</Show>
<SummaryTable
style={{ marginTop: 24, marginBottom: 20 }}
onAction={onAction}
retry={retry}
cancel={cancel}
uploadSummary={uploadSummary}
/>
</div>
);
}
const TableButton = ({ children, as = "button", ...props }) => (
<System.H5 css={Styles.BUTTON_RESET} color="blue" as={as} {...props}>
{children}
</System.H5>
);
const SummaryBox = () => {
const [
{ totalBytesUploaded, totalBytes, totalFilesUploaded, totalFiles, uploadStartingTime },
{ cancelAll },
] = useUploadContext();
const uploadRemainingTime = useUploadRemainingTime({
uploadStartingTime,
totalBytes,
totalBytesUploaded,
});
return (
<motion.div initial={{ opacity: 0.4 }} animate={{ opacity: 1 }} css={STYLES_BAR_CONTAINER}>
<div css={STYLES_PLACEHOLDER}>
<FilePlaceholder />
</div>
<div style={{ marginLeft: 36, width: "100%" }}>
<System.H4 color="textBlack">
Saving {totalFiles - totalFilesUploaded} of {totalFiles} Objects...
</System.H4>
<DataMeter bytes={totalBytesUploaded} maximumBytes={totalBytes} style={{ marginTop: 10 }} />
<System.H5 color="textGrayDark" style={{ marginTop: 12 }}>
{Strings.bytesToSize(totalBytesUploaded, 0)} of {Strings.bytesToSize(totalBytes, 0)}{" "}
<Show when={uploadRemainingTime && uploadRemainingTime !== Infinity}>
{Strings.getRemainingTime(uploadRemainingTime)} (Please keep this tab open during
uploading)
</Show>
</System.H5>
<System.ButtonTertiary
onClick={cancelAll}
style={{
backgroundColor: Constants.semantic.bgLight,
marginTop: 15,
padding: "1px 12px 3px",
minHeight: "auto",
boxShadow: "none",
}}
>
Cancel
</System.ButtonTertiary>
</div>
</motion.div>
);
};
const SummaryTable = ({ uploadSummary, onAction, retry, cancel, ...props }) => {
const columns = React.useMemo(() => {
return [
{
key: "status",
name: <System.H5 color="textGrayDark">Status</System.H5>,
width: "19%",
contentstyle: { padding: "0px" },
},
{
key: "object",
name: <System.H5 color="textGrayDark">Objects</System.H5>,
width: "30%",
contentstyle: { padding: "0px" },
},
{
key: "date",
name: <System.H5 color="textGrayDark">Date saved</System.H5>,
width: "23%",
contentstyle: { padding: "0px" },
},
{
key: "size",
name: <System.H5 color="textGrayDark">Sizes</System.H5>,
width: "20%",
contentstyle: { padding: "0px" },
},
{
key: "actions",
name: <System.H5 color="textGrayDark">Actions</System.H5>,
width: "8%",
contentstyle: { padding: "0px" },
},
];
}, []);
const rows = React.useMemo(() => {
return uploadSummary.map((row) => ({
status: (
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<Switch fallback={<System.H5>Saved</System.H5>}>
<Match when={row.status === "saving"}>
<System.LoaderSpinner />
<System.H5 color="blue" style={{ marginLeft: 8 }}>
Saving
</System.H5>
</Match>
<Match when={row.status === "failed"}>
<System.H5 color="red">Failed</System.H5>
</Match>
<Match when={row.status === "duplicate"}>
<System.H5 color="green">Already saved</System.H5>
</Match>
</Switch>
</div>
),
object: (
<div>
{row.cid ? (
<Link onAction={onAction} href={`/_/data?cid=${row.cid}`}>
<System.H5 nbrOflines={1} title={row.name}>
{row.name}
</System.H5>
</Link>
) : (
<System.H5 nbrOflines={1} title={row.name}>
{row.name}
</System.H5>
)}
</div>
),
date: (
<div>
<System.H5 nbrOflines={1} title={row.createdAt}>
{Utilities.formatDateToString(row.createdAt)}
</System.H5>
</div>
),
size: (
<div>
<Show
fallback={<System.H5>{Strings.bytesToSize(row.total)}</System.H5>}
when={row.status === "saving"}
>
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<DataMeter bytes={row.loaded} maximumBytes={row.total} style={{ maxWidth: 84 }} />
<System.P3 style={{ marginLeft: 8 }}>
{Strings.bytesToSize(row.loaded, 0)} of {Strings.bytesToSize(row.total, 0)}
</System.P3>
</div>
</Show>
</div>
),
actions: (
<div>
<Switch
fallback={
<div css={Styles.HORIZONTAL_CONTAINER}>
<Link onAction={onAction} href={`/_/data?cid=${row.cid}`}>
<TableButton as="p">Edit</TableButton>
</Link>
<TableButton style={{ marginLeft: 15 }}>Share</TableButton>
</div>
}
>
<Match when={row.status === "saving"}>
<TableButton
css={Styles.BUTTON_RESET}
onClick={() => cancel({ fileKey: row.id })}
color="blue"
as="button"
>
Cancel
</TableButton>
</Match>
<Match when={row.status === "failed"}>
<TableButton onClick={() => retry({ fileKey: row.id })}>Retry</TableButton>
</Match>
</Switch>
</div>
),
}));
}, [uploadSummary]);
return (
<div css={STYLES_TABLE} {...props}>
<Table
noColor
topRowStyle={{
position: "sticky",
zIndex: 1,
top: "0%",
padding: "14px 24px",
backgroundColor: Constants.semantic.bgLight,
}}
rowStyle={{
padding: "14px 24px",
backgroundColor: Constants.system.white,
}}
data={{
columns,
rows,
}}
/>
</div>
);
};

View File

@ -0,0 +1,415 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as System from "~/components/system";
import * as Constants from "~/common/constants";
import * as SVG from "~/common/svg";
import * as Strings from "~/common/strings";
import { useUploadContext } from "~/components/core/Upload/Provider";
import { motion, AnimatePresence } from "framer-motion";
import { css } from "@emotion/react";
import { Match, Switch } from "~/components/utility/Switch";
import { Show } from "~/components/utility/Show";
import { useHover } from "~/common/hooks";
import DataMeter from "~/components/core/DataMeter";
import BlobObjectPreview from "~/components/core/BlobObjectPreview";
import { clamp } from "lodash";
/* -------------------------------------------------------------------------------------------------
* Popup
* -----------------------------------------------------------------------------------------------*/
const STYLES_POPUP_WRAPPER = (theme) => css`
position: fixed;
bottom: 24px;
right: 24px;
z-index: ${theme.zindex.tooltip};
@media (max-width: ${theme.sizes.mobile}px) {
right: 50%;
transform: translateX(50%);
}
`;
const STYLES_DISMISS_BUTTON = (theme) => css`
${Styles.BUTTON_RESET};
position: absolute;
right: -8px;
top: -8px;
border-radius: 50%;
padding: 4px;
border: 1px solid ${theme.semantic.borderGrayLight4};
color: ${theme.semantic.textGrayDark};
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.bgBlurWhiteOP};
}
svg {
display: block;
}
`;
const STYLES_POPUP_CONTENT = css`
border-radius: 12px;
overflow: hidden;
`;
const useUploadPopup = ({ totalFilesSummary }) => {
const [{ isFinished }, { resetUploadState }] = useUploadContext();
const [popupState, setPopupState] = React.useState({
isVisible: false,
isSummaryExpanded: false,
});
// NOTE(amine): popup handlers
const showUploadPopup = () => setPopupState((prev) => ({ ...prev, isVisible: true }));
const hideUploadPopup = () => setPopupState((prev) => ({ ...prev, isVisible: false }));
const expandUploadSummary = () => setPopupState({ isVisible: true, isSummaryExpanded: true });
const collapseUploadSummary = () => setPopupState({ isVisible: true, isSummaryExpanded: false });
const timeoutRef = React.useRef();
//NOTE(amine): show the upload summary, then automatically collapse the upload summary after 3 seconds
const isStarted = totalFilesSummary.total > 0;
React.useEffect(() => {
if (!isStarted) return;
expandUploadSummary();
timeoutRef.current = setTimeout(collapseUploadSummary, 3000);
}, [isStarted]);
/**
* NOTE(amine): show the upload summary when a file fails to upload or is added to the queue,
* then automatically collapse the upload summary after 3 seconds
*/
const isSummaryExpandedRef = React.useRef();
isSummaryExpandedRef.current = popupState.isSummaryExpanded;
React.useEffect(() => {
if (isSummaryExpandedRef.current || totalFilesSummary.total === 0) return;
expandUploadSummary();
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(collapseUploadSummary, 3000);
}, [totalFilesSummary.failed, totalFilesSummary.total]);
// NOTE(amine): show the upload summary when upload finishes
const totalFilesSummaryRef = React.useRef();
totalFilesSummaryRef.current = totalFilesSummary;
React.useEffect(() => {
clearTimeout(timeoutRef.current);
if (!isFinished) return;
//NOTE(amine): if all the upload items have been canceled, hide the upload popup
if (totalFilesSummaryRef.current.total === 0) {
hideUploadPopup();
resetUploadState();
return;
}
expandUploadSummary();
//NOTE(amine): if the upload is successful, automatically close the popup
if (totalFilesSummaryRef.current.failed === 0) {
timeoutRef.current = setTimeout(() => {
hideUploadPopup();
resetUploadState();
}, 10000);
}
}, [isFinished]);
/**
* NOTE(amine): the upload summary is set to automatically collapse when the upload starts and when a file fails to upload.
* Let's cancel those effects when the user hovers over the summary
*/
const cancelAutoCollapseOnMouseEnter = () => clearTimeout(timeoutRef.current);
return [
popupState,
{
showUploadPopup,
hideUploadPopup,
expandUploadSummary,
collapseUploadSummary,
cancelAutoCollapseOnMouseEnter,
},
];
};
const useUploadSummary = ({ fileLoading }) =>
React.useMemo(() => {
let totalFilesSummary = { failed: 0, duplicate: 0, saved: 0, total: 0 };
const uploadSummary = Object.entries(fileLoading).map(([, file]) => {
totalFilesSummary["total"]++;
if (file.status === "saving") return { ...file, filename: file.name };
totalFilesSummary[file.status]++;
return { ...file, filename: file.name };
});
const statusOrder = {
failed: 1,
saving: 2,
duplicate: 3,
saved: 4,
};
return {
totalFilesSummary,
uploadSummary: uploadSummary.sort(
(a, b) => statusOrder[a.status] - statusOrder[b.status] || a.createdAt - b.createdAt
),
};
}, [fileLoading]);
export function Popup() {
const [{ isFinished, fileLoading }, { resetUploadState }] = useUploadContext();
const { uploadSummary, totalFilesSummary } = useUploadSummary({ fileLoading });
const [isHovered, { handleOnMouseEnter, handleOnMouseLeave }] = useHover();
const [
popupState,
{ hideUploadPopup, expandUploadSummary, collapseUploadSummary, cancelAutoCollapseOnMouseEnter },
] = useUploadPopup({ totalFilesSummary });
if (!popupState.isVisible) return null;
return (
<div
css={STYLES_POPUP_WRAPPER}
onMouseEnter={handleOnMouseEnter}
onMouseLeave={handleOnMouseLeave}
>
<div css={[STYLES_POPUP_CONTENT]}>
<AnimatePresence>
{popupState.isSummaryExpanded ? (
<motion.div
initial={{ y: 400 }}
animate={{ y: 0 }}
exit={{ y: 400 }}
transition={{ type: "spring", stiffness: 170, damping: 26 }}
onMouseEnter={cancelAutoCollapseOnMouseEnter}
>
<Summary uploadSummary={uploadSummary} />
</motion.div>
) : null}
</AnimatePresence>
<Header
totalFilesSummary={totalFilesSummary}
popupState={popupState}
expandUploadSummary={expandUploadSummary}
collapseUploadSummary={collapseUploadSummary}
/>
</div>
<Show when={isHovered && isFinished}>
<button css={STYLES_DISMISS_BUTTON} onClick={() => (hideUploadPopup(), resetUploadState())}>
<SVG.Dismiss width={16} />
</button>
</Show>
</div>
);
}
/* -------------------------------------------------------------------------------------------------
* Popup Header
* -----------------------------------------------------------------------------------------------*/
const STYLES_POPUP_HEADER = (theme) => css`
color: ${theme.semantic.textGrayDark};
width: 264px;
padding: 9px 12px 7px;
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.bgBlurWhiteOP};
}
border-radius: 12px;
transition: border-radius 0.5s ease-in-out;
border: 1px solid ${theme.semantic.bgGrayLight};
svg {
display: block;
}
`;
const STYLES_RESET_BORDER_TOP = css`
border-top: none;
border-radius: 0px 0px 12px 12px;
`;
function Header({ totalFilesSummary, popupState, expandUploadSummary, collapseUploadSummary }) {
const [{ isFinished, totalBytesUploaded, totalBytes }, { retryAll }] = useUploadContext();
const uploadProgress = clamp(Math.floor((totalBytesUploaded / totalBytes) * 100), 0, 100);
if (isFinished && totalFilesSummary.failed > 0) {
return (
<span
css={[STYLES_POPUP_HEADER, STYLES_RESET_BORDER_TOP, Styles.HORIZONTAL_CONTAINER_CENTERED]}
>
<SVG.AlertTriangle style={{ color: Constants.system.red }} />
<System.P2 style={{ marginLeft: 12 }}>{totalFilesSummary.failed} failed</System.P2>
<button
css={Styles.BUTTON_RESET}
style={{ marginLeft: "auto", color: Constants.system.blue }}
onClick={retryAll}
>
<span css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<SVG.RotateCcw width={16} />
<System.P2 style={{ marginLeft: 4 }}>Retry failed</System.P2>
</span>
</button>
</span>
);
}
if (isFinished) {
return (
<div
css={[STYLES_POPUP_HEADER, STYLES_RESET_BORDER_TOP, Styles.HORIZONTAL_CONTAINER_CENTERED]}
>
<SVG.CheckCircle style={{ color: Constants.system.green }} />
<System.P2 style={{ marginLeft: 12 }}>
{totalFilesSummary.saved + totalFilesSummary.duplicate} saved
</System.P2>
</div>
);
}
return (
<motion.button
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ y: 10, opacity: 0 }}
css={[
Styles.BUTTON_RESET,
STYLES_POPUP_HEADER,
popupState.isSummaryExpanded && STYLES_RESET_BORDER_TOP,
]}
aria-label="Upload Summary"
onClick={popupState.isSummaryExpanded ? collapseUploadSummary : expandUploadSummary}
>
<span css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<System.P2 color="textBlack" style={{ width: "5ch" }}>
{uploadProgress}%
</System.P2>
<DataMeter
bytes={totalBytesUploaded}
maximumBytes={totalBytes}
style={{
width: 164,
marginLeft: 8,
backgroundColor: Constants.semantic.bgGrayLight,
}}
/>
<motion.div initial={null} animate={{ rotateX: popupState.isSummaryExpanded ? 180 : 0 }}>
<SVG.ChevronUp style={{ marginLeft: 24, display: "block" }} />
</motion.div>
</span>
</motion.button>
);
}
/* -------------------------------------------------------------------------------------------------
* Popup Summary
* -----------------------------------------------------------------------------------------------*/
const STYLES_SUMMARY = (theme) => css`
position: relative;
background-color: ${theme.system.white};
max-height: 312px;
overflow-y: auto;
overflow-x: hidden;
border: 1px solid ${theme.semantic.bgGrayLight};
border-bottom: none;
border-radius: 12px 12px 0px 0px;
// NOTE(amine): fix alignment issue caused by inline display
svg {
display: block;
}
`;
const STYLES_PREVIEW_WRAPPER = css`
width: 24px;
height: 24px;
border-radius: 6px;
overflow: hidden;
`;
const STYLES_SUMMARY_ACTION = css`
${Styles.BUTTON_RESET};
position: relative;
width: 32;
height: 32;
right: -16;
`;
function Summary({ uploadSummary }) {
const [, { retry, cancel }] = useUploadContext();
return (
<div css={STYLES_SUMMARY}>
{uploadSummary.map((file) => (
<>
<div
key={file.id}
style={{ padding: "9px 12px 11px" }}
css={Styles.HORIZONTAL_CONTAINER_CENTERED}
>
<div css={STYLES_PREVIEW_WRAPPER}>
<BlobObjectPreview file={file} placeholderRatio={2.4} />
</div>
<div style={{ marginLeft: 12, maxWidth: 164 }}>
<System.H5 nbrOflines={1} as="p">
{file.name}
</System.H5>
<Switch
fallback={
<System.P3 color="textGrayDark">{Strings.bytesToSize(file.total, 0)}</System.P3>
}
>
<Match when={file.status === "saving"}>
<DataMeter
bytes={file.loaded}
maximumBytes={file.total}
style={{ maxWidth: 84, marginTop: 2 }}
/>
</Match>
<Match when={file.status === "failed"}>
<System.P3 color="red">failed</System.P3>
</Match>
<Match when={file.status === "duplicate"}>
<System.P3 color="green">already saved</System.P3>
</Match>
</Switch>
</div>
<div style={{ marginLeft: "auto" }}>
<Switch fallback={<SVG.CheckCircle style={{ color: Constants.system.green }} />}>
<Match when={file.status === "saving"}>
<button
css={STYLES_SUMMARY_ACTION}
style={{ color: Constants.semantic.textGray }}
onClick={() => cancel({ fileKey: file.id })}
>
<SVG.XCircle />
</button>
</Match>
<Match when={file.status === "failed"}>
<button
css={STYLES_SUMMARY_ACTION}
style={{ color: Constants.system.blue }}
onClick={() => retry({ fileKey: file.id })}
>
<SVG.RotateCcw width={16} />
</button>
</Match>
</Switch>
</div>
</div>
<System.Divider height={1} color="bgLight" />
</>
))}
</div>
);
}

View File

@ -1,6 +1,7 @@
import * as React from "react";
import * as UploadUtilities from "~/common/upload-utilities";
import * as FileUtilities from "~/common/file-utilities";
import * as Logging from "~/common/logging";
import { useEventListener } from "~/common/hooks";
@ -8,58 +9,67 @@ const UploadContext = React.createContext({});
export const useUploadContext = () => React.useContext(UploadContext);
export const Provider = ({ children, page, data, viewer }) => {
const [uploadState, uploadHandlers] = useUpload();
const [uploadState, uploadHandlers] = useUpload({});
const [isUploadModalVisible, { showUploadModal, hideUploadModal }] = useUploadModal();
const [isUploadJumperVisible, { showUploadJumper, hideUploadJumper }] = useUploadJumper();
useUploadOnDrop({ upload: uploadHandlers.upload, page, data, viewer });
useUploadFromClipboard({ upload: uploadHandlers.upload, page, data, viewer });
useUploadFromClipboard({
upload: uploadHandlers.upload,
uploadLink: uploadHandlers.uploadLink,
page,
data,
viewer,
});
useEventListener("upload-modal-open", showUploadModal);
useEventListener("open-upload-jumper", showUploadJumper);
const providerValue = React.useMemo(
() => [
{ isUploadModalVisible, ...uploadState },
{ showUploadModal, hideUploadModal, ...uploadHandlers },
{ ...uploadState, isUploadJumperVisible },
{
...uploadHandlers,
showUploadJumper,
hideUploadJumper,
},
],
[isUploadModalVisible, uploadHandlers, uploadState]
[uploadHandlers, uploadState, isUploadJumperVisible]
);
return <UploadContext.Provider value={providerValue}>{children}</UploadContext.Provider>;
};
const useUploadModal = () => {
const [isUploadModalVisible, setUploadModalState] = React.useState(false);
const showUploadModal = () => setUploadModalState(true);
const hideUploadModal = () => setUploadModalState(false);
return [isUploadModalVisible, { showUploadModal, hideUploadModal }];
const useUploadJumper = () => {
const [isUploadJumperVisible, setUploadJumperState] = React.useState(false);
const showUploadJumper = () => setUploadJumperState(true);
const hideUploadJumper = () => setUploadJumperState(false);
return [isUploadJumperVisible, { showUploadJumper, hideUploadJumper }];
};
const useUpload = () => {
const DEFAULT_STATE = {
fileLoading: {},
isUploading: false,
uploadStartingTime: null,
totalBytesUploaded: 0,
totalBytes: 0,
totalFilesUploaded: 0,
totalFiles: 0,
uploadRemainingTime: 0,
isFinished: false,
};
const [uploadState, setUploadState] = React.useState(DEFAULT_STATE);
const uploadProvider = React.useMemo(() => {
const handleStartUploading = () => {
setUploadState((prev) => ({ ...prev, isUploading: true, uploadStartingTime: new Date() }));
setUploadState((prev) => ({ ...prev, isFinished: false, isUploading: true }));
};
const handleFinishUploading = () => {
setUploadState((prev) => ({
...DEFAULT_STATE,
fileLoading: prev.fileLoading,
uploadStartingTime: null,
isFinished: true,
}));
};
@ -77,8 +87,10 @@ const useUpload = () => {
createdAt: Date.now(),
loaded: 0,
total: file.size,
blob: file,
},
},
isFinished: false,
totalFiles: prev.totalFiles + 1,
totalBytes: prev.totalBytes + file.size,
}));
@ -87,7 +99,7 @@ const useUpload = () => {
const handleSuccess = ({ fileKey, cid }) => {
setUploadState((prev) => {
const newFileLoading = { ...prev.fileLoading };
newFileLoading[fileKey].status = "success";
newFileLoading[fileKey].status = "saved";
newFileLoading[fileKey].cid = cid;
return {
...prev,
@ -142,9 +154,11 @@ const useUpload = () => {
const newFileLoading = { ...prev.fileLoading };
const newTotalFiles = prev.totalFiles - fileKeys.length;
let newTotalBytes = prev.totalBytes;
let newTotalBytesUploaded = prev.totalBytesUploaded;
fileKeys.forEach((fileKey) => {
newTotalBytes -= newFileLoading[fileKey].total;
newTotalBytesUploaded -= newFileLoading[fileKey].loaded;
delete newFileLoading[fileKey];
});
@ -153,6 +167,7 @@ const useUpload = () => {
fileLoading: newFileLoading,
totalFiles: newTotalFiles,
totalBytes: newTotalBytes,
totalBytesUploaded: newTotalBytesUploaded,
};
});
};
@ -169,14 +184,18 @@ const useUpload = () => {
});
}, []);
const resetUploadState = () => (uploadProvider.clearUploadCache(), setUploadState(DEFAULT_STATE));
return [
uploadState,
{
upload: uploadProvider.upload,
uploadLink: uploadProvider.uploadLink,
retry: uploadProvider.retry,
retryAll: uploadProvider.retryAll,
cancel: uploadProvider.cancel,
cancelAll: uploadProvider.cancelAll,
resetUploadState,
},
];
};
@ -210,55 +229,35 @@ const useUploadOnDrop = ({ upload, page, data, viewer }) => {
useEventListener("drop", handleDrop, []);
};
const useUploadFromClipboard = ({ upload, page, data, viewer }) => {
const useUploadFromClipboard = ({ upload, uploadLink, page, data, viewer }) => {
const handlePaste = (e) => {
const clipboardItems = e.clipboardData.items || [];
if (!clipboardItems) return;
const { files } = FileUtilities.formatPastedImages({
clipboardItems,
});
//NOTE(amine): skip when pasting into an input/textarea or an element with contentEditable set to true
const eventTargetTag = document?.activeElement.tagName.toLowerCase();
const isEventTargetEditable = !!document?.activeElement.getAttribute("contentEditable");
if (eventTargetTag === "input" || eventTargetTag === "textarea" || isEventTargetEditable) {
return;
}
let slate = null;
if (page?.id === "NAV_SLATE" && data?.ownerId === viewer?.id) {
slate = data;
}
const link = e.clipboardData?.getData("text");
try {
new URL(link);
uploadLink({ url: link, slate });
} catch (e) {
Logging.error(e);
}
const clipboardItems = e.clipboardData?.items || [];
if (!clipboardItems) return;
const { files } = FileUtilities.formatPastedImages({
clipboardItems,
});
upload({ files, slate });
};
useEventListener("paste", handlePaste);
};
export const useUploadRemainingTime = ({ uploadStartingTime, totalBytes, totalBytesUploaded }) => {
const [remainingTime, setRemainingTime] = React.useState();
// NOTE(amine): calculate remaining time for current upload queue
const SECOND = 1000;
// NOTE(amine): hack around stale state in the useEffect callback
const uploadStartingTimeRef = React.useRef(null);
uploadStartingTimeRef.current = uploadStartingTime;
const bytesRef = React.useRef({
bytesLoaded: totalBytesUploaded,
bytesTotal: totalBytes,
});
bytesRef.current = {
bytesLoaded: totalBytesUploaded,
bytesTotal: totalBytes,
};
React.useEffect(() => {
const intervalId = setInterval(() => {
const { bytesLoaded, bytesTotal } = bytesRef.current;
const timeElapsed = new Date() - uploadStartingTimeRef.current;
// NOTE(amine): upload speed in seconds
const uploadSpeed = bytesLoaded / (timeElapsed / SECOND);
setRemainingTime(Math.round((bytesTotal - bytesLoaded) / uploadSpeed));
}, SECOND);
return () => clearInterval(intervalId);
}, []);
// NOTE(amine): delay by 1 minute
return remainingTime + 60;
useEventListener("paste", handlePaste, []);
};

View File

@ -1,33 +1,26 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as System from "~/components/system";
import * as Constants from "~/common/constants";
import * as SVG from "~/common/svg";
import * as Events from "~/common/custom-events";
import { useUploadContext } from "~/components/core/Upload/Provider";
import { Show } from "~/components/utility/Show";
import { ModalPortal } from "../ModalPortal";
import { motion } from "framer-motion";
import { css } from "@emotion/react";
import { Provider } from "~/components/core/Upload/Provider";
import { Popup } from "~/components/core/Upload/popup";
import { UploadJumper as Jumper } from "~/components/core/Upload/Jumper";
import UploadModal from "~/components/core/Upload/Modal";
import DataMeter from "~/components/core/DataMeter";
import DropIndicator from "~/components/core/Upload/DropIndicator";
/* -------------------------------------------------------------------------------------------------
* Root
* -----------------------------------------------------------------------------------------------*/
const Root = ({ onAction, viewer, children }) => {
const [{ isUploadModalVisible }] = useUploadContext();
const Root = ({ children, data }) => {
return (
<>
{children}
<Show when={isUploadModalVisible}>
<ModalPortal>
<UploadModal viewer={viewer} onAction={onAction} />
</ModalPortal>
</Show>
<ModalPortal>
<Jumper data={data} />
<Popup />
<DropIndicator data={data} />
</ModalPortal>
</>
);
};
@ -36,20 +29,17 @@ const Root = ({ onAction, viewer, children }) => {
* Trigger
* -----------------------------------------------------------------------------------------------*/
const Trigger = ({ enableMetrics = false, viewer, css, children, ...props }) => {
const Trigger = ({ viewer, css, children, ...props }) => {
const showUploadModal = () => {
if (!viewer) {
Events.dispatchCustomEvent({ name: "slate-global-open-cta", detail: {} });
return;
}
Events.dispatchCustomEvent({ name: "upload-modal-open" });
Events.dispatchCustomEvent({ name: "open-upload-jumper" });
};
return (
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<Show when={enableMetrics}>
<UploadMetrics />
</Show>
<button css={[Styles.BUTTON_RESET, css]} onClick={showUploadModal} {...props}>
{children}
</button>
@ -57,34 +47,4 @@ const Trigger = ({ enableMetrics = false, viewer, css, children, ...props }) =>
);
};
const UploadMetrics = () => {
const [{ isUploading, totalBytesUploaded, totalBytes }, { showUploadModal }] = useUploadContext();
const uploadProgress = Math.floor((totalBytesUploaded / totalBytes) * 100);
return (
isUploading && (
<motion.button
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ y: 10, opacity: 0 }}
css={Styles.BUTTON_RESET}
style={{ marginRight: 14 }}
aria-label="Upload"
onClick={showUploadModal}
>
<System.P3 color="textBlack">{uploadProgress}%</System.P3>
<DataMeter
bytes={totalBytesUploaded}
maximumBytes={totalBytes}
style={{
width: 28,
marginTop: 4,
backgroundColor: Constants.semantic.bgGrayLight,
}}
/>
</motion.button>
)
);
};
export { Provider, Root, Trigger };

View File

@ -72,6 +72,7 @@ const STYLES_INPUT_CONTAINER_FULL = css`
const STYLES_INPUT = css`
${"" /* ${INPUT_STYLES} */}
display: flex;
width: 100%;
align-items: center;
justify-content: flex-start;
height: 40px;
@ -235,7 +236,6 @@ export class Input extends React.Component {
<div
css={[STYLES_INPUT, this.props.inputCss]}
style={{
width: "100%",
position: "relative",
boxShadow: this.props.validation
? `0 1px 4px rgba(0, 0, 0, 0.07), inset 0 0 0 2px ${

View File

@ -34,7 +34,7 @@ const truncateElements = (nbrOfLines) =>
nbrOfLines &&
css`
overflow: hidden;
word-break: break-word;
word-break: break-all;
text-overflow: ellipsis;
-webkit-line-clamp: ${nbrOfLines};
display: -webkit-box;

View File

@ -4,10 +4,27 @@ import ThemeProvider from "~/components/system/ThemeProvider";
import * as React from "react";
import { Global } from "@emotion/react";
import { injectGlobalStyles, injectCodeBlockStyles } from "~/common/styles/global";
import { IntercomProvider } from 'react-use-intercom';
import {
injectGlobalStyles,
injectCodeBlockStyles,
injectIntercomStyles,
} from "~/common/styles/global";
import { IntercomProvider, useIntercom } from "react-use-intercom";
const INTERCOM_APP_ID = 'jwgbampk';
const INTERCOM_APP_ID = "jwgbampk";
const CustomIntercomConfig = () => {
const { boot } = useIntercom();
React.useLayoutEffect(() => {
boot({
alignment: "left",
horizontalPadding: 23,
verticalPadding: 28,
});
}, [boot]);
return null;
};
// NOTE(wwwjim):
// https://nextjs.org/docs/advanced-features/custom-app
@ -17,8 +34,10 @@ function MyApp({ Component, pageProps }) {
<React.Fragment>
<script src="//cdn.iframe.ly/embed.js" async></script>
<Global styles={injectGlobalStyles()} />
<Global styles={injectIntercomStyles()} />
<Global styles={injectCodeBlockStyles()} />
<IntercomProvider appId={INTERCOM_APP_ID} autoBoot>
<IntercomProvider appId={INTERCOM_APP_ID}>
<CustomIntercomConfig />
<Component {...pageProps} />
</IntercomProvider>
</React.Fragment>