From 6997e4b26eb54db3223e94e0defcec1c24593c43 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:49:50 +0000 Subject: [PATCH] feat: add assets library page for managing Supabase storage files - Add new route at /app/assets for authenticated users - Implement file navigator with folder navigation and breadcrumbs - Support single/multiple file upload and folder upload - Add multi-select functionality for bulk delete - Implement copy public URL feature - Add folder creation capability - Create Supabase migration for assets storage bucket with RLS policies Co-Authored-By: john@hyprnote.com --- apps/web/src/routes/_view/app/assets.tsx | 583 ++++++++++++++++++ .../20250102000000_create_assets_storage.sql | 13 + 2 files changed, 596 insertions(+) create mode 100644 apps/web/src/routes/_view/app/assets.tsx create mode 100644 supabase/migrations/20250102000000_create_assets_storage.sql diff --git a/apps/web/src/routes/_view/app/assets.tsx b/apps/web/src/routes/_view/app/assets.tsx new file mode 100644 index 0000000000..57e1d2f689 --- /dev/null +++ b/apps/web/src/routes/_view/app/assets.tsx @@ -0,0 +1,583 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { + ChevronRight, + Copy, + File, + FileImage, + FileText, + FileVideo, + Folder, + FolderPlus, + Home, + Link, + MoreVertical, + Trash2, + Upload, +} from "lucide-react"; +import { useCallback, useRef, useState } from "react"; + +import { getSupabaseBrowserClient } from "@/functions/supabase"; + +export const Route = createFileRoute("/_view/app/assets")({ + component: Component, + loader: async ({ context }) => ({ user: context.user }), +}); + +interface FileObject { + name: string; + id: string | null; + updated_at: string | null; + created_at: string | null; + last_accessed_at: string | null; + metadata: Record | null; +} + +interface FolderItem { + name: string; + isFolder: true; +} + +type ListItem = (FileObject & { isFolder: false }) | FolderItem; + +function getFileIcon(filename: string) { + const ext = filename.split(".").pop()?.toLowerCase(); + if (["jpg", "jpeg", "png", "gif", "webp", "svg", "ico"].includes(ext || "")) { + return FileImage; + } + if (["mp4", "webm", "mov", "avi", "mkv"].includes(ext || "")) { + return FileVideo; + } + if (["pdf", "doc", "docx", "txt", "md"].includes(ext || "")) { + return FileText; + } + if (["html", "htm"].includes(ext || "")) { + return Link; + } + return File; +} + +function formatFileSize(bytes: number | undefined): string { + if (!bytes) return "-"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +function formatDate(dateString: string | null): string { + if (!dateString) return "-"; + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function Component() { + const { user } = Route.useLoaderData(); + const queryClient = useQueryClient(); + const [currentPath, setCurrentPath] = useState([]); + const [selectedItems, setSelectedItems] = useState>(new Set()); + const [isCreatingFolder, setIsCreatingFolder] = useState(false); + const [newFolderName, setNewFolderName] = useState(""); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + item: ListItem; + } | null>(null); + const fileInputRef = useRef(null); + const folderInputRef = useRef(null); + + const userId = user?.id; + const fullPath = userId + ? [userId, ...currentPath].filter(Boolean).join("/") + : ""; + + const listQuery = useQuery({ + queryKey: ["assets", fullPath], + queryFn: async () => { + const supabase = getSupabaseBrowserClient(); + const { data, error } = await supabase.storage + .from("assets") + .list(fullPath, { + limit: 1000, + sortBy: { column: "name", order: "asc" }, + }); + + if (error) throw error; + + const items: ListItem[] = []; + const seenFolders = new Set(); + + for (const item of data || []) { + if (item.id === null && !seenFolders.has(item.name)) { + seenFolders.add(item.name); + items.push({ name: item.name, isFolder: true }); + } else if (item.id !== null) { + items.push({ ...item, isFolder: false }); + } + } + + items.sort((a, b) => { + if (a.isFolder && !b.isFolder) return -1; + if (!a.isFolder && b.isFolder) return 1; + return a.name.localeCompare(b.name); + }); + + return items; + }, + enabled: !!userId, + }); + + const uploadMutation = useMutation({ + mutationFn: async (files: FileList) => { + const supabase = getSupabaseBrowserClient(); + const results = []; + + for (const file of Array.from(files)) { + const relativePath = + (file as File & { webkitRelativePath?: string }).webkitRelativePath || + file.name; + const filePath = fullPath + ? `${fullPath}/${relativePath}` + : relativePath; + + const { data, error } = await supabase.storage + .from("assets") + .upload(filePath, file, { upsert: true }); + + if (error) throw error; + results.push(data); + } + + return results; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["assets", fullPath] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (paths: string[]) => { + const supabase = getSupabaseBrowserClient(); + const { error } = await supabase.storage.from("assets").remove(paths); + if (error) throw error; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["assets", fullPath] }); + setSelectedItems(new Set()); + }, + }); + + const createFolderMutation = useMutation({ + mutationFn: async (folderName: string) => { + const supabase = getSupabaseBrowserClient(); + const folderPath = fullPath + ? `${fullPath}/${folderName}/.keep` + : `${folderName}/.keep`; + const { error } = await supabase.storage + .from("assets") + .upload(folderPath, new Blob([""]), { upsert: true }); + if (error) throw error; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["assets", fullPath] }); + setIsCreatingFolder(false); + setNewFolderName(""); + }, + }); + + const handleFileUpload = useCallback( + (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + uploadMutation.mutate(e.target.files); + } + e.target.value = ""; + }, + [uploadMutation], + ); + + const handleNavigate = useCallback((folderName: string) => { + setCurrentPath((prev) => [...prev, folderName]); + setSelectedItems(new Set()); + }, []); + + const handleNavigateToPath = useCallback((index: number) => { + setCurrentPath((prev) => prev.slice(0, index)); + setSelectedItems(new Set()); + }, []); + + const handleSelect = useCallback( + (itemName: string, isShiftClick: boolean) => { + setSelectedItems((prev) => { + const newSet = new Set(prev); + if (isShiftClick) { + if (newSet.has(itemName)) { + newSet.delete(itemName); + } else { + newSet.add(itemName); + } + } else { + if (newSet.has(itemName) && newSet.size === 1) { + newSet.clear(); + } else { + newSet.clear(); + newSet.add(itemName); + } + } + return newSet; + }); + }, + [], + ); + + const handleSelectAll = useCallback(() => { + if (!listQuery.data) return; + const allNames = listQuery.data + .filter((item) => !item.isFolder) + .map((item) => item.name); + setSelectedItems((prev) => { + if (prev.size === allNames.length) { + return new Set(); + } + return new Set(allNames); + }); + }, [listQuery.data]); + + const handleDelete = useCallback(() => { + const paths = Array.from(selectedItems).map((name) => + fullPath ? `${fullPath}/${name}` : name, + ); + if (paths.length > 0) { + deleteMutation.mutate(paths); + } + }, [selectedItems, fullPath, deleteMutation]); + + const handleCopyUrl = useCallback( + async (itemName: string) => { + const supabase = getSupabaseBrowserClient(); + const filePath = fullPath ? `${fullPath}/${itemName}` : itemName; + const { data } = supabase.storage.from("assets").getPublicUrl(filePath); + await navigator.clipboard.writeText(data.publicUrl); + setContextMenu(null); + }, + [fullPath], + ); + + const handleContextMenu = useCallback( + (e: React.MouseEvent, item: ListItem) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, item }); + }, + [], + ); + + const handleCreateFolder = useCallback(() => { + if (newFolderName.trim()) { + createFolderMutation.mutate(newFolderName.trim()); + } + }, [newFolderName, createFolderMutation]); + + return ( +
setContextMenu(null)} + > +
+
+

+ Assets Library +

+
+ +
+
+ + +
+ {selectedItems.size > 0 && ( + + )} + + + +
+
+ + {isCreatingFolder && ( +
+ + setNewFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreateFolder(); + if (e.key === "Escape") { + setIsCreatingFolder(false); + setNewFolderName(""); + } + }} + placeholder="Folder name" + className="flex-1 px-2 py-1 text-sm border border-neutral-200 rounded focus:outline-none focus:ring-2 focus:ring-neutral-400" + autoFocus + /> + + +
+ )} + + + )} + onChange={handleFileUpload} + className="hidden" + /> + + {listQuery.isLoading ? ( +
+
Loading...
+
+ ) : listQuery.error ? ( +
+
+ Error loading files: {(listQuery.error as Error).message} +
+
+ ) : listQuery.data?.length === 0 ? ( +
+ +

No files or folders yet

+

+ Upload files or create a folder to get started +

+
+ ) : ( +
+ + + + + + + + + + + + {listQuery.data?.map((item) => { + const Icon = item.isFolder + ? Folder + : getFileIcon(item.name); + const isSelected = selectedItems.has(item.name); + + return ( + { + if (item.isFolder) { + handleNavigate(item.name); + } else { + handleSelect(item.name, e.shiftKey || e.metaKey); + } + }} + onContextMenu={(e) => handleContextMenu(e, item)} + > + + + + + + + ); + })} + +
+ 0 && + selectedItems.size === + listQuery.data?.filter((i) => !i.isFolder).length + } + onChange={handleSelectAll} + className="rounded border-neutral-300" + /> + + Name + + Size + + Modified +
+ {!item.isFolder && ( + {}} + onClick={(e) => { + e.stopPropagation(); + handleSelect(item.name, true); + }} + className="rounded border-neutral-300" + /> + )} + +
+ + {item.name} +
+
+ {item.isFolder + ? "-" + : formatFileSize( + (item.metadata as { size?: number })?.size, + )} + + {item.isFolder ? "-" : formatDate(item.updated_at)} + + +
+
+ )} +
+
+ + {contextMenu && ( +
e.stopPropagation()} + > + {!contextMenu.item.isFolder && ( + + )} + {contextMenu.item.isFolder && ( + + )} + {!contextMenu.item.isFolder && ( + + )} +
+ )} +
+ ); +} diff --git a/supabase/migrations/20250102000000_create_assets_storage.sql b/supabase/migrations/20250102000000_create_assets_storage.sql new file mode 100644 index 0000000000..80cf61455b --- /dev/null +++ b/supabase/migrations/20250102000000_create_assets_storage.sql @@ -0,0 +1,13 @@ +INSERT INTO storage.buckets (id, name, public) +VALUES ('assets', 'assets', true) +ON CONFLICT (id) DO NOTHING; + +CREATE POLICY "assets_select_owner" ON storage.objects FOR SELECT TO authenticated USING (bucket_id = 'assets' AND (SELECT auth.uid())::text = (storage.foldername(name))[1]); + +CREATE POLICY "assets_insert_authenticated" ON storage.objects FOR INSERT TO authenticated WITH CHECK (bucket_id = 'assets' AND (SELECT auth.uid())::text = (storage.foldername(name))[1]); + +CREATE POLICY "assets_update_owner" ON storage.objects FOR UPDATE TO authenticated USING (bucket_id = 'assets' AND (SELECT auth.uid())::text = (storage.foldername(name))[1]); + +CREATE POLICY "assets_delete_owner" ON storage.objects FOR DELETE TO authenticated USING (bucket_id = 'assets' AND (SELECT auth.uid())::text = (storage.foldername(name))[1]); + +CREATE POLICY "assets_public_read" ON storage.objects FOR SELECT TO anon USING (bucket_id = 'assets');