From 0e460370da015bb3917fc2b2106ae3da94851283 Mon Sep 17 00:00:00 2001 From: kenneth Date: Sun, 12 Oct 2025 00:43:31 +0000 Subject: [PATCH] feat: initial bulk file upload dialog --- bun.lock | 40 ++- packages/web/package.json | 4 + packages/web/src/components/ui/dialog.tsx | 2 +- packages/web/src/components/ui/progress.tsx | 29 ++ packages/web/src/components/with-atom.tsx | 2 +- .../web/src/dashboard/dashboard-sidebar.tsx | 2 - packages/web/src/files/store.ts | 31 ++ packages/web/src/files/upload-file-dialog.tsx | 319 ++++++++++++++++++ packages/web/src/files/use-upload-file.ts | 58 ++++ .../directories.$directoryId.tsx | 151 +++++---- 10 files changed, 558 insertions(+), 80 deletions(-) create mode 100644 packages/web/src/components/ui/progress.tsx create mode 100644 packages/web/src/files/store.ts create mode 100644 packages/web/src/files/upload-file-dialog.tsx create mode 100644 packages/web/src/files/use-upload-file.ts diff --git a/bun.lock b/bun.lock index fa376e3..caad210 100644 --- a/bun.lock +++ b/bun.lock @@ -38,6 +38,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", @@ -52,8 +53,11 @@ "convex": "^1.27.0", "convex-helpers": "^0.1.104", "jotai": "^2.14.0", + "jotai-effect": "^2.1.3", + "jotai-scope": "^0.9.5", "lucide-react": "^0.544.0", "motion": "^12.23.16", + "nanoid": "^5.1.6", "next-themes": "^0.4.6", "react": "^19", "react-dom": "^19", @@ -238,6 +242,28 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WeXSaL29ylJEZMYHHW28QZ6rgAbxQ1KuNSZD9gvd3fPlo0s6s2PglvPArjjP07nmvIK9m4OffN0k4M98O7WmAg=="], + + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CFKjoUWQH0Oz3UHYfKbdKLq0wGryrFsTJEYq839qAwHQSECvVZYAnxVVDYUDa0yQFonhO2qSHY41f6HK+b7xtw=="], + + "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+FSr/ub5vA/EkD3fMhHJUzYioSf/sXd50OGxNDAntVxcDu4tXL/81Ka3R/gkZmjznpLFIzovU/1Ts+b7dlkrfw=="], + + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WHthS/eLkCNcp9pk4W8aubRl9fIUgt2XhHyLrP0GClB1FVvmodu/zIOtG0NXNpzlzB8+gglOkGo4dPjfVf4Z+g=="], + + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HT5sr7N8NDYbQRjAnT7ISpx64y+ewZZRQozOJb0+KQObKvg4UUNXGm4Pn1xA4/WPMZDDazjO8E2vtOQw1nJlAQ=="], + + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-sGEWoJQXO4GDr0x4t/yJQ/Bq1yNkOdX9tHbZZ+DBGJt3z3r7jeb4Digv8xQUk6gdTFC9vnGHuin+KW3/yD1Aww=="], + + "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-OmlEH3nlxQyv7HOvTH21vyNAZGv9DIPnrTznzvKiOQxkOphhCyKvPTlF13ydw4s/i18iwaUrhHy+YG9HSSxa4Q=="], + + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rtzUEzCynl3Rhgn/iR9DQezSFiZMcAXAbU+xfROqsweMGKwvwIA2ckyyckO08psEP8XcUZTs3LT9CH7PnaMiEA=="], + + "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-hrr7mDvUjMX1tuJaXz448tMsgKIqGJBY8+rJqztKOw1U5+a/v2w5HuIIW1ce7ut0ZwEn+KIDvAujlPvpH33vpQ=="], + + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-xXwtpZVVP7T+vkxcF/TUVVOGRjEfkByO4mKveKYb4xnHWV4u4NnV0oNmzyMKkvmj10to5j2h0oZxA4ZVVv4gfA=="], + + "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/jVZ8eYjpYHLDFNoT86cP+AjuWvpkzFY+0R0a1bdeu0sQ6ILuy1FV6hz1hUAP390E09VCo5oP76fnx29giHTtA=="], + "@peculiar/asn1-android": ["@peculiar/asn1-android@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-t8A83hgghWQkcneRsgGs2ebAlRe54ns88p7ouv8PW2tzF1nAW4yHcL4uZKrFpIU+uszIRzTkcCuie37gpkId0A=="], "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "@peculiar/asn1-x509-attr": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-p0SjJ3TuuleIvjPM4aYfvYw8Fk1Hn/zAVyPJZTtZ2eE9/MIer6/18ROxX6N/e6edVSfvuZBqhxAj3YgsmSjQ/A=="], @@ -302,6 +328,8 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], @@ -446,7 +474,9 @@ "browserslist": ["browserslist@4.26.3", "", { "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", "electron-to-chromium": "^1.5.227", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w=="], - "bun-plugin-tailwind": ["bun-plugin-tailwind@0.0.15", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-qtAXMNGG4R0UGGI8zWrqm2B7BdXqx48vunJXBPzfDOHPA5WkRUZdTSbE7TFwO4jLhYqSE23YMWsM9NhE6ovobw=="], + "bun": ["bun@1.3.0", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.0", "@oven/bun-darwin-x64": "1.3.0", "@oven/bun-darwin-x64-baseline": "1.3.0", "@oven/bun-linux-aarch64": "1.3.0", "@oven/bun-linux-aarch64-musl": "1.3.0", "@oven/bun-linux-x64": "1.3.0", "@oven/bun-linux-x64-baseline": "1.3.0", "@oven/bun-linux-x64-musl": "1.3.0", "@oven/bun-linux-x64-musl-baseline": "1.3.0", "@oven/bun-windows-x64": "1.3.0", "@oven/bun-windows-x64-baseline": "1.3.0" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-YI7mFs7iWc/VsGsh2aw6eAPD2cjzn1j+LKdYVk09x1CrdTWKYIHyd+dG5iQoN9//3hCDoZj8U6vKpZzEf5UARA=="], + + "bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="], "bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="], @@ -536,6 +566,10 @@ "jotai": ["jotai@2.15.0", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-nbp/6jN2Ftxgw0VwoVnOg0m5qYM1rVcfvij+MZx99Z5IK13eGve9FJoCwGv+17JvVthTjhSmNtT5e1coJnr6aw=="], + "jotai-effect": ["jotai-effect@2.1.3", "", { "peerDependencies": { "jotai": ">=2.14.0" } }, "sha512-gFIqKvW5hljRLaZihqI48SFFYZQifaT3ZDsqBdqyMRRvm++PAWpcmrRWvqG2MrG346Chs4QUWeZzAucgViggDQ=="], + + "jotai-scope": ["jotai-scope@0.9.5", "", { "peerDependencies": { "jotai": ">=2.15.0", "react": ">=16.0.0" } }, "sha512-oOUduQ4ObALHz1+tAyoGeiuNTO3X3H8sUoOfliuMvQqS0HAhTHspFTq06b6SvKQkUtruw98XzVntsrGChmBRNA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -562,7 +596,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], "nanostores": ["nanostores@0.11.4", "", {}, "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ=="], @@ -694,6 +728,8 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], diff --git a/packages/web/package.json b/packages/web/package.json index 9f18e48..d669eca 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", @@ -31,8 +32,11 @@ "convex": "^1.27.0", "convex-helpers": "^0.1.104", "jotai": "^2.14.0", + "jotai-effect": "^2.1.3", + "jotai-scope": "^0.9.5", "lucide-react": "^0.544.0", "motion": "^12.23.16", + "nanoid": "^5.1.6", "next-themes": "^0.4.6", "react": "^19", "react-dom": "^19", diff --git a/packages/web/src/components/ui/dialog.tsx b/packages/web/src/components/ui/dialog.tsx index ba114f5..ff36dff 100644 --- a/packages/web/src/components/ui/dialog.tsx +++ b/packages/web/src/components/ui/dialog.tsx @@ -36,7 +36,7 @@ function DialogOverlay({ ) { + return ( + + + + ) +} + +export { Progress } diff --git a/packages/web/src/components/with-atom.tsx b/packages/web/src/components/with-atom.tsx index daf7d75..917ceff 100644 --- a/packages/web/src/components/with-atom.tsx +++ b/packages/web/src/components/with-atom.tsx @@ -7,7 +7,7 @@ export function WithAtom({ atom: PrimitiveAtom children: ( value: Value, - setValue: (value: Value) => void, + setValue: (value: Value | ((current: Value) => Value)) => void, ) => React.ReactNode }) { const [value, setValue] = useAtom(atom) diff --git a/packages/web/src/dashboard/dashboard-sidebar.tsx b/packages/web/src/dashboard/dashboard-sidebar.tsx index 05b0cad..b265e38 100644 --- a/packages/web/src/dashboard/dashboard-sidebar.tsx +++ b/packages/web/src/dashboard/dashboard-sidebar.tsx @@ -25,7 +25,6 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, - SidebarRail, } from "@/components/ui/sidebar" import { LoadingSpinner } from "../components/ui/loading-spinner" import { backgroundTaskProgressAtom } from "./state" @@ -50,7 +49,6 @@ export function DashboardSidebar() { - ) } diff --git a/packages/web/src/files/store.ts b/packages/web/src/files/store.ts new file mode 100644 index 0000000..5db0134 --- /dev/null +++ b/packages/web/src/files/store.ts @@ -0,0 +1,31 @@ +import { atom } from "jotai" +import { atomFamily } from "jotai/utils" + +type FileUpload = { + id: string + progress: number +} + +export const fileUploadsAtom = atom>({}) + +export const fileUploadAtomFamily = atomFamily((id: string) => + atom( + (get) => get(fileUploadsAtom)[id], + (get, set, progress: number) => { + const fileUploads = { ...get(fileUploadsAtom) } + fileUploads[id] = { id, progress } + set(fileUploadsAtom, fileUploads) + }, + ), +) + +export const clearFileUploadAtom = atom(null, (get, set, id: string) => { + const fileUploads = { ...get(fileUploadsAtom) } + delete fileUploads[id] + fileUploadAtomFamily.remove(id) + set(fileUploadsAtom, fileUploads) +}) + +export const hasFileUploadsAtom = atom( + (get) => Object.keys(get(fileUploadsAtom)).length > 0, +) diff --git a/packages/web/src/files/upload-file-dialog.tsx b/packages/web/src/files/upload-file-dialog.tsx new file mode 100644 index 0000000..9b13bec --- /dev/null +++ b/packages/web/src/files/upload-file-dialog.tsx @@ -0,0 +1,319 @@ +import type { Doc } from "@fileone/convex/_generated/dataModel" +import { useMutation } from "@tanstack/react-query" +import { + atom, + type PrimitiveAtom, + useAtom, + useAtomValue, + useSetAtom, + useStore, +} from "jotai" +import { atomEffect } from "jotai-effect" +import { FilePlus2Icon, UploadCloudIcon, XIcon } from "lucide-react" +import { nanoid } from "nanoid" +import type React from "react" +import { useId, useMemo, useRef, useState } from "react" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Progress } from "@/components/ui/progress" +import { WithAtom } from "@/components/with-atom" +import { clearFileUploadAtom, fileUploadAtomFamily } from "./store" +import useUploadFile from "./use-upload-file" + +type UploadFileDialogProps = { + targetDirectory: Doc<"directories"> + onClose: () => void +} + +// Upload file atoms +export type PickedFile = { + id: string + file: File +} + +export const pickedFilesAtom = atom([]) + +export function UploadFileDialog({ + targetDirectory, + onClose, +}: UploadFileDialogProps) { + const formId = useId() + const fileInputRef = useRef(null) + const setPickedFiles = useSetAtom(pickedFilesAtom) + const clearFileUpload = useSetAtom(clearFileUploadAtom) + const store = useStore() + + const updateFileInputEffect = useMemo( + () => + atomEffect((get) => { + const dataTransfer = new DataTransfer() + const pickedFiles = get(pickedFilesAtom) + for (const { file } of pickedFiles) { + dataTransfer.items.add(file) + } + if (fileInputRef.current) { + fileInputRef.current.files = dataTransfer.files + } + }), + [], + ) + useAtom(updateFileInputEffect) + + const uploadFile = useUploadFile({ + targetDirectory, + }) + const { mutate: uploadFiles, isPending: isUploading } = useMutation({ + mutationFn: async (files: PickedFile[]) => { + const promises = files.map((pickedFile) => + uploadFile({ + file: pickedFile.file, + onStart: () => { + store.set(fileUploadAtomFamily(pickedFile.id), 0) + }, + onProgress: (progress) => { + store.set(fileUploadAtomFamily(pickedFile.id), progress) + }, + }).then(() => { + clearFileUpload(pickedFile.id) + }), + ) + await Promise.all(promises) + }, + onSuccess: () => { + toast.success("All files uploaded successfully") + onClose() + }, + }) + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + } + + function openFilePicker() { + fileInputRef.current?.click() + } + + function handleFileChange(event: React.ChangeEvent) { + const files = event.target.files + if (files) { + setPickedFiles((prev) => [ + ...prev, + ...Array.from(files).map((file) => ({ id: nanoid(), file })), + ]) + } + } + + function startFileUpload() { + const pickedFiles = store.get(pickedFilesAtom) + uploadFiles(pickedFiles) + } + + return ( + { + if (!open) onClose() + }} + > + + + + Upload file to "{targetDirectory.name}" + + + Drag and drop files here or click to select files + + + +
+ + + + + +
+ + + + {(pickedFiles) => ( + <> + {pickedFiles.length > 0 ? ( + + ) : null} + + + )} + + +
+
+ ) +} + +function UploadFileDropContainer({ children }: React.PropsWithChildren) { + const [draggedFiles, setDraggedFiles] = useState([]) + const setPickedFiles = useSetAtom(pickedFilesAtom) + + function handleDragOver(e: React.DragEvent) { + e.preventDefault() + const items = Array.from(e.dataTransfer.items) + const draggedFiles = [] + for (const item of items) { + if (item.kind === "file") { + draggedFiles.push(item) + } + } + setDraggedFiles(draggedFiles) + } + + function handleDragLeave() { + setDraggedFiles([]) + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault() + const items = Array.from(e.dataTransfer.items) + const droppedFiles: PickedFile[] = [] + for (const item of items) { + const file = item.getAsFile() + if (file) { + droppedFiles.push({ + id: nanoid(), + file, + }) + } + } + setPickedFiles((prev) => [...prev, ...droppedFiles]) + setDraggedFiles([]) + } + + return ( +
+ {children} + {draggedFiles.length > 0 ? ( +
+ +

Drop {draggedFiles.length} files here

+
+ ) : null} +
+ ) +} + +function UploadFileArea({ onClick }: { onClick: () => void }) { + const [pickedFiles, setPickedFiles] = useAtom(pickedFilesAtom) + + function removeSelectedFile(file: PickedFile) { + setPickedFiles((prev) => prev.filter((f) => f.id !== file.id)) + } + + if (pickedFiles.length > 0) { + return ( + + ) + } + + return ( + + ) +} + +function PickedFilesList({ + pickedFiles, + onRemoveFile, +}: { + pickedFiles: PickedFile[] + onRemoveFile: (file: PickedFile) => void +}) { + return ( +
    + {pickedFiles.map((file: PickedFile) => ( + + ))} +
+ ) +} + +function PickedFileItem({ + file: pickedFile, + onRemove, +}: { + file: PickedFile + onRemove: (file: PickedFile) => void +}) { + const fileUploadAtom = fileUploadAtomFamily(pickedFile.id) + const fileUpload = useAtomValue(fileUploadAtom) + console.log("fileUpload", fileUpload) + const { file, id } = pickedFile + return ( +
  • + {file.name} + {fileUpload ? ( + + ) : ( + + )} +
  • + ) +} diff --git a/packages/web/src/files/use-upload-file.ts b/packages/web/src/files/use-upload-file.ts new file mode 100644 index 0000000..08a72ec --- /dev/null +++ b/packages/web/src/files/use-upload-file.ts @@ -0,0 +1,58 @@ +import { api } from "@fileone/convex/_generated/api" +import type { Doc, Id } from "@fileone/convex/_generated/dataModel" +import { useMutation as useConvexMutation } from "convex/react" +import { useCallback } from "react" + +function useUploadFile({ + targetDirectory, +}: { + targetDirectory: Doc<"directories"> +}) { + const generateUploadUrl = useConvexMutation(api.files.generateUploadUrl) + const saveFile = useConvexMutation(api.files.saveFile) + + async function upload({ + file, + onStart, + onProgress, + }: { + file: File + onStart: (xhr: XMLHttpRequest) => void + onProgress: (progress: number) => void + }) { + const uploadUrl = await generateUploadUrl() + + return new Promise<{ storageId: Id<"_storage"> }>((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.upload.addEventListener("progress", (e) => { + onProgress(e.loaded / e.total) + }) + xhr.upload.addEventListener("error", reject) + xhr.addEventListener("load", () => { + console.log("load", xhr.response) + resolve( + xhr.response as { + storageId: Id<"_storage"> + }, + ) + }) + xhr.open("POST", uploadUrl) + xhr.responseType = "json" + xhr.setRequestHeader("Content-Type", file.type) + xhr.send(file) + onStart(xhr) + }).then(({ storageId }) => + saveFile({ + storageId, + name: file.name, + size: file.size, + mimeType: file.type, + directoryId: targetDirectory._id, + }), + ) + } + + return useCallback(upload, []) +} + +export default useUploadFile diff --git a/packages/web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx b/packages/web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx index 07d7afb..ef745e7 100644 --- a/packages/web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx +++ b/packages/web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx @@ -47,6 +47,7 @@ import { FilePathBreadcrumb } from "@/directories/directory-page/file-path-bread import { NewDirectoryDialog } from "@/directories/directory-page/new-directory-dialog" import { RenameFileDialog } from "@/directories/directory-page/rename-file-dialog" import { FilePreviewDialog } from "@/files/file-preview-dialog" +import { UploadFileDialog } from "@/files/upload-file-dialog" import type { FileDragInfo } from "@/files/use-file-drop" export const Route = createFileRoute( @@ -55,9 +56,25 @@ export const Route = createFileRoute( component: RouteComponent, }) +enum DialogKind { + NewDirectory = "NewDirectory", + UploadFile = "UploadFile", +} + +type NewDirectoryDialogData = { + kind: DialogKind.NewDirectory +} + +type UploadFileDialogData = { + kind: DialogKind.UploadFile + directory: Doc<"directories"> +} + +type ActiveDialogData = NewDirectoryDialogData | UploadFileDialogData + // MARK: atoms const contextMenuTargetItemsAtom = atom([]) -const newFileTypeAtom = atom(null) +const activeDialogDataAtom = atom(null) const fileDragInfoAtom = atom(null) const optimisticDeletedItemsAtom = atom( new Set | Id<"directories">>(), @@ -154,17 +171,25 @@ function RouteComponent() { - - {(newFileType, setNewFileType) => ( - { - if (!open) { - setNewFileType(null) - } - }} - /> + + {(data, setData) => ( + <> + { + if (!open) { + setData(null) + } + }} + /> + {data?.kind === DialogKind.UploadFile && ( + setData(null)} + /> + )} + )} @@ -249,8 +274,6 @@ function DirectoryContentContextMenu({ } } - console.log("target", target) - return ( { @@ -311,82 +334,62 @@ function RenameMenuItem() { // tags: upload, uploadfile, uploadfilebutton, fileupload, fileuploadbutton function UploadFileButton() { const { directory } = useContext(DirectoryPageContext) - const generateUploadUrl = useConvexMutation(api.files.generateUploadUrl) - const saveFile = useConvexMutation(api.files.saveFile) - const { mutate: uploadFile, isPending: isUploading } = useMutation({ - mutationFn: async (file: File) => { - const uploadUrl = await generateUploadUrl() - const uploadResult = await fetch(uploadUrl, { - method: "POST", - body: file, - headers: { - "Content-Type": file.type, - }, - }) - const { storageId } = await uploadResult.json() + // const uploadFile = useUploadFile({ + // targetDirectory: directory, + // }) + // const { mutate: uploadFile, isPending: isUploading } = useMutation({ + // mutationFn: async (file: File) => { + // const uploadUrl = await generateUploadUrl() + // const uploadResult = await fetch(uploadUrl, { + // method: "POST", + // body: file, + // headers: { + // "Content-Type": file.type, + // }, + // }) + // const { storageId } = await uploadResult.json() - await saveFile({ - storageId, - name: file.name, - size: file.size, - mimeType: file.type, - directoryId: directory._id, - }) - }, - onSuccess: () => { - toast.success("File uploaded successfully.") - }, - }) - - const fileInputRef = useRef(null) + // await saveFile({ + // storageId, + // name: file.name, + // size: file.size, + // mimeType: file.type, + // directoryId: directory._id, + // }) + // }, + // onSuccess: () => { + // toast.success("File uploaded successfully.") + // }, + // }) + const setActiveDialogData = useSetAtom(activeDialogDataAtom) const handleClick = () => { - fileInputRef.current?.click() - } - - const onFileUpload = async (e: ChangeEvent) => { - const file = e.target.files?.[0] - if (file) { - uploadFile(file) - } + setActiveDialogData({ + kind: DialogKind.UploadFile, + directory: directory, + }) } return ( - <> - - - + ) } function NewDirectoryItemDropdown() { - const [newFileType, setNewFileType] = useAtom(newFileTypeAtom) + const [activeDialogData, setActiveDialogData] = + useAtom(activeDialogDataAtom) const addNewDirectory = () => { - setNewFileType(FileType.Directory) + setActiveDialogData({ + kind: DialogKind.NewDirectory, + }) } const handleCloseAutoFocus = (event: Event) => { // If we just created a new item, prevent the dropdown from restoring focus to the trigger - if (newFileType) { + if (activeDialogData) { event.preventDefault() } }