From 716db8d0034f8d9a5b5bb1fc34e9cc628e875ba1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 16 Mar 2026 01:05:59 +0100 Subject: [PATCH] feat: add JMAP FileNode file storage backend and file settings - Implement JMAP FileNode client methods (get, query, list, create, update, destroy, copy) with Stalwart-compatible flat name encoding - Add FileNode/FileNodeFilter types to JMAP type definitions - Create file-store with Zustand for file management state (navigate, upload, delete, rename, move, cut/copy/paste, undo, favorites) - Add folder tree sidebar component for sidebar navigation layout - Add files settings dialog and settings page component with options for view mode, sort, icons, thumbnails, hidden files, folder layout - Update files page and file browser to support JMAP FileNode backend alongside WebDAV, with folder layout switching and settings integration - Add settings tab for files configuration in the settings page - Add translation keys for file settings, calendar subscriptions, identity deletion, contact deletion, email navigation, and reconnection messages across all 8 locales - Change WebDAV file storage to File storage in availability messages - Enhance translations test to verify source-referenced keys exist in the en locale - Fix duplicate JSX attribute in folder-tree-sidebar --- app/[locale]/files/page.tsx | 118 ++-- app/[locale]/settings/page.tsx | 6 +- components/files/file-browser.tsx | 302 +++++--- components/files/files-settings-dialog.tsx | 180 +++++ components/files/folder-tree-sidebar.tsx | 297 ++++++++ components/settings/files-settings.tsx | 109 +++ lib/__tests__/translations.test.ts | 88 ++- lib/jmap/client.ts | 265 +++++++- lib/jmap/types.ts | 19 + locales/de/common.json | 134 +++- locales/en/common.json | 107 ++- locales/es/common.json | 134 +++- locales/fr/common.json | 134 +++- locales/it/common.json | 134 +++- locales/ja/common.json | 134 +++- locales/nl/common.json | 134 +++- locales/pt/common.json | 134 +++- stores/file-store.ts | 757 +++++++++++++++++++++ 18 files changed, 2984 insertions(+), 202 deletions(-) create mode 100644 components/files/files-settings-dialog.tsx create mode 100644 components/files/folder-tree-sidebar.tsx create mode 100644 components/settings/files-settings.tsx create mode 100644 stores/file-store.ts diff --git a/app/[locale]/files/page.tsx b/app/[locale]/files/page.tsx index 20e177c4..2849b437 100644 --- a/app/[locale]/files/page.tsx +++ b/app/[locale]/files/page.tsx @@ -9,7 +9,7 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; -import { useWebDAVStore } from "@/stores/webdav-store"; +import { useFileStore } from "@/stores/file-store"; import { toast } from "@/stores/toast-store"; import { cn } from "@/lib/utils"; import { NavigationRail } from "@/components/layout/navigation-rail"; @@ -17,11 +17,13 @@ import { useIsMobile } from "@/hooks/use-media-query"; import { FileBrowser } from "@/components/files/file-browser"; import { ImagePreviewModal } from "@/components/files/image-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; +import { loadFilesSettings } from "@/components/files/files-settings-dialog"; +import type { FolderLayout } from "@/components/files/files-settings-dialog"; export default function FilesPage() { const router = useRouter(); const t = useTranslations("files"); - const { isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); + const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore(); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const { quota, isPushConnected } = useEmailStore(); const { @@ -29,13 +31,14 @@ export default function FilesPage() { resources, isLoading, error, - supportsWebDAV, + supportsFiles, selectedResources, uploadProgress, clipboard, initClient, checkSupport, navigate, + navigateByPath, refresh, createDirectory, uploadFile, @@ -51,6 +54,7 @@ export default function FilesPage() { duplicateResource, downloadResources, moveToFolder, + moveToParent, cutResources, copyResources, pasteResources, @@ -60,6 +64,7 @@ export default function FilesPage() { clearSelection, setSelection, listPath, + listByParentId, favorites, recentFiles, toggleFavorite, @@ -67,10 +72,23 @@ export default function FilesPage() { cancelUpload, undoLastAction, lastAction, - } = useWebDAVStore(); + } = useFileStore(); const isMobile = useIsMobile(); + const [folderLayout, setFolderLayout] = useState(() => loadFilesSettings().folderLayout); const hasFetched = useRef(false); + + // Sync folderLayout when settings change + useEffect(() => { + const reload = () => setFolderLayout(loadFilesSettings().folderLayout); + const handleStorage = (e: StorageEvent) => { if (e.key === "files-settings") reload(); }; + window.addEventListener("storage", handleStorage); + window.addEventListener("files-settings-changed", reload); + return () => { + window.removeEventListener("storage", handleStorage); + window.removeEventListener("files-settings-changed", reload); + }; + }, []); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const [previewImage, setPreviewImage] = useState(null); const [previewFile, setPreviewFile] = useState(null); @@ -94,34 +112,35 @@ export default function FilesPage() { } }, [initialCheckDone, isAuthenticated, authLoading, router]); - // Initialize WebDAV client + // Initialize JMAP files client useEffect(() => { - if (isAuthenticated && !hasFetched.current) { + if (isAuthenticated && client && !hasFetched.current) { hasFetched.current = true; - initClient(); + initClient(client); } - }, [isAuthenticated, initClient]); + }, [isAuthenticated, client, initClient]); // Check support and load root after client is initialized - const { webdavClient } = useWebDAVStore(); + const storeClient = useFileStore(s => s.client); useEffect(() => { - if (webdavClient && supportsWebDAV === null) { + if (storeClient && supportsFiles === null) { checkSupport().then((supported) => { if (supported) { - let initialPath = '/'; - try { - const saved = localStorage.getItem('webdav-last-path'); - if (saved) initialPath = saved; - } catch { /* ignore */ } - navigate(initialPath); + navigate(null); } }); } - }, [webdavClient, supportsWebDAV, checkSupport, navigate]); + }, [storeClient, supportsFiles, checkSupport, navigate]); - const handleNavigate = useCallback((path: string) => { - navigate(path); - }, [navigate]); + const handleNavigate = useCallback((path: string, resourceId?: string | null) => { + if (resourceId !== undefined) { + // Direct ID-based navigation (directory click, breadcrumb dropdown folder) + navigate(resourceId, path.split('/').pop() || ''); + } else { + // Path-based navigation (breadcrumbs, favorites, recent files) + navigateByPath(path); + } + }, [navigate, navigateByPath]); const handleCreateFolder = useCallback(async (name: string) => { try { @@ -225,15 +244,20 @@ export default function FilesPage() { } }, [renameResource, t, handleUndo]); + const findResourceId = useCallback((name: string) => { + const r = resources.find(res => res.name === name); + return r?.id || name; + }, [resources]); + const handleDownload = useCallback(async (name: string) => { try { await downloadResource(name); - addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name); + addRecentFile(name, findResourceId(name)); } catch (err) { console.error("Failed to download:", err); toast.error(t("download_error")); } - }, [downloadResource, addRecentFile, currentPath, t]); + }, [downloadResource, addRecentFile, findResourceId, t]); const handleBatchDownload = useCallback(async (names: string[]) => { try { @@ -276,6 +300,18 @@ export default function FilesPage() { } }, [moveToFolder, t, handleUndo]); + const handleMoveToParent = useCallback(async (names: string[]) => { + try { + await moveToParent(names); + toast.success(t("move_success", { count: names.length }), { + action: { label: t("undo"), onClick: handleUndo }, + }); + } catch (err) { + console.error("Failed to move:", err); + toast.error(t("move_error")); + } + }, [moveToParent, t, handleUndo]); + const handlePaste = useCallback(async () => { try { await pasteResources(); @@ -290,13 +326,13 @@ export default function FilesPage() { const handlePreviewImage = useCallback((name: string) => { setPreviewImage(name); - addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name); - }, [addRecentFile, currentPath]); + addRecentFile(name, findResourceId(name)); + }, [addRecentFile, findResourceId]); const handlePreviewFile = useCallback((name: string) => { setPreviewFile(name); - addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name); - }, [addRecentFile, currentPath]); + addRecentFile(name, findResourceId(name)); + }, [addRecentFile, findResourceId]); const handleShowDetails = useCallback((name: string) => { setDetailName(name); @@ -325,22 +361,24 @@ export default function FilesPage() {
-
-
- + {folderLayout !== "sidebar" && ( +
+
+ +
-
+ )}
- {supportsWebDAV === false ? ( + {supportsFiles === false ? (

{t("not_available")}

@@ -373,6 +411,7 @@ export default function FilesPage() { onCopy={copyResources} onPaste={handlePaste} onMoveToFolder={handleMoveToFolder} + onMoveToParent={handleMoveToParent} onPreviewImage={handlePreviewImage} onPreviewFile={handlePreviewFile} onShowDetails={handleShowDetails} @@ -380,6 +419,7 @@ export default function FilesPage() { onDuplicate={handleDuplicate} getImageUrl={getImageUrl} listPath={listPath} + listByParentId={listByParentId} favorites={favorites} recentFiles={recentFiles} onToggleFavorite={toggleFavorite} diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index bacf2ed5..1800bf46 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -18,6 +18,7 @@ import { AdvancedSettings } from '@/components/settings/advanced-settings'; import { FolderSettings } from '@/components/settings/folder-settings'; import { KeywordSettings } from '@/components/settings/keyword-settings'; import { AccountSecuritySettings } from '@/components/settings/account-security-settings'; +import { FilesSettingsComponent } from '@/components/settings/files-settings'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { useIsDesktop } from '@/hooks/use-media-query'; @@ -25,7 +26,7 @@ import { NavigationRail } from '@/components/layout/navigation-rail'; import { useConfig } from '@/hooks/use-config'; import { cn } from '@/lib/utils'; -type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'folders' | 'keywords' | 'advanced'; +type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'advanced'; export default function SettingsPage() { const router = useRouter(); @@ -66,6 +67,7 @@ export default function SettingsPage() { const supportsVacation = client?.supportsVacationResponse() ?? false; const supportsCalendar = client?.supportsCalendars() ?? false; const supportsSieve = client?.supportsSieve() ?? false; + const supportsFiles = client?.supportsFiles() ?? false; const tabs: { id: Tab; label: string }[] = [ { id: 'appearance', label: t('tabs.appearance') }, @@ -79,6 +81,7 @@ export default function SettingsPage() { { id: 'templates', label: t('tabs.templates') }, { id: 'folders', label: t('tabs.folders') }, { id: 'keywords', label: t('tabs.keywords') }, + ...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files') }] : []), { id: 'advanced', label: t('tabs.advanced') }, ]; @@ -105,6 +108,7 @@ export default function SettingsPage() { {activeTab === 'templates' && } {activeTab === 'folders' && } {activeTab === 'keywords' && } + {activeTab === 'files' && } {activeTab === 'advanced' && } ); diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index be6d21d0..2dfc7bd4 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -9,13 +9,19 @@ import { Copy, Clipboard, Scissors, Info, Image as ImageIcon, FilePlus, CopyPlus, FileText, FileAudio, FileVideo, AlertCircle, Star, Clock, FolderUp, + FileArchive, FileSpreadsheet, Presentation, FileCode, + Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn, formatFileSize } from "@/lib/utils"; import { NewFolderDialog } from "@/components/files/new-folder-dialog"; import { RenameDialog } from "@/components/files/rename-dialog"; import { FileUploadArea } from "@/components/files/file-upload-area"; -import type { WebDAVResource } from "@/lib/webdav/client"; +import { loadFilesSettings } from "@/components/files/files-settings-dialog"; +import type { FolderLayout } from "@/components/files/files-settings-dialog"; +import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar"; +import { ResizeHandle } from "@/components/layout/resize-handle"; +import type { FileResource } from "@/stores/file-store"; type SortKey = "name" | "size" | "modified"; type SortDir = "asc" | "desc"; @@ -23,20 +29,20 @@ type ViewMode = "list" | "grid"; interface ClipboardState { mode: "cut" | "copy"; - paths: string[]; + ids: string[]; names: string[]; - sourcePath: string; + sourceParentId: string | null; } interface FileBrowserProps { currentPath: string; - resources: WebDAVResource[]; + resources: FileResource[]; isLoading: boolean; error: string | null; selectedResources: Set; uploadProgress: { name: string; loaded: number; total: number; current: number; totalFiles: number } | null; clipboard: ClipboardState | null; - onNavigate: (path: string) => void; + onNavigate: (path: string, resourceId?: string | null) => void; onCreateFolder: (name: string) => Promise; onUploadFiles: (files: File[]) => Promise; onUploadFolder: (files: File[]) => Promise; @@ -56,19 +62,21 @@ interface FileBrowserProps { onCopy: (names: string[]) => void; onPaste: () => Promise; onMoveToFolder: (names: string[], targetFolder: string) => Promise; + onMoveToParent: (names: string[]) => Promise; onPreviewImage: (name: string) => void; onPreviewFile: (name: string) => void; onShowDetails: (name: string) => void; onCreateTextFile: (name: string) => Promise; onDuplicate: (name: string) => Promise; getImageUrl: (name: string) => Promise; - listPath: (path: string) => Promise; + listPath: (path: string) => Promise; + listByParentId: (parentId: string | null) => Promise; favorites: string[]; - recentFiles: { name: string; path: string; timestamp: number }[]; + recentFiles: { name: string; id: string; timestamp: number }[]; onToggleFavorite: (path: string) => void; showDetails: boolean; onToggleDetails: () => void; - detailResource: WebDAVResource | null; + detailResource: FileResource | null; } const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]); @@ -110,48 +118,100 @@ function isPdfFile(name: string): boolean { return PDF_EXTENSIONS.has(ext); } +const VECTOR_EXTENSIONS = new Set(["svg", "ai", "eps", "ps", "sketch", "fig", "xd", "gvdesign"]); +function isVectorFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + return VECTOR_EXTENSIONS.has(ext); +} + +const THREE_D_EXTENSIONS = new Set([ + "obj", "fbx", "gltf", "glb", "stl", "3mf", "step", "stp", "iges", "igs", + "blend", "3ds", "dae", "usdz", "usd", "usda", "usdc", "ply", "wrl", + "c4d", "max", "ma", "mb", "dwg", "dxf", +]); +function is3DFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + return THREE_D_EXTENSIONS.has(ext); +} + +const EXECUTABLE_EXTENSIONS = new Set([ + "exe", "msi", "dmg", "app", "appimage", "deb", "rpm", "snap", "flatpak", + "bat", "cmd", "com", "scr", "ps1", "apk", "ipa", "jar", "run", +]); +function isExecutableFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + return EXECUTABLE_EXTENSIONS.has(ext); +} + +const ARCHIVE_EXTENSIONS = new Set([ + "zip", "rar", "7z", "tar", "gz", "bz2", "xz", "zst", "lz", "lzma", + "tgz", "tbz2", "txz", "cab", "iso", "img", +]); +function isArchiveFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + return ARCHIVE_EXTENSIONS.has(ext); +} + +const SPREADSHEET_EXTENSIONS = new Set(["xls", "xlsx", "ods", "numbers", "tsv"]); +function isSpreadsheetFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + return SPREADSHEET_EXTENSIONS.has(ext); +} + +const PRESENTATION_EXTENSIONS = new Set(["ppt", "pptx", "odp", "key"]); +function isPresentationFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + return PRESENTATION_EXTENSIONS.has(ext); +} + +const FONT_EXTENSIONS = new Set(["ttf", "otf", "woff", "woff2", "eot"]); +function isFontFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + return FONT_EXTENSIONS.has(ext); +} + +const DATABASE_EXTENSIONS = new Set(["db", "sqlite", "sqlite3", "mdb", "accdb"]); +function isDatabaseFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + return DATABASE_EXTENSIONS.has(ext); +} + function isPreviewable(name: string): boolean { return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name); } const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB -function getFileIcon(resource: WebDAVResource) { +function getFileIconByName(name: string, size: "sm" | "lg") { + const cls = size === "sm" ? "w-5 h-5" : "w-10 h-10"; + if (isVectorFile(name)) return ; + if (is3DFile(name)) return ; + if (isImageFile(name)) return ; + if (isAudioFile(name)) return ; + if (isVideoFile(name)) return ; + if (isArchiveFile(name)) return ; + if (isExecutableFile(name)) return ; + if (isSpreadsheetFile(name)) return ; + if (isPresentationFile(name)) return ; + if (isFontFile(name)) return ; + if (isDatabaseFile(name)) return ; + if (isPdfFile(name)) return ; + if (isTextFile(name)) return ; + return ; +} + +function getFileIcon(resource: FileResource) { if (resource.isDirectory) { return ; } - if (isImageFile(resource.name)) { - return ; - } - if (isAudioFile(resource.name)) { - return ; - } - if (isVideoFile(resource.name)) { - return ; - } - if (isTextFile(resource.name)) { - return ; - } - return ; + return getFileIconByName(resource.name, "sm"); } -function getGridIcon(resource: WebDAVResource) { +function getGridIcon(resource: FileResource) { if (resource.isDirectory) { return ; } - if (isImageFile(resource.name)) { - return ; - } - if (isAudioFile(resource.name)) { - return ; - } - if (isVideoFile(resource.name)) { - return ; - } - if (isTextFile(resource.name)) { - return ; - } - return ; + return getFileIconByName(resource.name, "lg"); } function Thumbnail({ name, getImageUrl: fetchUrl, size = "sm" }: { @@ -246,6 +306,7 @@ export function FileBrowser({ onCopy, onPaste, onMoveToFolder, + onMoveToParent, onPreviewImage, onPreviewFile, onShowDetails, @@ -253,6 +314,7 @@ export function FileBrowser({ onDuplicate, getImageUrl, listPath, + listByParentId, favorites, recentFiles, onToggleFavorite, @@ -275,14 +337,44 @@ export function FileBrowser({ const [sortDir, setSortDir] = useState("asc"); const [viewMode, setViewMode] = useState(() => { if (typeof window !== "undefined") { - return (localStorage.getItem("webdav-view-mode") as ViewMode) || "list"; + return (localStorage.getItem("files-view-mode") as ViewMode) || "list"; } return "list"; }); + const [showThumbnails, setShowThumbnails] = useState(() => loadFilesSettings().showThumbnails); + const [folderLayout, setFolderLayout] = useState(() => loadFilesSettings().folderLayout); + const [sidebarWidth, setSidebarWidth] = useState(() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem("files-sidebar-width"); + if (saved) return Math.max(180, Math.min(400, Number(saved))); + } + return 220; + }); + const [isResizing, setIsResizing] = useState(false); + const dragStartWidth = useRef(220); const [dragTarget, setDragTarget] = useState(null); + + // Sync showThumbnails and folderLayout when settings change + useEffect(() => { + const reloadSettings = () => { + const s = loadFilesSettings(); + setShowThumbnails(s.showThumbnails); + setFolderLayout(s.folderLayout); + }; + const handleStorage = (e: StorageEvent) => { + if (e.key === "files-settings") reloadSettings(); + }; + // StorageEvent fires cross-tab, custom event fires same-tab + window.addEventListener("storage", handleStorage); + window.addEventListener("files-settings-changed", reloadSettings); + return () => { + window.removeEventListener("storage", handleStorage); + window.removeEventListener("files-settings-changed", reloadSettings); + }; + }, []); const [breadcrumbDropdown, setBreadcrumbDropdown] = useState<{ path: string; - folders: WebDAVResource[]; + folders: FileResource[]; x: number; y: number; } | null>(null); @@ -315,15 +407,19 @@ export function FileBrowser({ // Persist view mode const handleViewModeChange = useCallback((mode: ViewMode) => { setViewMode(mode); - localStorage.setItem("webdav-view-mode", mode); + localStorage.setItem("files-view-mode", mode); }, []); // Filter and sort resources const displayResources = useMemo(() => { let filtered = resources; + // In sidebar mode, folders are shown in the sidebar tree — hide them from the main list + if (folderLayout === "sidebar") { + filtered = filtered.filter(r => !r.isDirectory); + } if (searchQuery) { const q = searchQuery.toLowerCase(); - filtered = resources.filter(r => r.name.toLowerCase().includes(q)); + filtered = filtered.filter(r => r.name.toLowerCase().includes(q)); } const sorted = [...filtered].sort((a, b) => { @@ -345,7 +441,7 @@ export function FileBrowser({ return sortDir === "asc" ? cmp : -cmp; }); return sorted; - }, [resources, searchQuery, sortKey, sortDir]); + }, [resources, searchQuery, sortKey, sortDir, folderLayout]); // Build breadcrumb segments const breadcrumbs = currentPath === '/' @@ -363,10 +459,10 @@ export function FileBrowser({ const segments = currentPath.split('/').filter(Boolean); segments.pop(); const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/'); - onNavigate(parentPath); + onNavigate(parentPath, null); }; - const handleResourceClick = (resource: WebDAVResource, e: React.MouseEvent) => { + const handleResourceClick = (resource: FileResource, e: React.MouseEvent) => { if (resource.isDirectory) { // Ctrl/Cmd+click on directories also toggles selection if (e.ctrlKey || e.metaKey) { @@ -376,7 +472,7 @@ export function FileBrowser({ const newPath = currentPath === '/' ? `/${resource.name}` : `${currentPath}/${resource.name}`; - onNavigate(newPath); + onNavigate(newPath, resource.id); } else { if (e.ctrlKey || e.metaKey) { onToggleSelect(resource.name); @@ -491,12 +587,12 @@ export function FileBrowser({ }; }, [marquee, onSetSelection]); - const handleResourceDoubleClick = (resource: WebDAVResource) => { + const handleResourceDoubleClick = (resource: FileResource) => { if (resource.isDirectory) { const newPath = currentPath === '/' ? `/${resource.name}` : `${currentPath}/${resource.name}`; - onNavigate(newPath); + onNavigate(newPath, resource.id); } else if (isPreviewable(resource.name)) { if (isImageFile(resource.name)) { onPreviewImage(resource.name); @@ -511,7 +607,10 @@ export function FileBrowser({ const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - setIsDraggingOver(true); + // Only show upload overlay for external file drags, not internal resource drags + if (e.dataTransfer.types.includes("Files")) { + setIsDraggingOver(true); + } }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { @@ -711,7 +810,7 @@ export function FileBrowser({ const newPath = currentPath === '/' ? `/${resource.name}` : `${currentPath}/${resource.name}`; - onNavigate(newPath); + onNavigate(newPath, resource.id); } else if (resource) { onDownload(resource.name); } @@ -988,8 +1087,29 @@ export function FileBrowser({ {/* File list */}
- {/* Favorites & Recent sidebar */} - {(favorites.length > 0 || recentFiles.length > 0) && ( + {/* Folder tree sidebar (when layout is sidebar) */} + {folderLayout === "sidebar" && ( + <> + + { dragStartWidth.current = sidebarWidth; setIsResizing(true); }} + onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} + onResizeEnd={() => { + setIsResizing(false); + localStorage.setItem("files-sidebar-width", String(sidebarWidth)); + }} + onDoubleClick={() => { setSidebarWidth(220); localStorage.setItem("files-sidebar-width", "220"); }} + /> + + )} + {/* Favorites & Recent sidebar (when layout is inline) */} + {folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && (
{favorites.length > 0 && (
@@ -1023,13 +1143,9 @@ export function FileBrowser({
{recentFiles.slice(0, 10).map((recent) => (
)} - {detailResource.etag && ( -
-
ETag
-
{detailResource.etag}
-
- )}
{t("path")}
diff --git a/components/files/files-settings-dialog.tsx b/components/files/files-settings-dialog.tsx new file mode 100644 index 00000000..63281ea2 --- /dev/null +++ b/components/files/files-settings-dialog.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useRef, useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "@/components/settings/settings-section"; + +export type FolderLayout = "inline" | "sidebar"; + +export interface FilesSettings { + defaultViewMode: "list" | "grid"; + showIcons: boolean; + coloredIcons: boolean; + defaultSortKey: "name" | "size" | "modified"; + defaultSortDir: "asc" | "desc"; + showHiddenFiles: boolean; + showThumbnails: boolean; + folderLayout: FolderLayout; +} + +export const DEFAULT_FILES_SETTINGS: FilesSettings = { + defaultViewMode: "list", + showIcons: true, + coloredIcons: true, + defaultSortKey: "name", + defaultSortDir: "asc", + showHiddenFiles: false, + showThumbnails: true, + folderLayout: "inline", +}; + +export function loadFilesSettings(): FilesSettings { + if (typeof window === "undefined") return DEFAULT_FILES_SETTINGS; + try { + const raw = localStorage.getItem("files-settings"); + if (raw) return { ...DEFAULT_FILES_SETTINGS, ...JSON.parse(raw) }; + } catch { /* ignore */ } + return DEFAULT_FILES_SETTINGS; +} + +export function saveFilesSettings(settings: FilesSettings) { + localStorage.setItem("files-settings", JSON.stringify(settings)); + // Dispatch custom event for same-tab listeners (StorageEvent only fires cross-tab) + window.dispatchEvent(new CustomEvent("files-settings-changed")); +} + +interface FilesSettingsDialogProps { + isOpen: boolean; + onClose: () => void; + settings: FilesSettings; + onSettingsChange: (settings: FilesSettings) => void; +} + +export function FilesSettingsDialog({ isOpen, onClose, settings, onSettingsChange }: FilesSettingsDialogProps) { + const t = useTranslations("files"); + const modalRef = useRef(null); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + if (isOpen) window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose(); + } + }; + if (isOpen) document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const update = (patch: Partial) => { + const next = { ...settings, ...patch }; + onSettingsChange(next); + saveFilesSettings(next); + }; + + return ( +
+
+
+

{t("settings_title")}

+ +
+ +
+ + + update({ folderLayout: v as FolderLayout })} + options={[ + { value: "inline", label: t("settings_folder_layout_inline") }, + { value: "sidebar", label: t("settings_folder_layout_sidebar") }, + ]} + /> + + + update({ defaultViewMode: v as "list" | "grid" })} + options={[ + { value: "list", label: t("list_view") }, + { value: "grid", label: t("grid_view") }, + ]} + /> + + + update({ defaultSortKey: v as "name" | "size" | "modified" })} + options={[ + { value: "name", label: t("name") }, + { value: "size", label: t("size") }, + { value: "modified", label: t("modified") }, + ]} + /> + + + update({ defaultSortDir: v as "asc" | "desc" })} + options={[ + { value: "asc", label: t("settings_ascending") }, + { value: "desc", label: t("settings_descending") }, + ]} + /> + + + + + + update({ showIcons: v })} + /> + + + update({ coloredIcons: v })} + disabled={!settings.showIcons} + /> + + + update({ showThumbnails: v })} + /> + + + + + + update({ showHiddenFiles: v })} + /> + + +
+
+
+ ); +} diff --git a/components/files/folder-tree-sidebar.tsx b/components/files/folder-tree-sidebar.tsx new file mode 100644 index 00000000..7feaf921 --- /dev/null +++ b/components/files/folder-tree-sidebar.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { + Folder, + FolderOpen, + ChevronRight, + ChevronDown, + Home, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useFileStore, type FileResource } from "@/stores/file-store"; + +interface FolderNode { + id: string; + name: string; + path: string; +} + +interface FolderTreeSidebarProps { + currentPath: string; + onNavigate: (path: string, resourceId?: string | null) => void; + listByParentId: (parentId: string | null) => Promise; + width?: number; + isResizing?: boolean; +} + +export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 220, isResizing }: FolderTreeSidebarProps) { + const t = useTranslations("files"); + const client = useFileStore(s => s.client); + const [rootChildren, setRootChildren] = useState(null); + const [expandedIds, setExpandedIds] = useState>(new Set(["root"])); + const [loadingIds, setLoadingIds] = useState>(new Set()); + // Cache: parentId (or "root") -> FolderNode[] + const [childrenCache, setChildrenCache] = useState>(new Map()); + // Map folder path -> id for reverse lookup + const pathToIdRef = useRef>(new Map()); + + const loadChildren = useCallback(async (parentId: string | null, parentPath: string) => { + const cacheKey = parentId ?? "root"; + // Skip if already loading or cached + if (childrenCache.has(cacheKey)) return; + + setLoadingIds(prev => new Set(prev).add(cacheKey)); + try { + const resources = await listByParentId(parentId); + const folders = resources + .filter(r => r.isDirectory) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(r => { + const folderPath = parentPath === "/" ? `/${r.name}` : `${parentPath}/${r.name}`; + pathToIdRef.current.set(folderPath, r.id); + return { + id: r.id, + name: r.name, + path: folderPath, + }; + }); + + // Don't cache empty root results — empty root likely means client wasn't ready yet + if (folders.length > 0 || parentId !== null) { + setChildrenCache(prev => new Map(prev).set(cacheKey, folders)); + } + + if (parentId === null) { + setRootChildren(folders); + } + } catch { + // Silently fail + } finally { + setLoadingIds(prev => { + const next = new Set(prev); + next.delete(cacheKey); + return next; + }); + } + }, [childrenCache, listByParentId]); + + // Load root folders when client is available (handles page refresh timing) + useEffect(() => { + if (client) { + loadChildren(null, "/"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [client]); + + // Auto-expand along the current path when navigating + useEffect(() => { + if (currentPath === "/") return; + const segments = currentPath.split("/").filter(Boolean); + + // Walk down the path and expand + load each ancestor + let ancestorPath = ""; + for (let i = 0; i < segments.length; i++) { + ancestorPath = "/" + segments.slice(0, i + 1).join("/"); + const folderId = pathToIdRef.current.get(ancestorPath); + if (folderId) { + setExpandedIds(prev => { + if (prev.has(folderId)) return prev; + return new Set(prev).add(folderId); + }); + if (!childrenCache.has(folderId)) { + loadChildren(folderId, ancestorPath); + } + } + } + }, [currentPath, childrenCache, loadChildren]); + + const handleToggleExpand = useCallback(async (folderId: string, folderPath: string) => { + setExpandedIds(prev => { + const next = new Set(prev); + if (next.has(folderId)) { + next.delete(folderId); + } else { + next.add(folderId); + } + return next; + }); + + if (!childrenCache.has(folderId)) { + await loadChildren(folderId, folderPath); + } + }, [childrenCache, loadChildren]); + + const handleFolderClick = useCallback((path: string, id: string | null) => { + onNavigate(path, id); + }, [onNavigate]); + + return ( +