feat(Upload/Popup): add popup component

This commit is contained in:
Aminejv 2021-10-08 17:47:58 +01:00
parent 8727358958
commit 1108e1aa15

View File

@ -1,126 +1,400 @@
import * as React from "react";
import * as System from "~/components/system";
import * as Styles from "~/common/styles";
import * as Strings from "~/common/strings";
import * as FileUtilities from "~/common/file-utilities";
import * as System from "~/components/system";
import * as Constants from "~/common/constants";
import * as SVG from "~/common/svg";
import { useUploadContext } from "~/components/core/Upload/Provider";
import { motion, AnimatePresence } from "framer-motion";
import { css } from "@emotion/react";
import { AnimatePresence, motion } from "framer-motion";
import { Match, Switch } from "~/components/utility/Switch";
import { Show } from "~/components/utility/Show";
import { useHover } from "~/common/hooks";
import FilePlaceholder from "~/components/core/ObjectPreview/placeholders/File";
import { clamp } from "lodash";
import { useEventListener } from "~/common/hooks";
import DataMeter from "~/components/core/DataMeter";
import BlobObjectPreview from "~/components/core/BlobObjectPreview";
/* -------------------------------------------------------------------------------------------------
* Popup
* -----------------------------------------------------------------------------------------------*/
const STYLES_POPUP = (theme) => css`
${Styles.CONTAINER_CENTERED};
flex-direction: column;
const STYLES_POPUP_WRAPPER = (theme) => css`
position: fixed;
width: 100%;
height: 100vh;
top: 0px;
left: 0;
background-color: ${theme.semantic.bgWhite};
bottom: 24px;
right: 24px;
z-index: ${theme.zindex.sidebar};
@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};
}
`;
const STYLES_PLACEHOLDER = css`
width: 64px;
height: 80px;
svg {
height: 100%;
width: 100%;
display: block;
}
`;
export default function Popup() {
const DEFAULT_DROPPING_STATE = {
isDroppingFiles: false,
totalFilesDropped: undefined,
};
const STYLES_POPUP_CONTENT = css`
border-radius: 12px;
overflow: hidden;
`;
const timerRef = React.useRef();
const useUploadPopup = ({ totalFilesSummary }) => {
const [{ isFinished }, { resetUploadState }] = useUploadContext();
const [popupState, setPopupState] = React.useState({
isVisible: false,
isSummaryExpanded: false,
});
const [{ isDroppingFiles, totalFilesDropped }, setDroppingState] =
React.useState(DEFAULT_DROPPING_STATE);
// 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 handleDragOver = (e) => {
e.preventDefault();
// NOTE(amine): Hack to hide the popup if the user drags files outside of the app
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setDroppingState(DEFAULT_DROPPING_STATE);
}, 100);
};
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]);
const handleDragEnter = async (e) => {
e.preventDefault();
const { files } = await FileUtilities.formatDroppedFiles({
dataTransfer: e.dataTransfer,
/**
* NOTE(amine): show the upload summary when a file fails to upload,
* then automatically collapse the upload summary after 3 seconds
*/
const isSummaryExpandedRef = React.useRef();
isSummaryExpandedRef.current = popupState.isSummaryExpanded;
React.useEffect(() => {
if (isSummaryExpandedRef.current || totalFilesSummary.failed === 0) return;
expandUploadSummary();
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(collapseUploadSummary, 3000);
}, [totalFilesSummary.failed]);
// NOTE(amine): show the upload summary when upload finishes
const totalFilesSummaryRef = React.useRef();
totalFilesSummaryRef.current = totalFilesSummary;
React.useEffect(() => {
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;
}
clearTimeout(timeoutRef.current);
expandUploadSummary();
}, [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 };
});
setDroppingState({ totalFilesDropped: files.length || undefined, isDroppingFiles: true });
};
useEventListener("dragenter", handleDragEnter, []);
useEventListener("dragover", handleDragOver, []);
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 (
<AnimatePresence>
{isDroppingFiles ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
css={STYLES_POPUP}
>
<DroppedFilesPlaceholder totalFilesDropped={totalFilesDropped} />
<div style={{ marginTop: 64 }}>
<System.H3 as="p" style={{ textAlign: "center" }}>
Dropping {totalFilesDropped}{" "}
{totalFilesDropped ? Strings.pluralize("file", totalFilesDropped) : "files"} to save
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
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={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 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 = Math.floor((totalBytesUploaded / totalBytes) * 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.H5>Saved</System.H5>}>
<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>
);
}