mirror of
https://github.com/filecoin-project/slate.git
synced 2024-12-26 18:44:56 +03:00
Merge pull request #959 from filecoin-project/@aminejv/saving-changes
Additional changes for save
This commit is contained in:
commit
3fee27424c
@ -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 = {
|
||||
|
@ -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 }];
|
||||
};
|
||||
|
@ -251,6 +251,7 @@ export const BUTTON_RESET = css`
|
||||
margin: 0;
|
||||
background-color: unset;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
${HOVERABLE}
|
||||
`;
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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) => {
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
54
components/core/BlobObjectPreview.js
Normal file
54
components/core/BlobObjectPreview.js
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -85,7 +85,7 @@ const useFilterWorker = ({ filterState, setFilterObjects, library }) => {
|
||||
setFilterObjects(e.data);
|
||||
},
|
||||
},
|
||||
[view, subview, type]
|
||||
[view, subview, library, type]
|
||||
);
|
||||
|
||||
return workerState;
|
||||
|
@ -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};
|
||||
`;
|
||||
|
76
components/core/Jumper/index.js
Normal file
76
components/core/Jumper/index.js
Normal 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 };
|
126
components/core/Upload/DropIndicator.js
Normal file
126
components/core/Upload/DropIndicator.js
Normal 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>
|
||||
);
|
||||
};
|
178
components/core/Upload/Jumper.js
Normal file
178
components/core/Upload/Jumper.js
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
415
components/core/Upload/Popup.js
Normal file
415
components/core/Upload/Popup.js
Normal 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>
|
||||
);
|
||||
}
|
@ -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, []);
|
||||
};
|
||||
|
@ -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 };
|
||||
|
@ -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 ${
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user