diff --git a/src/App.tsx b/src/App.tsx
index 8cb1fd5..abfdd2d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -9,6 +9,7 @@ import React from "react";
import Header from "./Header";
import Main from "./Main";
+import ProgressDialog from "./ProgressDialog";
const globalStyles = (
@@ -20,6 +21,7 @@ const theme = createTheme({
function App() {
const [search, setSearch] = React.useState("");
+ const [showProgressDialog, setShowProgressDialog] = React.useState(false);
const [error, setError] = React.useState(null);
return (
@@ -29,6 +31,7 @@ function App() {
setSearch(newSearch)}
+ setShowProgressDialog={setShowProgressDialog}
/>
setError(null)}
/>
+ setShowProgressDialog(false)}
+ />
);
}
diff --git a/src/Header.tsx b/src/Header.tsx
index 3b3331b..2ad3566 100644
--- a/src/Header.tsx
+++ b/src/Header.tsx
@@ -5,9 +5,11 @@ import { MoreHoriz as MoreHorizIcon } from "@mui/icons-material";
function Header({
search,
onSearchChange,
+ setShowProgressDialog,
}: {
search: string;
onSearchChange: (newSearch: string) => void;
+ setShowProgressDialog: (show: boolean) => void;
}) {
const [anchorEl, setAnchorEl] = useState(null);
@@ -39,6 +41,14 @@ function Header({
>
+
);
diff --git a/src/ProgressDialog.tsx b/src/ProgressDialog.tsx
new file mode 100644
index 0000000..2d288a8
--- /dev/null
+++ b/src/ProgressDialog.tsx
@@ -0,0 +1,29 @@
+import { Dialog, DialogContent, DialogTitle, Tab, Tabs } from "@mui/material";
+import { useState } from "react";
+
+function ProgressDialog({
+ open,
+ onClose,
+}: {
+ open: boolean;
+ onClose: () => void;
+}) {
+ const [tab, setTab] = useState(0);
+
+ return (
+
+ );
+}
+
+export default ProgressDialog;
diff --git a/src/UploadFab.tsx b/src/UploadFab.tsx
index 84a8e95..2a27c61 100644
--- a/src/UploadFab.tsx
+++ b/src/UploadFab.tsx
@@ -5,7 +5,7 @@ import {
CreateNewFolder as CreateNewFolderIcon,
Upload as UploadIcon,
} from "@mui/icons-material";
-import { createFolder } from "./app/fs";
+import { createFolder } from "./app/transfer";
function IconCaptionButton({
icon,
diff --git a/src/app/fs.ts b/src/app/fs.ts
deleted file mode 100644
index 07acae0..0000000
--- a/src/app/fs.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-export async function copyPaste(source: string, target: string) {
- const uploadUrl = `/api/write/items/${target}`;
- await fetch(uploadUrl, {
- method: "PUT",
- headers: { "x-amz-copy-source": encodeURIComponent(source) },
- });
-}
-
-export async function createFolder(cwd: string) {
- try {
- const folderName = window.prompt("Folder name");
- if (!folderName) return;
- const uploadUrl = `/api/write/items/${cwd}${folderName}/_$folder$`;
- await fetch(uploadUrl, { method: "PUT" });
- } catch (error) {
- fetch("/api/write/")
- .then((value) => {
- if (value.redirected) window.location.href = value.url;
- })
- .catch(() => {});
- console.log(`Create folder failed`);
- }
-}
diff --git a/src/app/transfer.ts b/src/app/transfer.ts
new file mode 100644
index 0000000..30a9865
--- /dev/null
+++ b/src/app/transfer.ts
@@ -0,0 +1,227 @@
+const THUMBNAIL_SIZE = 144;
+
+export async function generateThumbnail(file: File) {
+ const canvas = document.createElement("canvas");
+ canvas.width = THUMBNAIL_SIZE;
+ canvas.height = THUMBNAIL_SIZE;
+ var ctx = canvas.getContext("2d")!;
+
+ if (file.type.startsWith("image/")) {
+ const image = await new Promise((resolve) => {
+ const image = new Image();
+ image.onload = () => resolve(image);
+ image.src = URL.createObjectURL(file);
+ });
+ ctx.drawImage(image, 0, 0, THUMBNAIL_SIZE, THUMBNAIL_SIZE);
+ } else if (file.type === "video/mp4") {
+ // Generate thumbnail from video
+ const video = await new Promise(
+ async (resolve, reject) => {
+ const video = document.createElement("video");
+ video.muted = true;
+ video.src = URL.createObjectURL(file);
+ setTimeout(() => reject(new Error("Video load timeout")), 2000);
+ await video.play();
+ video.pause();
+ video.currentTime = 0;
+ resolve(video);
+ }
+ );
+ ctx.drawImage(video, 0, 0, THUMBNAIL_SIZE, THUMBNAIL_SIZE);
+ }
+
+ const thumbnailBlob = await new Promise((resolve) =>
+ canvas.toBlob((blob) => resolve(blob!))
+ );
+
+ return thumbnailBlob;
+}
+
+export async function blobDigest(blob: Blob) {
+ const digest = await crypto.subtle.digest("SHA-1", await blob.arrayBuffer());
+ const digestArray = Array.from(new Uint8Array(digest));
+ const digestHex = digestArray
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+ return digestHex;
+}
+
+export const SIZE_LIMIT = 100 * 1000 * 1000; // 100MB
+
+function xhrFetch(
+ url: RequestInfo | URL,
+ requestInit: RequestInit & {
+ onUploadProgress?: (progressEvent: ProgressEvent) => void;
+ }
+) {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.upload.onprogress = requestInit.onUploadProgress ?? null;
+ xhr.open(
+ requestInit.method ?? "GET",
+ url instanceof Request ? url.url : url
+ );
+ const headers = new Headers(requestInit.headers);
+ headers.forEach((value, key) => xhr.setRequestHeader(key, value));
+ xhr.onload = () => {
+ const headers = xhr
+ .getAllResponseHeaders()
+ .split("\r\n")
+ .reduce((acc, header) => {
+ const [key, value] = header.split(": ");
+ acc[key] = value;
+ return acc;
+ }, {} as Record);
+ resolve(new Response(xhr.responseText, { status: xhr.status, headers }));
+ };
+ xhr.onerror = reject;
+ if (
+ requestInit.body instanceof Blob ||
+ typeof requestInit.body === "string"
+ ) {
+ xhr.send(requestInit.body);
+ }
+ });
+}
+
+export async function multipartUpload(
+ key: string,
+ file: File,
+ options?: {
+ headers?: Record;
+ onUploadProgress?: (progressEvent: {
+ loaded: number;
+ total: number;
+ }) => void;
+ }
+) {
+ const headers = options?.headers || {};
+ headers["content-type"] = file.type;
+
+ const uploadResponse = await fetch(`/api/write/items/${key}?uploads`, {
+ headers,
+ method: "POST",
+ });
+ const { uploadId } = await uploadResponse.json<{ uploadId: string }>();
+ const totalChunks = Math.ceil(file.size / SIZE_LIMIT);
+
+ const promiseGenerator = function* () {
+ for (let i = 1; i <= totalChunks; i++) {
+ const chunk = file.slice((i - 1) * SIZE_LIMIT, i * SIZE_LIMIT);
+ const searchParams = new URLSearchParams({
+ partNumber: i.toString(),
+ uploadId,
+ });
+ yield xhrFetch(`/api/write/items/${key}?${searchParams}`, {
+ method: "PUT",
+ headers,
+ body: chunk,
+ onUploadProgress: (progressEvent) => {
+ if (typeof options?.onUploadProgress !== "function") return;
+ options.onUploadProgress({
+ loaded: (i - 1) * SIZE_LIMIT + progressEvent.loaded,
+ total: file.size,
+ });
+ },
+ }).then((res) => ({
+ partNumber: i,
+ etag: res.headers.get("etag")!,
+ }));
+ }
+ };
+
+ const uploadedParts = [];
+ for (const part of promiseGenerator()) {
+ const { partNumber, etag } = await part;
+ uploadedParts[partNumber - 1] = { partNumber, etag };
+ }
+ const completeParams = new URLSearchParams({ uploadId });
+ await fetch(`/api/write/items/${key}?${completeParams}`, {
+ method: "POST",
+ body: JSON.stringify({ parts: uploadedParts }),
+ });
+}
+
+export async function copyPaste(source: string, target: string) {
+ const uploadUrl = `/api/write/items/${target}`;
+ await fetch(uploadUrl, {
+ method: "PUT",
+ headers: { "x-amz-copy-source": encodeURIComponent(source) },
+ });
+}
+
+export async function createFolder(cwd: string) {
+ try {
+ const folderName = window.prompt("Folder name");
+ if (!folderName) return;
+ const uploadUrl = `/api/write/items/${cwd}${folderName}/_$folder$`;
+ await fetch(uploadUrl, { method: "PUT" });
+ } catch (error) {
+ fetch("/api/write/")
+ .then((value) => {
+ if (value.redirected) window.location.href = value.url;
+ })
+ .catch(() => {});
+ console.log(`Create folder failed`);
+ }
+}
+
+export const uploadQueue: {
+ basedir: string;
+ file: File;
+}[] = [];
+
+export async function processUploadQueue() {
+ if (!uploadQueue.length) {
+ return;
+ }
+
+ const { basedir, file } = uploadQueue.shift()!;
+ let thumbnailDigest = null;
+
+ if (file.type.startsWith("image/") || file.type === "video/mp4") {
+ try {
+ const thumbnailBlob = await generateThumbnail(file);
+ const digestHex = await blobDigest(thumbnailBlob);
+
+ const thumbnailUploadUrl = `/api/write/items/_$flaredrive$/thumbnails/${digestHex}.png`;
+ try {
+ await fetch(thumbnailUploadUrl, {
+ method: "PUT",
+ body: thumbnailBlob,
+ });
+ thumbnailDigest = digestHex;
+ } catch (error) {
+ fetch("/api/write/")
+ .then((value) => {
+ if (value.redirected) window.location.href = value.url;
+ })
+ .catch(() => {});
+ console.log(`Upload ${digestHex}.png failed`);
+ }
+ } catch (error) {
+ console.log(`Generate thumbnail failed`);
+ }
+ }
+
+ try {
+ const uploadUrl = `/api/write/items/${basedir}${file.name}`;
+ const headers: { "fd-thumbnail"?: string } = {};
+ if (thumbnailDigest) headers["fd-thumbnail"] = thumbnailDigest;
+ if (file.size >= SIZE_LIMIT) {
+ await multipartUpload(`${basedir}${file.name}`, file, {
+ headers,
+ });
+ } else {
+ await xhrFetch(uploadUrl, { headers, body: file });
+ }
+ } catch (error) {
+ fetch("/api/write/")
+ .then((value) => {
+ if (value.redirected) window.location.href = value.url;
+ })
+ .catch(() => {});
+ console.log(`Upload ${file.name} failed`, error);
+ }
+ setTimeout(processUploadQueue);
+}
diff --git a/tsconfig.json b/tsconfig.json
index 567bead..fe9c251 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -11,8 +11,8 @@
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
- "target": "es2018" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
- // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
+ "target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
+ "lib": ["es2021", "DOM"],
"jsx": "preserve" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */