Merge pull request #944 from filecoin-project/@aminejv/new-saving-flow

Update: Saving flow
This commit is contained in:
martinalong 2021-09-22 12:49:43 -07:00 committed by GitHub
commit 654dbb3b32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1300 additions and 1389 deletions

View File

@ -44,6 +44,7 @@ const returnJSON = async (route, options) => {
return json;
} catch (e) {
if (e.name === "AbortError") return { aborted: true };
Logging.error(e);
}
};
@ -223,11 +224,12 @@ export const createFile = async (data) => {
});
};
export const createLink = async (data) => {
export const createLink = async (data, options) => {
await Websockets.checkWebsocket();
return await returnJSON(`/api/data/create-link`, {
...DEFAULT_OPTIONS,
body: JSON.stringify({ data }),
...options,
});
};

View File

@ -91,6 +91,7 @@ export const semantic = {
textGrayDark: system.grayDark3,
textBlack: system.black,
bgWhite: system.white,
bgLight: system.grayLight6,
bgGrayLight: system.grayLight5,
bgBlurWhite: "rgba(255, 255, 255, 0.7)",
@ -137,6 +138,7 @@ export const zindex = {
body: 2,
sidebar: 5,
alert: 3,
uploadModal: 3,
header: 4,
modal: 6,
tooltip: 7,

View File

@ -1,12 +1,10 @@
import * as Actions from "~/common/actions";
import * as Store from "~/common/store";
import * as Credentials from "~/common/credentials";
import * as Strings from "~/common/strings";
import * as Validations from "~/common/validations";
import * as Events from "~/common/custom-events";
import * as Logging from "~/common/logging";
import * as Environment from "~/common/environment";
import * as Window from "~/common/window";
import { encode, isBlurhashValid } from "blurhash";
import { v4 as uuid } from "uuid";
@ -49,87 +47,15 @@ const getCookie = (name) => {
if (match) return match[2];
};
export const uploadLink = async ({ url, slate }) => {
Events.dispatchMessage({ message: "Uploading link...", status: "INFO" });
let createResponse = await Actions.createLink({ url, slate });
if (Events.hasError(createResponse)) {
return;
}
export const uploadLink = async ({ url, slate, uploadAbort }) => {
const abortController = new AbortController();
if (uploadAbort) uploadAbort.abort = abortController.abort.bind(abortController);
const { added, skipped } = createResponse.data;
if (added) {
Events.dispatchMessage({ message: "Link added", status: "INFO" });
} else if (skipped) {
Events.dispatchMessage({
message: "You've already saved this link",
});
return;
}
let createResponse = await Actions.createLink({ url, slate }, { signal: abortController.signal });
return createResponse;
};
export const uploadFiles = async ({ context, files, slate, keys, numFailed = 0 }) => {
if (!files || !files.length) {
context._handleRegisterLoadingFinished({ keys });
return;
}
const resolvedFiles = [];
for (let i = 0; i < files.length; i++) {
const currentFileKey = fileKey(files[i]);
if (Store.checkCancelled(currentFileKey)) {
continue;
}
// NOTE(jim): With so many failures, probably good to wait a few seconds.
await Window.delay(3000);
// NOTE(jim): Sends XHR request.
let response;
try {
response = await upload({
file: files[i],
context,
});
} catch (e) {
Logging.error(e);
}
if (!response || response.error) {
continue;
}
resolvedFiles.push(response);
}
if (!resolvedFiles.length) {
context._handleRegisterLoadingFinished({ keys });
return;
}
//NOTE(martina): this commented out portion is only for if parallel uploading
// let responses = await Promise.allSettled(resolvedFiles);
// let succeeded = responses
// .filter((res) => {
// return res.status === "fulfilled" && res.value && !res.value.error;
// })
// .map((res) => res.value);
let createResponse = await Actions.createFile({ files: resolvedFiles, slate });
if (Events.hasError(createResponse)) {
context._handleRegisterLoadingFinished({ keys });
return;
}
const { added, skipped } = createResponse.data;
let message = Strings.formatAsUploadMessage(added, skipped + numFailed, slate);
Events.dispatchMessage({ message, status: !added ? null : "INFO" });
context._handleRegisterLoadingFinished({ keys });
};
//NOTE(migration): check that upload works still and file.name
export const upload = async ({ file, context, bucketName }) => {
const currentFileKey = fileKey(file);
export const upload = async ({ file, onProgress, bucketName, uploadAbort }) => {
let formData = new FormData();
const HEIC2ANY = require("heic2any");
@ -155,17 +81,11 @@ export const upload = async ({ file, context, bucketName }) => {
formData.append("data", file);
}
if (Store.checkCancelled(currentFileKey)) {
return;
}
const _privateUploadMethod = (path, file) =>
new Promise((resolve) => {
const XHR = new XMLHttpRequest();
window.addEventListener(`cancel-${currentFileKey}`, () => {
XHR.abort();
});
if (uploadAbort) uploadAbort.abort = XHR.abort.bind(XHR);
XHR.open("post", path, true);
XHR.setRequestHeader("authorization", getCookie(Credentials.session.key));
@ -178,28 +98,16 @@ export const upload = async ({ file, context, bucketName }) => {
XHR.upload.addEventListener(
"progress",
(event) => {
if (!context) {
return;
}
if (event.lengthComputable) {
Logging.log("FILE UPLOAD PROGRESS", event);
context.setState({
fileLoading: {
...context.state.fileLoading,
[currentFileKey]: {
name: file.name,
loaded: event.loaded,
total: event.total,
},
},
});
if (onProgress) onProgress(event);
}
},
false
);
window.removeEventListener(`cancel-${currentFileKey}`, () => XHR.abort());
XHR.addEventListener("abort", () => {
resolve({ aborted: true });
});
XHR.onloadend = (event) => {
Logging.log("FILE UPLOAD END", event);
@ -236,25 +144,12 @@ export const upload = async ({ file, context, bucketName }) => {
res = await _privateUploadMethod(`${generalRoute}${file.name}`, file);
}
if (!res?.data || res.error) {
if (context) {
await context.setState({
fileLoading: {
...context.state.fileLoading,
[`${file.lastModified}-${file.name}`]: {
name: file.name,
failed: true,
},
},
});
}
Events.dispatchMessage({ message: "Some of your files could not be uploaded" });
return !res ? { decorator: "NO_RESPONSE_FROM_SERVER", error: true } : res;
if (!res || res.error || res.aborted) {
return res;
}
let item = res.data.data;
if (item.type.startsWith("image/")) {
if (Validations.isPreviewableImage(item.type)) {
let url = Strings.getURLfromCID(item.cid);
try {
let blurhash = await encodeImageToBlurhash(url);
@ -271,19 +166,13 @@ export const upload = async ({ file, context, bucketName }) => {
export const formatPastedImages = ({ clipboardItems }) => {
let files = [];
let fileLoading = {};
for (let i = 0; i < clipboardItems.length; i++) {
// Note(Amine): skip content if it's not an image
if (clipboardItems[i].type.indexOf("image") === -1) continue;
const file = clipboardItems[i].getAsFile();
files.push(file);
fileLoading[`${file.lastModified}-${file.name}`] = {
name: file.name,
loaded: 0,
total: file.size,
};
}
return { fileLoading, toUpload: files };
return { files };
};
export const formatDroppedFiles = async ({ dataTransfer }) => {
@ -294,7 +183,6 @@ export const formatDroppedFiles = async ({ dataTransfer }) => {
}
const files = [];
let fileLoading = {};
if (dataTransfer.items && dataTransfer.items.length) {
for (var i = 0; i < dataTransfer.items.length; i++) {
const data = dataTransfer.items[i];
@ -320,43 +208,21 @@ export const formatDroppedFiles = async ({ dataTransfer }) => {
}
files.push(file);
fileLoading[`${file.lastModified}-${file.name}`] = {
name: file.name,
loaded: 0,
total: file.size,
};
}
}
if (!files.length) {
Events.dispatchMessage({ message: "File type not supported. Please try a different file" });
}
return { fileLoading, files, numFailed: dataTransfer.items.length - files.length };
return { files };
};
export const formatUploadedFiles = ({ files }) => {
let toUpload = [];
let fileLoading = {};
for (let i = 0; i < files.length; i++) {
let file = files[i];
if (!file) {
continue;
}
toUpload.push(file);
fileLoading[fileKey(file)] = {
name: file.name,
loaded: 0,
total: file.size,
};
}
if (!toUpload.length) {
Events.dispatchMessage({ message: "We could not find any files to upload." });
return false;
}
return { toUpload, fileLoading, numFailed: files.length - toUpload.length };
return { files: toUpload };
};

View File

@ -406,6 +406,7 @@ export const useTimeout = (callback, ms, dependencies) => {
return () => clearTimeout(timeoutId);
}, dependencies);
};
export const useEscapeKey = (callback) => {
const handleKeyUp = React.useCallback(
(e) => {
@ -416,7 +417,7 @@ export const useEscapeKey = (callback) => {
useEventListener("keyup", handleKeyUp, [handleKeyUp]);
};
export const useLockScroll = ({ lock = true }) => {
export const useLockScroll = ({ lock = true } = {}) => {
React.useEffect(() => {
if (!lock) return;
document.body.style.overflow = "hidden";

View File

@ -1,9 +0,0 @@
const cancelledUploads = {};
export const checkCancelled = (val) => {
return cancelledUploads[val];
};
export const setCancelled = (val) => {
cancelledUploads[val] = true;
};

View File

@ -2050,3 +2050,15 @@ export const Clipboard = (props) => (
/>
</svg>
);
export const List = (props) => (
<svg width={16} height={16} fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M5.333 4H14M5.333 8H14M5.333 12H14M2 4h.007M2 8h.007M2 12h.007"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);

222
common/upload-utilities.js Normal file
View File

@ -0,0 +1,222 @@
import * as FileUtilities from "~/common/file-utilities";
import * as Logging from "~/common/logging";
import * as Actions from "~/common/actions";
// NOTE(amine): utilities
export const getFileKey = ({ lastModified, name }) => `${lastModified}-${name}`;
const getLinkSize = (url) => new TextEncoder().encode(url).length;
let UploadStore = {
queue: [],
failedFilesCache: {},
isUploading: false,
uploadedFiles: {},
};
let UploadAbort = {
currentUploadingFile: null,
abort: null,
};
// NOTE(amine): queue utilities
const getUploadQueue = () => UploadStore.queue;
const pushToUploadQueue = ({ file, slate, bucketName }) =>
UploadStore.queue.push({ file, slate, bucketName });
const resetUploadQueue = () => (UploadStore.queue = []);
const removeFromUploadQueue = ({ fileKey }) =>
(UploadStore.queue = UploadStore.queue.filter(({ file }) => getFileKey(file) !== fileKey));
// NOTE(amine): failedFilesCache utilities
const storeFileInCache = ({ file, slate, bucketName }) =>
(UploadStore.failedFilesCache[getFileKey(file)] = { file, slate, bucketName });
const removeFileFromCache = ({ fileKey }) => delete UploadStore.failedFilesCache[fileKey];
const getFileFromCache = ({ fileKey }) => UploadStore.failedFilesCache[fileKey] || {};
// NOTE(amine): UploadAbort utilities
const registerFileUploading = ({ fileKey }) => (UploadAbort.currentUploadingFile = fileKey);
const resetAbortUploadState = () => (UploadAbort = { currentUploadingFile: null, abort: null });
const abortCurrentFileUpload = () => UploadAbort.abort();
const canCurrentFileBeAborted = () => UploadAbort.currentUploadingFile && UploadAbort.abort;
const isFileCurrentlyUploading = ({ fileKey }) =>
fileKey === UploadAbort.currentUploadingFile && UploadAbort.abort;
// NOTE(amine): upload factory function
export function createUploadProvider({
onStart,
onFinish,
onAddedToQueue,
onProgress,
onSuccess,
onError,
onCancel,
onDuplicate,
}) {
const scheduleQueueUpload = async () => {
const uploadQueue = getUploadQueue();
if (UploadStore.isUploading || uploadQueue.length === 0) return;
const { file, slate, bucketName } = getUploadQueue().shift() || {};
const fileKey = getFileKey(file);
UploadStore.isUploading = true;
registerFileUploading({ fileKey });
try {
if (file.type === "link") {
onProgress({ fileKey, loaded: getLinkSize(file.name) });
const response = await FileUtilities.uploadLink({
url: file.name,
slate,
uploadAbort: UploadAbort,
});
if (!response?.aborted) {
if (!response || response.error) throw new Error(response);
const isDuplicate = response.data?.duplicate;
const fileCid = response.data?.links[0];
UploadStore.uploadedFiles[fileKey] = true;
if (isDuplicate) {
if (onDuplicate) onDuplicate({ fileKey, cid: fileCid });
} else {
if (onSuccess) onSuccess({ fileKey, cid: fileCid });
}
}
} else {
const response = await FileUtilities.upload({
file,
bucketName,
uploadAbort: UploadAbort,
onProgress: (e) => onProgress({ fileKey, loaded: e.loaded }),
});
if (!response?.aborted) {
if (!response || response.error) throw new Error(response);
// TODO(amine): merge createFile and upload endpoints
let createResponse = await Actions.createFile({ files: [response], slate });
if (!createResponse || createResponse.error) throw new Error(response);
const isDuplicate = createResponse?.data?.skipped > 0;
const fileCid = createResponse.data?.cid;
UploadStore.uploadedFiles[fileKey] = true;
if (isDuplicate) {
if (onDuplicate) onDuplicate({ fileKey, cid: fileCid });
} else {
if (onSuccess) onSuccess({ fileKey, cid: fileCid });
}
}
}
} catch (e) {
storeFileInCache({ file, slate, bucketName });
if (onError) onError({ fileKey });
Logging.error(e);
}
UploadStore.isUploading = false;
resetAbortUploadState();
const isQueueEmpty = getUploadQueue().length === 0;
if (!isQueueEmpty) {
scheduleQueueUpload();
return;
}
if (onFinish) onFinish();
};
const addToUploadQueue = ({ files, slate, bucketName }) => {
if (!files || !files.length) return;
for (let i = 0; i < files.length; i++) {
const fileKey = getFileKey(files[i]);
const doesQueueIncludeFile = getUploadQueue().some(
({ file }) => getFileKey(files[i]) === getFileKey(file)
);
const isUploaded = fileKey in UploadStore.uploadedFiles;
// NOTE(amine): skip the file if already uploaded or is in queue
if (doesQueueIncludeFile || isUploaded) continue;
// NOTE(amine): if the added file has failed before, remove it from failedFilesCache
if (fileKey in UploadStore.failedFilesCache) removeFileFromCache({ fileKey });
if (onAddedToQueue) onAddedToQueue(files[i]);
pushToUploadQueue({ file: files[i], slate, bucketName });
}
const isQueueEmpty = getUploadQueue().length === 0;
if (!UploadStore.isUploading && !isQueueEmpty && onStart) {
onStart();
scheduleQueueUpload();
}
};
const retry = ({ fileKey }) => {
const { file, slate, bucketName } = getFileFromCache({ fileKey });
if (file.type === "link") {
addLinkToUploadQueue({ url: file.name, slate });
return;
}
addToUploadQueue({ files: [file], slate, bucketName });
};
const cancel = ({ fileKey }) => {
if (onCancel) onCancel({ fileKeys: [fileKey] });
if (isFileCurrentlyUploading({ fileKey })) {
abortCurrentFileUpload();
return;
}
removeFromUploadQueue({ fileKey });
};
const cancelAll = () => {
const fileKeys = getUploadQueue().map(({ file }) => getFileKey(file));
if (onCancel) onCancel({ fileKeys: [UploadAbort.currentUploadingFile, ...fileKeys] });
if (canCurrentFileBeAborted()) abortCurrentFileUpload();
resetUploadQueue();
};
const addLinkToUploadQueue = async ({ url, slate }) => {
const linkAsFile = {
name: url,
type: "link",
size: getLinkSize(url),
lastModified: "",
};
const fileKey = getFileKey(linkAsFile);
const doesQueueIncludeFile = getUploadQueue().some(
({ file }) => getFileKey(linkAsFile) === getFileKey(file)
);
const isUploaded = fileKey in UploadStore.uploadedFiles;
// NOTE(amine): skip the file if already uploaded or is in queue
if (doesQueueIncludeFile || isUploaded) return;
// NOTE(amine): if the added file has failed before, remove it from failedFilesCache
if (fileKey in UploadStore.failedFilesCache) removeFileFromCache({ fileKey });
if (onAddedToQueue) onAddedToQueue(linkAsFile);
pushToUploadQueue({ file: linkAsFile, slate, type: "link" });
const isQueueEmpty = getUploadQueue().length === 0;
if (!UploadStore.isUploading && !isQueueEmpty && onStart) {
onStart();
scheduleQueueUpload();
}
};
return {
upload: addToUploadQueue,
uploadLink: addLinkToUploadQueue,
retry,
cancel,
cancelAll,
};
}

View File

@ -4,6 +4,8 @@ import * as Strings from "~/common/strings";
import * as Validations from "~/common/validations";
import * as Constants from "~/common/constants";
import moment from "moment";
//NOTE(martina): this file is for utility functions that do not involve API calls
//For API related utility functions, see common/user-behaviors.js
//And for uploading related utility functions, see common/file-utilities.js
@ -145,3 +147,25 @@ export function mapResponsiveProp(prop, mapper) {
}
export const copyToClipboard = (text) => navigator.clipboard.writeText(text);
export function formatDateToString(date) {
const providedDate = moment(date);
const today = moment();
const yesterday = moment().subtract(1, "day");
if (today.isSame(providedDate, "day")) {
return "Today at " + providedDate.format("h:mm:ssA");
}
if (yesterday.isSame(providedDate, "day")) {
return "Yesterday at " + providedDate.format("h:mm:ssA");
}
return providedDate.format("MMM D, YYYY") + " at " + providedDate.format("h:mm:ssA");
}
export const clamp = (value, min, max) => {
if (value < min) return min;
if (value > max) return max;
return value;
};

View File

@ -154,40 +154,6 @@ export class Alert extends React.Component {
);
}
//NOTE(martina): uploading message
if (this.props.fileLoading && Object.keys(this.props.fileLoading).length) {
let total = Object.values(this.props.fileLoading).filter((upload) => {
return !upload.cancelled;
}).length;
let uploaded =
Object.values(this.props.fileLoading).filter((upload) => {
return upload.loaded === upload.total;
}).length || 0;
return (
<div
css={STYLES_INFO}
style={{ cursor: "pointer", ...this.props.style }}
onClick={() =>
this.props.onAction({
type: "SIDEBAR",
value: "SIDEBAR_ADD_FILE_TO_BUCKET",
})
}
>
<div css={STYLES_MESSAGE_BOX}>
<div style={{ height: 16, width: 16, marginRight: 16 }}>
<LoaderSpinner style={{ height: 16, width: 16 }} />
</div>
<span css={STYLES_TEXT}>
{uploaded} / {total} file
{total === 1 ? "" : "s"} uploading{" "}
</span>
</div>
</div>
);
}
//NOTE(martina): don't upload sensitive info alert
if (this.props.viewer && !this.props.noWarning) {
return (

View File

@ -6,9 +6,7 @@ import * as Styles from "~/common/styles";
import * as Credentials from "~/common/credentials";
import * as Constants from "~/common/constants";
import * as Validations from "~/common/validations";
import * as FileUtilities from "~/common/file-utilities";
import * as Window from "~/common/window";
import * as Store from "~/common/store";
import * as Websockets from "~/common/browser-websockets";
import * as UserBehaviors from "~/common/user-behaviors";
import * as Events from "~/common/custom-events";
@ -38,7 +36,6 @@ import SidebarCreateSlate from "~/components/sidebars/SidebarCreateSlate";
import SidebarCreateWalletAddress from "~/components/sidebars/SidebarCreateWalletAddress";
import SidebarWalletSendFunds from "~/components/sidebars/SidebarWalletSendFunds";
import SidebarFileStorageDeal from "~/components/sidebars/SidebarFileStorageDeal";
import ModalAddFileToBucket from "~/components/sidebars/ModalAddFileToBucket";
import SidebarAddFileToSlate from "~/components/sidebars/SidebarAddFileToSlate";
import SidebarDragDropNotice from "~/components/sidebars/SidebarDragDropNotice";
import SidebarSingleSlateSettings from "~/components/sidebars/SidebarSingleSlateSettings";
@ -68,7 +65,6 @@ const SIDEBARS = {
SIDEBAR_FILE_STORAGE_DEAL: <SidebarFileStorageDeal />,
SIDEBAR_WALLET_SEND_FUNDS: <SidebarWalletSendFunds />,
SIDEBAR_CREATE_WALLET_ADDRESS: <SidebarCreateWalletAddress />,
SIDEBAR_ADD_FILE_TO_BUCKET: <ModalAddFileToBucket />,
SIDEBAR_ADD_FILE_TO_SLATE: <SidebarAddFileToSlate />,
SIDEBAR_CREATE_SLATE: <SidebarCreateSlate />,
SIDEBAR_DRAG_DROP_NOTICE: <SidebarDragDropNotice />,
@ -121,14 +117,9 @@ export default class ApplicationPage extends React.Component {
mounted = true;
window.addEventListener("dragenter", this._handleDragEnter);
window.addEventListener("dragleave", this._handleDragLeave);
window.addEventListener("dragover", this._handleDragOver);
window.addEventListener("drop", this._handleDrop);
window.addEventListener("online", this._handleOnlineStatus);
window.addEventListener("offline", this._handleOnlineStatus);
window.addEventListener("resize", this._handleWindowResize);
window.addEventListener("paste", this._handleUploadFromClipboard);
window.onpopstate = this._handleBackForward;
if (this.state.viewer) {
@ -137,14 +128,9 @@ export default class ApplicationPage extends React.Component {
}
componentWillUnmount() {
window.removeEventListener("dragenter", this._handleDragEnter);
window.removeEventListener("dragleave", this._handleDragLeave);
window.removeEventListener("dragover", this._handleDragOver);
window.removeEventListener("drop", this._handleDrop);
window.removeEventListener("online", this._handleOnlineStatus);
window.removeEventListener("offline", this._handleOnlineStatus);
window.removeEventListener("resize", this._handleWindowResize);
window.removeEventListener("paste", this._handleUploadFromClipboard);
mounted = false;
@ -154,30 +140,6 @@ export default class ApplicationPage extends React.Component {
}
}
_handleUploadFromClipboard = (e) => {
const clipboardItems = e.clipboardData.items || [];
if (!clipboardItems) return;
const { fileLoading, toUpload } = FileUtilities.formatPastedImages({
clipboardItems,
});
this._handleRegisterFileLoading({ fileLoading });
const page = this.state.page;
let slate = null;
if (page?.id === "NAV_SLATE" && this.state.data?.ownerId === this.state.viewer?.id) {
slate = this.state.data;
}
FileUtilities.uploadFiles({
files: toUpload,
slate,
keys: Object.keys(fileLoading),
context: this,
});
};
_handleUpdateViewer = ({ viewer, callback }) => {
// _handleUpdateViewer = (newViewerState, callback) => {
// let setAsyncState = (newState) =>
@ -290,97 +252,6 @@ export default class ApplicationPage extends React.Component {
this.setState({ online: navigator.onLine });
};
_handleDrop = async (e) => {
e.preventDefault();
this.setState({ sidebar: null });
const { fileLoading, files, numFailed, error } = await FileUtilities.formatDroppedFiles({
dataTransfer: e.dataTransfer,
});
if (error) {
return null;
}
let page = this.state.page;
let slate = null;
if (page?.id === "NAV_SLATE" && this.state.data?.ownerId === this.state.viewer?.id) {
slate = this.state.data;
}
this._handleRegisterFileLoading({ fileLoading });
FileUtilities.uploadFiles({
files,
slate,
keys: Object.keys(fileLoading),
numFailed,
context: this,
});
};
_handleUploadFiles = async ({ files, slate }) => {
const { fileLoading, toUpload, numFailed } = FileUtilities.formatUploadedFiles({ files });
this._handleRegisterFileLoading({ fileLoading });
FileUtilities.uploadFiles({
files: toUpload,
slate,
keys: Object.keys(fileLoading),
numFailed,
context: this,
});
};
_handleRegisterFileLoading = ({ fileLoading }) => {
if (this.state.fileLoading) {
return this.setState({
fileLoading: { ...this.state.fileLoading, ...fileLoading },
});
}
return this.setState({
fileLoading,
});
};
_handleRegisterFileCancelled = ({ key }) => {
let fileLoading = this.state.fileLoading;
fileLoading[key].cancelled = true;
this.setState({ fileLoading });
};
_handleRegisterLoadingFinished = ({ keys }) => {
let fileLoading = this.state.fileLoading;
for (let key of keys) {
delete fileLoading[key];
}
this.setState({ fileLoading });
};
_handleDragEnter = (e) => {
e.preventDefault();
if (this.state.sidebar) {
return;
}
// NOTE(jim): Only allow the sidebar to show with file drag and drop.
if (e.dataTransfer?.items?.length && e.dataTransfer.items[0].kind !== "file") {
return;
}
this._handleAction({
type: "SIDEBAR",
value: "SIDEBAR_ADD_FILE_TO_BUCKET",
});
};
_handleDragLeave = (e) => {
e.preventDefault();
};
_handleDragOver = (e) => {
e.preventDefault();
};
_withAuthenticationBehavior = (authenticate) => async (state, newAccount) => {
let response = await authenticate(state);
if (Events.hasError(response)) {
@ -464,10 +335,6 @@ export default class ApplicationPage extends React.Component {
});
}
if (options.type === "REGISTER_FILE_CANCELLED") {
return this._handleRegisterFileCancelled({ key: options.value });
}
if (options.type === "NEW_WINDOW") {
return window.open(options.value);
}
@ -566,8 +433,9 @@ export default class ApplicationPage extends React.Component {
headerElement = (
<ApplicationHeader
viewer={this.state.viewer}
navigation={NavigationData.navigation}
data={this.state.data}
page={page}
navigation={NavigationData.navigation}
onAction={this._handleAction}
isMobile={this.state.isMobile}
isMac={this.props.isMac}
@ -586,7 +454,6 @@ export default class ApplicationPage extends React.Component {
onAuthenticate: this._withAuthenticationBehavior(UserBehaviors.authenticate),
onTwitterAuthenticate: this._withAuthenticationBehavior(UserBehaviors.authenticateViaTwitter),
onAction: this._handleAction,
onUpload: this._handleUploadFiles,
isMobile: this.state.isMobile,
isMac: this.props.isMac,
activeUsers: this.state.activeUsers,
@ -602,10 +469,8 @@ export default class ApplicationPage extends React.Component {
viewer: this.state.viewer,
data: this.state.data,
sidebarData: this.state.sidebarData,
fileLoading: this.state.fileLoading,
onSelectedChange: this._handleSelectedChange,
onCancel: this._handleDismissSidebar,
onUpload: this._handleUploadFiles,
onAction: this._handleAction,
});
}
@ -639,7 +504,6 @@ export default class ApplicationPage extends React.Component {
header={headerElement}
sidebar={sidebarElement}
onDismissSidebar={this._handleDismissSidebar}
fileLoading={this.state.fileLoading}
isMobile={this.state.isMobile}
isMac={this.props.isMac}
viewer={this.state.viewer}

View File

@ -3,6 +3,7 @@ import * as Constants from "~/common/constants";
import * as SVG from "~/common/svg";
import * as Events from "~/common/custom-events";
import * as Styles from "~/common/styles";
import * as Upload from "~/components/core/Upload";
import {
ApplicationUserControls,
@ -49,9 +50,9 @@ const STYLES_APPLICATION_HEADER_BACKGROUND = (theme) => css`
background-color: ${theme.system.white};
box-shadow: 0 0 0 1px ${theme.semantic.bgGrayLight};
@supports ((-webkit-backdrop-filter: blur(25px)) or (backdrop-filter: blur(25px))) {
-webkit-backdrop-filter: blur(25px);
backdrop-filter: blur(25px);
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
-webkit-backdrop-filter: blur(75px);
backdrop-filter: blur(75px);
background-color: rgba(255, 255, 255, 0.7);
}
`;
@ -107,7 +108,6 @@ const STYLES_BACKGROUND = css`
const STYLES_UPLOAD_BUTTON = css`
${Styles.CONTAINER_CENTERED};
${Styles.BUTTON_RESET};
background-color: ${Constants.semantic.bgGrayLight};
border-radius: 8px;
width: 24px;
@ -116,7 +116,7 @@ const STYLES_UPLOAD_BUTTON = css`
pointer-events: auto;
`;
export default function ApplicationHeader({ viewer, onAction }) {
export default function ApplicationHeader({ viewer, page, data, onAction }) {
const [state, setState] = React.useState({
showDropdown: false,
popup: null,
@ -150,10 +150,6 @@ export default function ApplicationHeader({ viewer, onAction }) {
onSubmit: handleCreateSearch,
});
const handleUpload = React.useCallback(() => {
onAction({ type: "SIDEBAR", value: "SIDEBAR_ADD_FILE_TO_BUCKET" });
}, [onAction]);
const handleDismissSearch = () => setFieldValue("");
const { mobile } = useMediaQuery();
@ -192,15 +188,28 @@ export default function ApplicationHeader({ viewer, onAction }) {
{...getFieldProps()}
/>
</div>
<div css={STYLES_RIGHT}>
<Actions
isSearching={isSearching}
isSignedOut={isSignedOut}
onAction={onAction}
onUpload={handleUpload}
onDismissSearch={handleDismissSearch}
/>
</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}
onAction={onAction}
onDismissSearch={handleDismissSearch}
/>
</div>
</Upload.Root>
</Upload.Provider>
</div>
<Show when={mobile && state.popup === "profile"}>
<ApplicationUserControlsPopup
@ -219,7 +228,7 @@ export default function ApplicationHeader({ viewer, onAction }) {
);
}
const Actions = ({ isSignedOut, isSearching, onAction, onUpload, onDismissSearch }) => {
const Actions = ({ uploadAction, isSignedOut, isSearching, onAction, onDismissSearch }) => {
const authActions = React.useMemo(
() => (
<>
@ -249,29 +258,18 @@ const Actions = ({ isSignedOut, isSearching, onAction, onUpload, onDismissSearch
[onAction]
);
const uploadAction = React.useMemo(
() => (
<button css={STYLES_UPLOAD_BUTTON} onClick={onUpload}>
<SVG.Plus height="16px" />
</button>
),
[onUpload]
);
return (
<AnimatePresence>
<Switch
fallback={
<Switch fallback={uploadAction}>
<Match when={isSignedOut}>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ y: 10, opacity: 0 }}
>
{uploadAction}
{authActions}
</motion.div>
}
>
<Match when={isSignedOut}>{authActions}</Match>
</Match>
<Match when={isSearching}>
<motion.div
initial={{ opacity: 0, y: -10 }}

View File

@ -8,8 +8,6 @@ import { GlobalTooltip } from "~/components/system/components/fragments/GlobalTo
import { Boundary } from "~/components/system/components/fragments/Boundary";
import { Alert } from "~/components/core/Alert";
const MODAL_MARGIN = 56;
const STYLES_NO_VISIBLE_SCROLL = css`
overflow-y: scroll;
scrollbar-width: none;
@ -86,27 +84,22 @@ const STYLES_MODAL = css`
top: ${Constants.sizes.header}px;
right: 0;
bottom: 0;
width: 100vw;
height: calc(100vh - ${Constants.sizes.header}px);
position: fixed;
left: 0;
padding: ${MODAL_MARGIN}px;
padding: 24px 24px 32px;
height: calc(100vh - ${Constants.sizes.header}px);
background-color: ${Constants.semantic.bgBlurLight6};
background-color: ${Constants.semantic.bgBlurWhiteOP};
@supports ((-webkit-backdrop-filter: blur(25px)) or (backdrop-filter: blur(25px))) {
-webkit-backdrop-filter: blur(25px);
backdrop-filter: blur(25px);
@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`
border-radius: 8px;
background-color: ${Constants.system.white};
width: 100%;
height: calc(100vh - ${Constants.sizes.header}px - ${MODAL_MARGIN * 2}px);
padding: 30px;
position: relative;
height: 100%;
`;
const STYLES_SIDEBAR_HEADER = css`
@ -194,69 +187,31 @@ export default class ApplicationLayout extends React.Component {
render() {
let sidebarElements = null;
if (this.props.sidebar) {
if (this.props.sidebarName === "SIDEBAR_ADD_FILE_TO_BUCKET") {
sidebarElements = (
<div css={STYLES_MODAL}>
<Boundary
onMouseDown
captureResize={false}
captureScroll={false}
enabled
onOutsideRectEvent={this._handleDismiss}
sidebarElements = (
<Boundary
onMouseDown
captureResize={false}
captureScroll={false}
enabled
onOutsideRectEvent={this._handleDismiss}
>
<div css={STYLES_SIDEBAR}>
<div
css={STYLES_SIDEBAR_ELEMENTS}
ref={(c) => {
this._sidebar = c;
}}
>
<div
css={STYLES_MODAL_ELEMENTS}
ref={(c) => {
this._sidebar = c;
}}
>
<div css={STYLES_SIDEBAR_HEADER} style={{ position: "absolute", right: 30 }}>
<div css={STYLES_DISMISS} onClick={this._handleDismiss}>
<SVG.Dismiss height="24px" />
</div>
</div>
<div
css={STYLES_SIDEBAR_CONTENT}
style={{
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{this.props.sidebar}
<div css={STYLES_SIDEBAR_HEADER}>
<div css={STYLES_BLOCK} onClick={this._handleDismiss}>
<SVG.Dismiss height="24px" />
</div>
</div>
</Boundary>
</div>
);
} else {
sidebarElements = (
<Boundary
onMouseDown
captureResize={false}
captureScroll={false}
enabled
onOutsideRectEvent={this._handleDismiss}
>
<div css={STYLES_SIDEBAR}>
<div
css={STYLES_SIDEBAR_ELEMENTS}
ref={(c) => {
this._sidebar = c;
}}
>
<div css={STYLES_SIDEBAR_HEADER}>
<div css={STYLES_BLOCK} onClick={this._handleDismiss}>
<SVG.Dismiss height="24px" />
</div>
</div>
<div css={STYLES_SIDEBAR_CONTENT}>{this.props.sidebar}</div>
</div>
<div css={STYLES_SIDEBAR_CONTENT}>{this.props.sidebar}</div>
</div>
</Boundary>
);
}
</div>
</Boundary>
);
}
return (
<React.Fragment>
@ -264,13 +219,8 @@ export default class ApplicationLayout extends React.Component {
<GlobalTooltip />
{this.props.header && (
<>
<div style={{ visibility: "hidden" }}>{this.props.header}</div>
<div
css={STYLES_HEADER}
style={{ top: this.props.isMobile ? this.state.headerTop : null }}
>
{this.props.header}
</div>
<div style={{ height: Constants.sizes.header }} />
<div css={STYLES_HEADER}>{this.props.header}</div>
</>
)}
<Alert
@ -278,10 +228,9 @@ export default class ApplicationLayout extends React.Component {
this.props.page?.id === "NAV_SIGN_IN"
? true
: this.props.viewer
? this.props.viewer.onboarding.hidePrivacyAlert
? this.props.viewer?.onboarding?.hidePrivacyAlert
: false
}
fileLoading={this.props.fileLoading}
onAction={this.props.onAction}
id={this.props.isMobile ? "slate-mobile-alert" : null}
viewer={this.props.viewer}

View File

@ -12,6 +12,7 @@ import { Boundary } from "~/components/system/components/fragments/Boundary";
import { H4, P3 } from "~/components/system/components/Typography";
import ProfilePhoto from "~/components/core/ProfilePhoto";
import DataMeter from "~/components/core/DataMeter";
const STYLES_HEADER = css`
position: relative;
@ -80,36 +81,6 @@ const STYLES_SECTION_ITEM_HOVER = (theme) => css`
}
`;
const STYLES_DATAMETER_WRAPPER = (theme) => css`
width: 100%;
min-width: 240px;
height: 8px;
background-color: ${theme.semantic.bgBlurWhiteTRN};
border: 1px solid ${theme.semantic.borderGrayLight4};
border-radius: 2px;
overflow: hidden;
`;
const STYLES_DATAMETER = (theme) => css`
height: 100%;
background-color: ${theme.system.blue};
border-radius: 2px;
`;
const DataMeter = ({ bytes = 1000, maximumBytes = 4000, ...props }) => {
const percentage = bytes / maximumBytes;
return (
<div css={STYLES_DATAMETER_WRAPPER} {...props}>
<div
style={{
width: `calc(${percentage} * 100%)`,
}}
css={STYLES_DATAMETER}
/>
</div>
);
};
export class ApplicationUserControlsPopup extends React.Component {
state = {
isExtensionDownloaded: false,
@ -180,7 +151,7 @@ export class ApplicationUserControlsPopup extends React.Component {
<DataMeter
bytes={stats.bytes}
maximumBytes={stats.maximumBytes}
style={{ marginTop: 8 }}
style={{ minWidth: "240px", marginTop: 8 }}
/>
</div>
</Link>

View File

@ -1,109 +1,36 @@
import * as React from "react";
import * as Constants from "~/common/constants";
import * as Strings from "~/common/strings";
import * as Utilities from "~/common/utilities";
import { css } from "@emotion/react";
import { motion } from "framer-motion";
const STYLES_CONTAINER = css`
const STYLES_DATAMETER_WRAPPER = (theme) => css`
width: 100%;
height: 8px;
background-color: ${theme.semantic.bgBlurWhiteTRN};
border: 1px solid ${theme.semantic.borderGrayLight4};
border-radius: 4px;
box-shadow: 0 0 0 1px ${Constants.semantic.borderGrayLight} inset, ${Constants.shadow.lightSmall};
padding: 32px;
max-width: 100%;
width: 100%;
@media (max-width: ${Constants.sizes.mobile}px) {
padding: 24px;
}
`;
const STYLES_DATA = css`
width: 100%;
display: flex;
align-items: center;
height: 16px;
border-radius: 3px;
background-color: ${Constants.semantic.bgLight};
overflow: hidden;
`;
const STYLES_DATA_METER = css`
flex-shrink: 0;
height: 16px;
background-color: ${Constants.system.blue};
const STYLES_DATAMETER = (theme) => css`
position: relative;
left: -100%;
height: 100%;
background-color: ${theme.system.blue};
border-radius: 4px;
`;
const STYLES_ROW = css`
display: flex;
align-items: flex-end;
justify-content: space-between;
font-family: ${Constants.font.code};
color: ${Constants.system.grayLight2};
font-size: 10px;
margin-top: 2px;
text-transform: uppercase;
`;
const STYLES_LEFT = css`
min-width: 10%;
width: 100% "";
`;
const STYLES_RIGHT = css`
flex-shrink: 0;
`;
const STYLES_TITLE = css`
font-family: ${Constants.font.medium};
font-size: ${Constants.typescale.lvl1};
display: block;
margin-bottom: 4px;
overflow-wrap: break-word;
`;
const STYLES_NOTE = css`
margin-top: 8px;
font-family: ${Constants.font.text};
font-size: ${Constants.typescale.lvl0};
color: ${Constants.system.grayLight2};
display: block;
margin-bottom: 4px;
`;
export const DataMeterBar = (props) => {
const percentage = props.bytes / props.maximumBytes;
export default function DataMeter({ bytes = 1000, maximumBytes = 4000, css, ...props }) {
const percentage = Utilities.clamp((bytes / maximumBytes) * 100, 0, 100);
return (
<React.Fragment>
<div css={STYLES_ROW}>
<div css={STYLES_LEFT} style={{ color: props.failed ? Constants.system.red : null }}>
{props.leftLabel}
</div>
<div css={STYLES_RIGHT}>{props.rightLabel}</div>
</div>
<div
css={STYLES_DATA}
style={{
marginTop: 8,
backgroundColor: props.failed ? Constants.system.red : null,
}}
>
<div css={STYLES_DATA_METER} style={{ width: `${percentage * 100}%` }} />
</div>
</React.Fragment>
);
};
export const DataMeter = (props) => {
return (
<div css={STYLES_CONTAINER} style={props.style}>
<div css={STYLES_TITLE}>
{Strings.bytesToSize(props.stats.bytes)} of {Strings.bytesToSize(props.stats.maximumBytes)}{" "}
used
</div>
<DataMeterBar bytes={props.stats.bytes} maximumBytes={props.stats.maximumBytes} />
<div css={[STYLES_DATAMETER_WRAPPER, css]} {...props}>
<motion.div
initial={false}
css={STYLES_DATAMETER}
animate={{ x: `${percentage}%` }}
transition={{ type: "spring", stiffness: 170, damping: 26 }}
/>
</div>
);
};
export default DataMeter;
}

View File

@ -1,179 +0,0 @@
import * as React from "react";
import * as Constants from "~/common/constants";
import * as Strings from "~/common/strings";
import { css } from "@emotion/react";
const STYLES_CONTAINER = css`
border-radius: 16px;
box-shadow: 0 0 0 1px ${Constants.semantic.borderGrayLight} inset, ${Constants.shadow.lightSmall};
padding: 32px;
max-width: 100%;
width: 100%;
${"" /* background-color: ${Constants.system.white}; */}
@media (max-width: ${Constants.sizes.mobile}px) {
padding: 24px;
}
`;
const STYLES_ROW = css`
font-family: ${Constants.font.code};
color: ${Constants.system.grayLight2};
background-color: ${Constants.semantic.bgLight};
display: inline-flex;
height: 16px;
width: 100%;
`;
const STYLES_STATS_ROW = css`
background-color: ${Constants.semantic.bgLight};
width: 100%;
border-radius: 3px;
height: 16px;
`;
const STYLES_TITLE = css`
font-family: ${Constants.font.medium};
font-size: ${Constants.typescale.lvl1};
display: block;
margin-bottom: 12px;
overflow-wrap: break-word;
`;
const STYLES_NOTE = css`
margin-top: 12px;
font-family: ${Constants.font.text};
font-size: ${Constants.typescale.lvl0};
color: ${Constants.system.grayLight2};
display: block;
`;
const STYLES_DATA_METER_KEY_WRAPPER = css`
display: inline-block;
`;
const STYLES_DATA_METER_KEY_SQUARE = css`
display: inline-block;
border-radius: 3px;
background: #73ad21;
width: 12px;
height: 12px;
margin-right: 4px;
vertical-align: middle;
`;
const STYLES_DATA_METER_KEY_LABEL = css`
display: inline-block;
margin-right: 16px;
vertical-align: middle;
`;
const DATA_METER_METER_SEGMENT = css`
height: 16px;
`;
export const DataMeterBar = (props) => {
const percentageUsed = props.bytes / props.maximumBytes;
const percentageImage = props.stats.imageBytes / props.bytes;
const percentageVideo = props.stats.videoBytes / props.bytes;
const percentageEpub = props.stats.epubBytes / props.bytes;
const percentagePdf = props.stats.pdfBytes / props.bytes;
const percentageAudio = props.stats.audioBytes / props.bytes;
const percentageFreeSpace = props.bytes - props.maximumBytes;
return (
<React.Fragment>
<div css={STYLES_STATS_ROW}>
<div
css={STYLES_ROW}
style={{
width: `${percentageUsed * 100}%`,
}}
>
<div
css={DATA_METER_METER_SEGMENT}
style={{
width: `${percentageImage * 100}%`,
backgroundColor: "#C0D8EE",
borderRadius: "3px 0px 0px 3px",
}}
/>
<div
css={DATA_METER_METER_SEGMENT}
style={{ width: `${percentageVideo * 100}%`, backgroundColor: "#C0DACD" }}
/>
<div
css={DATA_METER_METER_SEGMENT}
style={{ width: `${percentageEpub * 100}%`, backgroundColor: "#FEEDC4" }}
/>
<div
css={DATA_METER_METER_SEGMENT}
style={{ width: `${percentagePdf * 100}%`, backgroundColor: "#FAB413" }}
/>
<div
css={DATA_METER_METER_SEGMENT}
style={{ width: `${percentageAudio * 100}%`, backgroundColor: "#F1C4C4" }}
/>
<div
css={DATA_METER_METER_SEGMENT}
style={{
width: `${percentageFreeSpace * 100}%`,
}}
/>
</div>
</div>
</React.Fragment>
);
};
export const DataMeterDetailed = (props) => {
return (
<div css={STYLES_CONTAINER} style={props.style}>
<div css={STYLES_TITLE}>
{Strings.bytesToSize(props.stats.bytes)} of {Strings.bytesToSize(props.stats.maximumBytes)}{" "}
used
</div>
<DataMeterBar
stats={props.stats}
bytes={props.stats.bytes}
maximumBytes={props.stats.maximumBytes}
/>
<div css={STYLES_NOTE} style={{ marginTop: 8 }}>
<div css={STYLES_DATA_METER_KEY_WRAPPER}>
<div css={STYLES_DATA_METER_KEY_SQUARE} style={{ background: `#C0D8EE` }}>
{" "}
</div>
<div css={STYLES_DATA_METER_KEY_LABEL}>Images</div>
</div>
<div css={STYLES_DATA_METER_KEY_WRAPPER}>
<div css={STYLES_DATA_METER_KEY_SQUARE} style={{ background: `#C0DACD` }}>
{" "}
</div>
<div css={STYLES_DATA_METER_KEY_LABEL}>Videos</div>
</div>
<div css={STYLES_DATA_METER_KEY_WRAPPER}>
<div css={STYLES_DATA_METER_KEY_SQUARE} style={{ background: "#FEEDC4" }}>
{" "}
</div>
<div css={STYLES_DATA_METER_KEY_LABEL}>Books</div>
</div>
<div css={STYLES_DATA_METER_KEY_WRAPPER}>
<div css={STYLES_DATA_METER_KEY_SQUARE} style={{ background: "#FAB413" }}>
{" "}
</div>
<div css={STYLES_DATA_METER_KEY_LABEL}>PDF</div>
</div>
<div css={STYLES_DATA_METER_KEY_WRAPPER}>
<div css={STYLES_DATA_METER_KEY_SQUARE} style={{ background: "#F1C4C4" }}>
{" "}
</div>
<div css={STYLES_DATA_METER_KEY_LABEL}>Audio</div>
</div>
</div>
{props.buttons ? <div style={{ marginTop: 24 }}>{props.buttons}</div> : null}
</div>
);
};
export default DataMeterDetailed;

View File

@ -0,0 +1,14 @@
import * as React from "react";
import * as ReactDOM from "react-dom";
export const ModalPortal = ({ children }) => {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
return mounted ? ReactDOM.createPortal(children, document.getElementById("modals_portal")) : null;
};

View File

@ -0,0 +1,514 @@
/* 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,264 @@
import * as React from "react";
import * as UploadUtilities from "~/common/upload-utilities";
import * as FileUtilities from "~/common/file-utilities";
import { useEventListener } from "~/common/hooks";
const UploadContext = React.createContext({});
export const useUploadContext = () => React.useContext(UploadContext);
export const Provider = ({ children, page, data, viewer }) => {
const [uploadState, uploadHandlers] = useUpload();
const [isUploadModalVisible, { showUploadModal, hideUploadModal }] = useUploadModal();
useUploadOnDrop({ upload: uploadHandlers.upload, page, data, viewer });
useUploadFromClipboard({ upload: uploadHandlers.upload, page, data, viewer });
useEventListener("upload-modal-open", showUploadModal);
const providerValue = React.useMemo(
() => [
{ isUploadModalVisible, ...uploadState },
{ showUploadModal, hideUploadModal, ...uploadHandlers },
],
[isUploadModalVisible, uploadHandlers, uploadState]
);
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 useUpload = () => {
const DEFAULT_STATE = {
fileLoading: {},
isUploading: false,
uploadStartingTime: null,
totalBytesUploaded: 0,
totalBytes: 0,
totalFilesUploaded: 0,
totalFiles: 0,
uploadRemainingTime: 0,
};
const [uploadState, setUploadState] = React.useState(DEFAULT_STATE);
const uploadProvider = React.useMemo(() => {
const handleStartUploading = () => {
setUploadState((prev) => ({ ...prev, isUploading: true, uploadStartingTime: new Date() }));
};
const handleFinishUploading = () => {
setUploadState((prev) => ({
...DEFAULT_STATE,
fileLoading: prev.fileLoading,
uploadStartingTime: null,
}));
};
const handleAddToQueue = (file) => {
const fileKey = UploadUtilities.getFileKey(file);
setUploadState((prev) => ({
...prev,
fileLoading: {
...prev.fileLoading,
[fileKey]: {
id: fileKey,
status: "saving",
name: file.name,
type: file.type,
createdAt: Date.now(),
loaded: 0,
total: file.size,
},
},
totalFiles: prev.totalFiles + 1,
totalBytes: prev.totalBytes + file.size,
}));
};
const handleSuccess = ({ fileKey, cid }) => {
setUploadState((prev) => {
const newFileLoading = { ...prev.fileLoading };
newFileLoading[fileKey].status = "success";
newFileLoading[fileKey].cid = cid;
return {
...prev,
fileLoading: newFileLoading,
totalFilesUploaded: prev.totalFilesUploaded + 1,
};
});
};
const handleDuplicate = ({ fileKey, cid }) => {
setUploadState((prev) => {
const newFileLoading = { ...prev.fileLoading };
newFileLoading[fileKey].status = "duplicate";
newFileLoading[fileKey].cid = cid;
return {
...prev,
fileLoading: newFileLoading,
totalFilesUploaded: prev.totalFilesUploaded + 1,
};
});
};
const handleProgress = ({ fileKey, loaded }) => {
setUploadState((prev) => {
const newFileLoading = { ...prev.fileLoading };
const bytesLoaded = loaded - newFileLoading[fileKey].loaded;
newFileLoading[fileKey].loaded = loaded;
return {
...prev,
fileLoading: newFileLoading,
totalBytesUploaded: prev.totalBytesUploaded + bytesLoaded,
};
});
};
const handleError = ({ fileKey }) => {
setUploadState((prev) => {
const newFileLoading = { ...prev.fileLoading };
newFileLoading[fileKey].status = "failed";
return {
...prev,
fileLoading: newFileLoading,
totalFiles: prev.totalFiles - 1,
totalBytes: prev.totalBytes - newFileLoading[fileKey].total,
totalBytesUploaded: prev.totalBytesUploaded - newFileLoading[fileKey].total,
};
});
};
const handleCancelUploading = ({ fileKeys }) => {
setUploadState((prev) => {
const newFileLoading = { ...prev.fileLoading };
const newTotalFiles = prev.totalFiles - fileKeys.length;
let newTotalBytes = prev.totalBytes;
fileKeys.forEach((fileKey) => {
newTotalBytes -= newFileLoading[fileKey].total;
delete newFileLoading[fileKey];
});
return {
...prev,
fileLoading: newFileLoading,
totalFiles: newTotalFiles,
totalBytes: newTotalBytes,
};
});
};
return UploadUtilities.createUploadProvider({
onStart: handleStartUploading,
onFinish: handleFinishUploading,
onAddedToQueue: handleAddToQueue,
onSuccess: handleSuccess,
onDuplicate: handleDuplicate,
onProgress: handleProgress,
onCancel: handleCancelUploading,
onError: handleError,
});
}, []);
return [
uploadState,
{
upload: uploadProvider.upload,
uploadLink: uploadProvider.uploadLink,
retry: uploadProvider.retry,
cancel: uploadProvider.cancel,
cancelAll: uploadProvider.cancelAll,
},
];
};
const useUploadOnDrop = ({ upload, page, data, viewer }) => {
const handleDragEnter = (e) => e.preventDefault();
const handleDragLeave = (e) => e.preventDefault();
const handleDragOver = (e) => e.preventDefault();
const handleDrop = async (e) => {
e.preventDefault();
const { files, error } = await FileUtilities.formatDroppedFiles({
dataTransfer: e.dataTransfer,
});
if (error) {
return null;
}
let slate = null;
if (page?.id === "NAV_SLATE" && data?.ownerId === viewer?.id) {
slate = data;
}
upload({ files, slate });
};
useEventListener("dragenter", handleDragEnter, []);
useEventListener("dragleave", handleDragLeave, []);
useEventListener("dragover", handleDragOver, []);
useEventListener("drop", handleDrop, []);
};
const useUploadFromClipboard = ({ upload, page, data, viewer }) => {
const handlePaste = (e) => {
const clipboardItems = e.clipboardData.items || [];
if (!clipboardItems) return;
const { files } = FileUtilities.formatPastedImages({
clipboardItems,
});
let slate = null;
if (page?.id === "NAV_SLATE" && data?.ownerId === viewer?.id) {
slate = data;
}
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;
};

View File

@ -0,0 +1,90 @@
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 UploadModal from "~/components/core/Upload/Modal";
import DataMeter from "~/components/core/DataMeter";
/* -------------------------------------------------------------------------------------------------
* Root
* -----------------------------------------------------------------------------------------------*/
const Root = ({ onAction, viewer, children }) => {
const [{ isUploadModalVisible }] = useUploadContext();
return (
<>
{children}
<Show when={isUploadModalVisible}>
<ModalPortal>
<UploadModal viewer={viewer} onAction={onAction} />
</ModalPortal>
</Show>
</>
);
};
/* -------------------------------------------------------------------------------------------------
* Trigger
* -----------------------------------------------------------------------------------------------*/
const Trigger = ({ enableMetrics = false, viewer, css, children, ...props }) => {
const showUploadModal = () => {
if (!viewer) {
Events.dispatchCustomEvent({ name: "slate-global-open-cta", detail: {} });
return;
}
Events.dispatchCustomEvent({ name: "upload-modal-open" });
};
return (
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<Show when={enableMetrics}>
<UploadMetrics />
</Show>
<button css={[Styles.BUTTON_RESET, css]} onClick={showUploadModal} {...props}>
{children}
</button>
</div>
);
};
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

@ -1,315 +0,0 @@
import * as React from "react";
import * as Constants from "~/common/constants";
import * as Strings from "~/common/strings";
import * as System from "~/components/system";
import * as Store from "~/common/store";
import * as Styles from "~/common/styles";
import * as SVG from "~/common/svg";
import * as Actions from "~/common/actions";
import * as Events from "~/common/custom-events";
import * as FileUtilities from "~/common/file-utilities";
import * as Logging from "~/common/logging";
import { css } from "@emotion/react";
import { DataMeterBar } from "~/components/core/DataMeter";
import { SidebarWarningMessage } from "~/components/core/WarningMessage";
import { FileTypeGroup } from "~/components/core/FileTypeIcon";
const STYLES_FILE_HIDDEN = css`
height: 1px;
width: 1px;
opacity: 0;
visibility: hidden;
position: fixed;
top: -1px;
left: -1px;
`;
const STYLES_FILE_LIST = css`
box-shadow: 0 0 0 1px inset ${Constants.semantic.borderGrayLight};
border-radius: 8px;
`;
const STYLES_FILE_LINE = css`
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 16px;
border-bottom: 1px solid ${Constants.semantic.borderGrayLight};
`;
const STYLES_FILE_NAME = css`
min-width: 10%;
width: 100%;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
font-size: 0.9rem;
font-family: ${Constants.font.medium};
`;
const STYLES_LEFT = css`
width: 100%;
min-width: 10%;
display: flex;
align-items: center;
`;
const STYLES_RIGHT = css`
flex-shrink: 0;
display: flex;
align-items: center;
`;
const STYLES_FILE_STATUS = css`
flex-shrink: 0;
margin-right: 16px;
display: flex;
align-items: center;
`;
const STYLES_BAR_CONTAINER = css`
${"" /* background: ${Constants.semantic.bgLight}; */}
box-shadow: 0 0 0 1px inset ${Constants.semantic.borderGrayLight};
border-radius: 8px;
padding: 24px;
${"" /* margin-top: 48px; */}
`;
const STYLES_PERFORMANCE = css`
font-family: ${Constants.font.code};
font-size: 12px;
display: block;
margin: 0 0 8px 0;
`;
export default class ModalAddFileToBucket extends React.Component {
state = {
url: "",
slate: this.props.page.id === "NAV_SLATE" && this.props.data?.id ? this.props.data : null,
urlError: false,
};
componentDidMount = () => {
window.addEventListener("keydown", this._handleDocumentKeydown);
};
componentWillUnmount = () => {
window.removeEventListener("keydown", this._handleDocumentKeydown);
};
_handleDocumentKeydown = (e) => {
if (e.keyCode === 27) {
this.props.onCancel();
e.stopPropagation();
}
};
_handleUpload = (e) => {
this.props.onUpload({
files: e.target.files,
slate: this.state.slate,
});
this.props.onCancel();
};
_handleChange = (e) => {
this.setState({ [e.target.name]: e.target.value, urlError: false });
};
_handleCancel = (e, key) => {
e.preventDefault();
e.stopPropagation();
Events.dispatchCustomEvent({ name: `cancel-${key}` }); //NOTE(martina): so that will cancel if is in the middle of uploading
Store.setCancelled(key); //NOTE(martina): so that will cancel if hasn't started uploading yet
this.props.onAction({ type: "REGISTER_FILE_CANCELLED", value: key }); //NOTE(martina): so that fileLoading registers it
};
_handleUploadLink = () => {
if (Strings.isEmpty(this.state.url)) {
this.setState({ urlError: true });
return;
}
try {
const url = new URL(this.state.url);
} catch (e) {
Logging.error(e);
this.setState({ urlError: true });
return;
}
FileUtilities.uploadLink({ url: this.state.url, slate: this.state.slate });
this.props.onCancel();
};
render() {
let loaded = 0;
let total = 0;
if (this.props.fileLoading) {
for (let file of Object.values(this.props.fileLoading)) {
if (typeof file.loaded === "number" && typeof file.total === "number") {
total += file.total;
loaded += file.loaded;
}
}
}
if (this.props.fileLoading && Object.keys(this.props.fileLoading).length) {
return (
<div style={{ width: "100%", height: "100%" }}>
<System.H2 style={{ marginBottom: 36, marginTop: 12 }}>Upload Status</System.H2>
<div css={STYLES_BAR_CONTAINER}>
<strong css={STYLES_PERFORMANCE}>
{Strings.bytesToSize(loaded)} / {Strings.bytesToSize(total)}
</strong>
<DataMeterBar bytes={loaded} maximumBytes={total} />
</div>
<div css={STYLES_FILE_LIST} style={{ marginTop: 36, overflow: "hidden" }}>
{this.props.fileLoading
? Object.entries(this.props.fileLoading).map((entry) => {
let file = entry[1];
return (
<div css={STYLES_FILE_LINE} key={file.name}>
<span css={STYLES_LEFT}>
<div css={STYLES_FILE_STATUS}>
{file.failed ? (
<SVG.Alert
height="24px"
style={{
color: Constants.system.red,
pointerEvents: "none",
}}
/>
) : file.cancelled ? (
<SVG.Dismiss
height="24px"
style={{
color: Constants.system.gray,
pointerEvents: "none",
}}
/>
) : file.loaded === file.total ? (
<SVG.CheckBox
height="24px"
style={{ color: Constants.system.grayLight2 }}
/>
) : (
<System.LoaderSpinner
style={{
width: "20px",
height: "20px",
margin: "2px",
}}
/>
)}
</div>
<div
css={STYLES_FILE_NAME}
style={
file.failed
? { color: Constants.system.red }
: file.cancelled
? { color: Constants.system.gray }
: null
}
>
{file.name}
</div>
</span>
{file.loaded === file.total || file.failed || file.cancelled ? (
<div css={STYLES_RIGHT} style={{ height: 24, width: 24 }} />
) : (
<span
css={STYLES_RIGHT}
style={{
cursor: "pointer",
}}
onClick={(e) => this._handleCancel(e, entry[0])}
>
<SVG.Dismiss
height="24px"
className="boundary-ignore"
style={{
color: Constants.system.gray,
pointerEvents: "none",
}}
/>
</span>
)}
</div>
);
})
: null}
</div>
</div>
);
} else {
return (
<div css={Styles.VERTICAL_CONTAINER_CENTERED} style={{ width: "100%", maxWidth: 680 }}>
<input
css={STYLES_FILE_HIDDEN}
multiple
type="file"
id="file"
onChange={this._handleUpload}
/>
<div css={Styles.HORIZONTAL_CONTAINER} style={{ width: "100%" }}>
<System.Input
placeholder="Paste a link to save"
value={this.state.url}
style={{
height: 40,
backgroundColor: Constants.semantic.bgLight,
boxShadow: this.state.urlError ? `0 0 0 1px ${Constants.system.red} inset` : "none",
}}
containerStyle={{ maxWidth: 600 }}
name="url"
type="url"
onChange={this._handleChange}
onSubmit={this._handleUploadLink}
autoFocus
/>
<System.ButtonPrimary
onClick={this._handleUploadLink}
style={{ height: 40, marginLeft: 8 }}
>
Save
</System.ButtonPrimary>
</div>
<System.Divider width="64px" style={{ margin: "40px 0px" }} />
<System.H4
style={{
color: Constants.semantic.textGrayDark,
textAlign: "center",
marginBottom: 8,
}}
>
Drag and drop files or use the button below to save to{" "}
{this.props.data ? Strings.getPresentationSlateName(this.props.data) : "Slate"}
</System.H4>
<System.P3
style={{
color: Constants.semantic.textGray,
maxWidth: 456,
textAlign: "center",
}}
>
Please don't upload sensitive information to Slate yet. All uploaded data can currently
be accessed by anyone with the link. Private storage is coming soon.
</System.P3>
<System.ButtonPrimary
type="label"
full
htmlFor="file"
style={{ marginTop: 40, maxWidth: 210 }}
>
Select files
</System.ButtonPrimary>
<br />
</div>
);
}
}
}

View File

@ -1,280 +0,0 @@
import * as React from "react";
import * as Constants from "~/common/constants";
import * as Strings from "~/common/strings";
import * as System from "~/components/system";
import * as Store from "~/common/store";
import * as SVG from "~/common/svg";
import * as Actions from "~/common/actions";
import * as Events from "~/common/custom-events";
import * as FileUtilities from "~/common/file-utilities";
import { css } from "@emotion/react";
import { DataMeterBar } from "~/components/core/DataMeter";
import { SidebarWarningMessage } from "~/components/core/WarningMessage";
import { FileTypeGroup } from "~/components/core/FileTypeIcon";
const STYLES_FILE_HIDDEN = css`
height: 1px;
width: 1px;
opacity: 0;
visibility: hidden;
position: fixed;
top: -1px;
left: -1px;
`;
const STYLES_FILE_LINE = css`
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 16px;
background-color: ${Constants.system.white};
border-bottom: 1px solid ${Constants.semantic.bgLight};
`;
const STYLES_FILE_NAME = css`
min-width: 10%;
width: 100%;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
font-size: 0.9rem;
font-family: ${Constants.font.medium};
`;
const STYLES_LEFT = css`
width: 100%;
min-width: 10%;
display: flex;
align-items: center;
`;
const STYLES_RIGHT = css`
flex-shrink: 0;
display: flex;
align-items: center;
`;
const STYLES_FILE_STATUS = css`
flex-shrink: 0;
margin-right: 16px;
display: flex;
align-items: center;
`;
const STYLES_BAR_CONTAINER = css`
background: ${Constants.system.white};
border-radius: 4px;
padding: 16px;
margin-top: 48px;
`;
const STYLES_PERFORMANCE = css`
font-family: ${Constants.font.code};
font-size: 12px;
display: block;
margin: 0 0 8px 0;
`;
export default class SidebarAddFileToBucket extends React.Component {
state = {
url: "",
slate: this.props.page.id === "NAV_SLATE" && this.props.data?.id ? this.props.data : null,
};
_handleUpload = (e) => {
this.props.onUpload({
files: e.target.files,
slate: this.state.slate,
});
this.props.onCancel();
};
_handleChange = (e) => {
this.setState({ [e.target.name]: e.target.value });
};
_handleCancel = (e, key) => {
e.preventDefault();
e.stopPropagation();
Events.dispatchCustomEvent({ name: `cancel-${key}` }); //NOTE(martina): so that will cancel if is in the middle of uploading
Store.setCancelled(key); //NOTE(martina): so that will cancel if hasn't started uploading yet
this.props.onAction({ type: "REGISTER_FILE_CANCELLED", value: key }); //NOTE(martina): so that fileLoading registers it
};
_handleUploadLink = () => {
FileUtilities.uploadLink({ url: this.state.url, slate: this.state.slate });
this.props.onCancel();
};
render() {
let loaded = 0;
let total = 0;
if (this.props.fileLoading) {
for (let file of Object.values(this.props.fileLoading)) {
if (typeof file.loaded === "number" && typeof file.total === "number") {
total += file.total;
loaded += file.loaded;
}
}
}
return (
<React.Fragment>
<System.P1
style={{
fontFamily: Constants.font.semiBold,
fontSize: Constants.typescale.lvl3,
marginBottom: 36,
}}
>
{this.props.fileLoading && Object.keys(this.props.fileLoading).length
? "Upload progress"
: "Upload data"}
</System.P1>
<input
css={STYLES_FILE_HIDDEN}
multiple
type="file"
id="file"
onChange={this._handleUpload}
/>
{this.props.fileLoading && Object.keys(this.props.fileLoading).length ? null : (
<React.Fragment>
<FileTypeGroup style={{ margin: "64px 0px" }} />
<System.P1>
Click below or drop a file anywhere on the page to upload a file
{this.props.data?.slatename || this.props.data?.name ? (
<span>
{" "}
to <strong>{Strings.getPresentationSlateName(this.props.data)}</strong>
</span>
) : (
""
)}
.
</System.P1>
<SidebarWarningMessage />
<System.Input
name="url"
type="url"
value={this.state.url}
placeholder="URL"
onChange={this._handleChange}
style={{ marginTop: 48 }}
/>
<System.ButtonPrimary
full
type="label"
style={{ marginTop: 24 }}
onClick={this._handleUploadLink}
>
Add link
</System.ButtonPrimary>
<System.Divider
color="#AEAEB2"
width="45px"
height="0.5px"
style={{ margin: "0px auto", marginTop: "20px" }}
/>
<System.ButtonPrimary full type="label" htmlFor="file" style={{ marginTop: 24 }}>
Upload file
</System.ButtonPrimary>
<br />
</React.Fragment>
)}
{this.props.fileLoading && Object.keys(this.props.fileLoading).length ? (
<div css={STYLES_BAR_CONTAINER}>
<strong css={STYLES_PERFORMANCE}>
{Strings.bytesToSize(loaded)} / {Strings.bytesToSize(total)}
</strong>
<DataMeterBar bytes={loaded} maximumBytes={total} />
</div>
) : null}
<div style={{ marginTop: 24, borderRadius: 4, overflow: "hidden" }}>
{this.props.fileLoading
? Object.entries(this.props.fileLoading).map((entry) => {
let file = entry[1];
return (
<div css={STYLES_FILE_LINE} key={file.name}>
<span css={STYLES_LEFT}>
<div css={STYLES_FILE_STATUS}>
{file.failed ? (
<SVG.Alert
height="24px"
style={{
color: Constants.system.red,
pointerEvents: "none",
}}
/>
) : file.cancelled ? (
<SVG.Dismiss
height="24px"
style={{
color: Constants.system.gray,
pointerEvents: "none",
}}
/>
) : file.loaded === file.total ? (
<SVG.CheckBox height="24px" />
) : (
<System.LoaderSpinner
style={{
width: "20px",
height: "20px",
margin: "2px",
}}
/>
)}
</div>
<div
css={STYLES_FILE_NAME}
style={
file.failed
? { color: Constants.system.red }
: file.cancelled
? { color: Constants.system.gray }
: null
}
>
{file.name}
</div>
</span>
{file.loaded === file.total || file.failed || file.cancelled ? (
<div css={STYLES_RIGHT} style={{ height: 24, width: 24 }} />
) : (
<span
css={STYLES_RIGHT}
style={{
cursor: "pointer",
}}
onClick={(e) => this._handleCancel(e, entry[0])}
>
<SVG.Dismiss
height="24px"
className="boundary-ignore"
style={{
color: Constants.system.gray,
pointerEvents: "none",
}}
/>
</span>
)}
</div>
);
})
: null}
</div>
</React.Fragment>
);
}
}

View File

@ -7,7 +7,7 @@ import { LoaderSpinner } from "~/components/system/components/Loaders";
const STYLES_BUTTON = `
box-sizing: border-box;
border-radius: 8px;
border-radius: 12px;
outline: 0;
border: 0;
min-height: 40px;

View File

@ -17,8 +17,6 @@ const STYLES_ROOT = css`
right: 0;
bottom: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
@ -26,9 +24,8 @@ const STYLES_ROOT = css`
z-index: ${Constants.zindex.modal};
background-color: rgba(0, 0, 0, 0.8);
${
"" /* Note(Amine): we're using the blur filter to fix a weird backdrop-filter's bug in chrome */
}
// Note(Amine): we're using the blur filter to fix a weird backdrop-filter's bug in chrome
filter: blur(0px);
@supports ((-webkit-backdrop-filter: blur(15px)) or (backdrop-filter: blur(15px))) {
-webkit-backdrop-filter: blur(15px);
@ -37,11 +34,11 @@ const STYLES_ROOT = css`
@keyframes global-carousel-fade-in {
from {
transform: translate(8px);
transform: translateX(8px);
opacity: 0;
}
to {
transform: trannslateX(0px);
transform: translateX(0px);
opacity: 1;
}
}

View File

@ -147,6 +147,7 @@ export class Table extends React.Component {
backgroundColor: this.props.noColor ? null : ac[c.key].color,
flexShrink,
}}
contentstyle={c.contentstyle}
tooltip={c.tooltip}
>
{text}

View File

@ -67,6 +67,7 @@ import {
H2,
H3,
H4,
H5,
P1,
P2,
P3,
@ -158,6 +159,7 @@ export {
H2,
H3,
H4,
H5,
P1,
P2,
P3,

View File

@ -15,6 +15,7 @@ class MyDocument extends Document {
* e.g. if the extension is installed on the user's browser, it will add 'isDownloaded' to className*/}
<div id="browser_extension" />
<Main />
<div id="modals_portal" />
<NextScript />
</body>
</Html>

View File

@ -46,7 +46,13 @@ export default async (req, res) => {
});
if (!filteredFiles?.length) {
return res.status(400).send({ decorator: "SERVER_CREATE_LINK_DUPLICATE", error: true });
return res.status(400).send({
decorator: "SERVER_CREATE_LINK_DUPLICATE",
data: {
links: duplicateFiles.map((file) => file.cid),
duplicate: true,
},
});
}
files = [];
@ -143,6 +149,6 @@ export default async (req, res) => {
return res.status(200).send({
decorator,
data: { added, skipped: files.length - added },
data: { added, links: filteredFiles.map((file) => file.cid), skipped: files.length - added },
});
};

View File

@ -86,6 +86,11 @@ export default async (req, res) => {
return res.status(200).send({
decorator,
data: { added, skipped: files.length - added },
data: {
added,
skipped: files.length - added,
// TODO(amine): merge upload and create endpoints
cid: added ? createdFiles[0]?.cid : duplicateFiles[0]?.cid,
},
});
};

View File

@ -46,12 +46,12 @@ export default function SceneActivity({ page, viewer, external, onAction, ...pro
const nbrOfCardsInRow = useNbrOfCardsPerRow(divRef);
const [globalCarouselState, setGlobalCarouselState] = React.useState({
currentCarrousel: -1,
currentCarousel: -1,
currentObjects: [],
});
const handleFileClick = (fileIdx, groupFiles) =>
setGlobalCarouselState({ currentCarrousel: fileIdx, currentObjects: groupFiles });
console.log(globalCarouselState.currentCarrousel, globalCarouselState.currentObjects.length);
setGlobalCarouselState({ currentCarousel: fileIdx, currentObjects: groupFiles });
console.log(globalCarouselState.currentCarousel, globalCarouselState.currentObjects.length);
useIntersection({
ref: divRef,
@ -100,9 +100,9 @@ export default function SceneActivity({ page, viewer, external, onAction, ...pro
carouselType="ACTIVITY"
viewer={viewer}
objects={globalCarouselState.currentObjects}
index={globalCarouselState.currentCarrousel}
index={globalCarouselState.currentCarousel}
isMobile={props.isMobile}
onChange={(idx) => setGlobalCarouselState((prev) => ({ ...prev, currentCarrousel: idx }))}
onChange={(idx) => setGlobalCarouselState((prev) => ({ ...prev, currentCarousel: idx }))}
isOwner={false}
onAction={() => {}}
/>
@ -130,24 +130,29 @@ function useNbrOfCardsPerRow(ref) {
const isMobile = window.matchMedia(`(max-width: ${Constants.sizes.mobile}px)`).matches;
const responsiveKey = isMobile ? "mobile" : "desktop";
const { width: objectPreviewWidth, rowGap: objectPreviewGridRowGap } =
Constants.grids.object[responsiveKey];
const { width: objectPreviewWidth, rowGap: objectPreviewGridRowGap } = Constants.grids.object[
responsiveKey
];
NbrOfCardsInRow.object = calculateNbrOfCards({
width: objectPreviewWidth,
gap: objectPreviewGridRowGap,
});
const { width: collectionPreviewWidth, rowGap: collectionPreviewGridRowGap } =
Constants.grids.collection[responsiveKey];
const {
width: collectionPreviewWidth,
rowGap: collectionPreviewGridRowGap,
} = Constants.grids.collection[responsiveKey];
NbrOfCardsInRow.collection = calculateNbrOfCards({
width: collectionPreviewWidth,
gap: collectionPreviewGridRowGap,
});
const { width: profilePreviewWidth, rowGap: profilePreviewGridRowGap } =
Constants.grids.profile[responsiveKey];
const {
width: profilePreviewWidth,
rowGap: profilePreviewGridRowGap,
} = Constants.grids.profile[responsiveKey];
NbrOfCardsInRow.profile = calculateNbrOfCards({
width: profilePreviewWidth,
gap: profilePreviewGridRowGap,

View File

@ -9,6 +9,7 @@ import * as Utilities from "~/common/utilities";
import * as UserBehaviors from "~/common/user-behaviors";
import * as Events from "~/common/custom-events";
import * as Styles from "~/common/styles";
import * as Upload from "~/components/core/Upload";
import { Link } from "~/components/core/Link";
import { LoaderSpinner } from "~/components/system/components/Loaders";
@ -356,18 +357,6 @@ class SlatePage extends React.Component {
// detail: { index },
// });
_handleAdd = async () => {
if (!this.props.viewer) {
Events.dispatchCustomEvent({ name: "slate-global-open-cta", detail: {} });
return;
}
await this.props.onAction({
type: "SIDEBAR",
value: "SIDEBAR_ADD_FILE_TO_BUCKET",
data: this.props.data,
});
};
_handleShowSettings = () => {
return this.props.onAction({
type: "SIDEBAR",
@ -406,13 +395,15 @@ class SlatePage extends React.Component {
const isOwner = this.props.viewer ? ownerId === this.props.viewer.id : false;
let actions = isOwner ? (
<span>
<span css={Styles.HORIZONTAL_CONTAINER}>
<SquareButtonGray onClick={this._handleDownload} style={{ marginRight: 16 }}>
<SVG.Download height="16px" />
</SquareButtonGray>
<SquareButtonGray onClick={this._handleAdd} style={{ marginRight: 16 }}>
<SVG.Plus height="16px" />
</SquareButtonGray>
<Upload.Trigger viewer={this.props.viewer} style={{ marginRight: 16 }}>
<SquareButtonGray>
<SVG.Plus height="16px" />
</SquareButtonGray>
</Upload.Trigger>
<SquareButtonGray onClick={this._handleShowSettings}>
<SVG.Settings height="16px" />
</SquareButtonGray>