diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 7e49c884..f5358428 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -45,7 +45,8 @@ export default function CalendarPage() { const router = useRouter(); const t = useTranslations("calendar"); const isMobile = useIsMobile(); - const { client, isAuthenticated, logout } = useAuthStore(); + const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); + const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const { quota, isPushConnected } = useEmailStore(); const { calendars, events, selectedDate, viewMode, selectedCalendarIds, @@ -76,14 +77,21 @@ export default function CalendarPage() { // Swipe navigation ref (handlers defined after navigatePrev/navigateNext) const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null); + // Check auth on mount useEffect(() => { - if (!isAuthenticated) { + checkAuth().finally(() => { + setInitialCheckDone(true); + }); + }, [checkAuth]); + + useEffect(() => { + if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } router.push("/login"); - } else if (!supportsCalendar) { + } else if (client && !supportsCalendar) { router.push("/"); } - }, [isAuthenticated, supportsCalendar, router]); + }, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, router]); useEffect(() => { if (error) { diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index 718c47cb..245c0b32 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -38,7 +38,8 @@ type View = export default function ContactsPage() { const router = useRouter(); const t = useTranslations("contacts"); - const { client, isAuthenticated, logout } = useAuthStore(); + const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); + const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const { quota, isPushConnected } = useEmailStore(); const { contacts, @@ -77,12 +78,19 @@ export default function ContactsPage() { const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const isMobile = useIsMobile(); + // Check auth on mount useEffect(() => { - if (!isAuthenticated) { + checkAuth().finally(() => { + setInitialCheckDone(true); + }); + }, [checkAuth]); + + useEffect(() => { + if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } router.push("/login"); } - }, [isAuthenticated, router]); + }, [initialCheckDone, isAuthenticated, authLoading, router]); useEffect(() => { if (client && supportsSync && !hasFetched.current) { diff --git a/app/[locale]/files/page.tsx b/app/[locale]/files/page.tsx new file mode 100644 index 00000000..20e177c4 --- /dev/null +++ b/app/[locale]/files/page.tsx @@ -0,0 +1,423 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { useRouter } from "@/i18n/navigation"; +import { useTranslations } from "next-intl"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +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 { toast } from "@/stores/toast-store"; +import { cn } from "@/lib/utils"; +import { NavigationRail } from "@/components/layout/navigation-rail"; +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"; + +export default function FilesPage() { + const router = useRouter(); + const t = useTranslations("files"); + const { isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); + const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); + const { quota, isPushConnected } = useEmailStore(); + const { + currentPath, + resources, + isLoading, + error, + supportsWebDAV, + selectedResources, + uploadProgress, + clipboard, + initClient, + checkSupport, + navigate, + refresh, + createDirectory, + uploadFile, + uploadFiles, + uploadFolder, + deleteResource, + deleteResources, + renameResource, + downloadResource, + getImageUrl, + getFileContent, + createTextFile, + duplicateResource, + downloadResources, + moveToFolder, + cutResources, + copyResources, + pasteResources, + selectResource, + toggleSelect, + selectAll, + clearSelection, + setSelection, + listPath, + favorites, + recentFiles, + toggleFavorite, + addRecentFile, + cancelUpload, + undoLastAction, + lastAction, + } = useWebDAVStore(); + + const isMobile = useIsMobile(); + const hasFetched = useRef(false); + const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); + const [previewImage, setPreviewImage] = useState(null); + const [previewFile, setPreviewFile] = useState(null); + const [showDetails, setShowDetails] = useState(false); + const [detailName, setDetailName] = useState(null); + + const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null; + + // Check auth on mount + useEffect(() => { + checkAuth().finally(() => { + setInitialCheckDone(true); + }); + }, [checkAuth]); + + // Redirect if not authenticated + useEffect(() => { + if (initialCheckDone && !isAuthenticated && !authLoading) { + try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } + router.push("/login"); + } + }, [initialCheckDone, isAuthenticated, authLoading, router]); + + // Initialize WebDAV client + useEffect(() => { + if (isAuthenticated && !hasFetched.current) { + hasFetched.current = true; + initClient(); + } + }, [isAuthenticated, initClient]); + + // Check support and load root after client is initialized + const { webdavClient } = useWebDAVStore(); + useEffect(() => { + if (webdavClient && supportsWebDAV === null) { + checkSupport().then((supported) => { + if (supported) { + let initialPath = '/'; + try { + const saved = localStorage.getItem('webdav-last-path'); + if (saved) initialPath = saved; + } catch { /* ignore */ } + navigate(initialPath); + } + }); + } + }, [webdavClient, supportsWebDAV, checkSupport, navigate]); + + const handleNavigate = useCallback((path: string) => { + navigate(path); + }, [navigate]); + + const handleCreateFolder = useCallback(async (name: string) => { + try { + await createDirectory(name); + toast.success(t("create_folder_success")); + } catch (err) { + console.error("Failed to create folder:", err); + toast.error(t("create_folder_error")); + } + }, [createDirectory, t]); + + const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB + + const handleUploadFiles = useCallback(async (files: File[]) => { + const oversized = files.filter(f => f.size > MAX_FILE_SIZE); + const valid = files.filter(f => f.size <= MAX_FILE_SIZE); + if (oversized.length > 0) { + toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" })); + } + if (valid.length === 0) return; + try { + await uploadFiles(valid); + toast.success(t("upload_success", { count: valid.length })); + } catch (err) { + console.error("Failed to upload files:", err); + toast.error(t("upload_error")); + } + }, [uploadFiles, t]); + + const handleUploadFolder = useCallback(async (files: File[]) => { + const oversized = files.filter(f => f.size > MAX_FILE_SIZE); + const valid = files.filter(f => f.size <= MAX_FILE_SIZE); + if (oversized.length > 0) { + toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" })); + } + if (valid.length === 0) return; + try { + await uploadFolder(valid); + toast.success(t("upload_success", { count: valid.length })); + } catch (err) { + console.error("Failed to upload folder:", err); + toast.error(t("upload_error")); + } + }, [uploadFolder, t]); + + const handleDelete = useCallback(async (name: string) => { + const confirmed = await confirmDialog({ + title: t("delete_confirm_title"), + message: t("delete_confirm_message", { name }), + confirmText: t("delete"), + variant: "destructive", + }); + if (!confirmed) return; + + try { + await deleteResource(name); + toast.success(t("delete_success")); + } catch (err) { + console.error("Failed to delete:", err); + toast.error(t("delete_error")); + } + }, [deleteResource, confirmDialog, t]); + + const handleBatchDelete = useCallback(async (names: string[]) => { + const confirmed = await confirmDialog({ + title: t("delete_confirm_title"), + message: t("batch_delete_confirm_message", { count: names.length }), + confirmText: t("delete"), + variant: "destructive", + }); + if (!confirmed) return; + + try { + await deleteResources(names); + toast.success(t("batch_delete_success", { count: names.length })); + } catch (err) { + console.error("Failed to batch delete:", err); + toast.error(t("delete_error")); + } + }, [deleteResources, confirmDialog, t]); + + const handleUndo = useCallback(async () => { + try { + await undoLastAction(); + toast.success(t("undo_success")); + } catch (err) { + console.error("Failed to undo:", err); + toast.error(t("undo_error")); + } + }, [undoLastAction, t]); + + const handleRename = useCallback(async (oldName: string, newName: string) => { + try { + await renameResource(oldName, newName); + toast.success(t("rename_success"), { + action: { label: t("undo"), onClick: handleUndo }, + }); + } catch (err) { + console.error("Failed to rename:", err); + toast.error(t("rename_error")); + } + }, [renameResource, t, handleUndo]); + + const handleDownload = useCallback(async (name: string) => { + try { + await downloadResource(name); + addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name); + } catch (err) { + console.error("Failed to download:", err); + toast.error(t("download_error")); + } + }, [downloadResource, addRecentFile, currentPath, t]); + + const handleBatchDownload = useCallback(async (names: string[]) => { + try { + await downloadResources(names); + } catch (err) { + console.error("Failed to batch download:", err); + toast.error(t("download_error")); + } + }, [downloadResources, t]); + + const handleCreateTextFile = useCallback(async (name: string) => { + try { + await createTextFile(name); + toast.success(t("create_file_success")); + } catch (err) { + console.error("Failed to create file:", err); + toast.error(t("create_file_error")); + } + }, [createTextFile, t]); + + const handleDuplicate = useCallback(async (name: string) => { + try { + await duplicateResource(name); + toast.success(t("duplicate_success")); + } catch (err) { + console.error("Failed to duplicate:", err); + toast.error(t("duplicate_error")); + } + }, [duplicateResource, t]); + + const handleMoveToFolder = useCallback(async (names: string[], targetFolder: string) => { + try { + await moveToFolder(names, targetFolder); + 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")); + } + }, [moveToFolder, t, handleUndo]); + + const handlePaste = useCallback(async () => { + try { + await pasteResources(); + toast.success(t("paste_success"), { + action: lastAction ? { label: t("undo"), onClick: handleUndo } : undefined, + }); + } catch (err) { + console.error("Failed to paste:", err); + toast.error(t("paste_error")); + } + }, [pasteResources, t, lastAction, handleUndo]); + + const handlePreviewImage = useCallback((name: string) => { + setPreviewImage(name); + addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name); + }, [addRecentFile, currentPath]); + + const handlePreviewFile = useCallback((name: string) => { + setPreviewFile(name); + addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name); + }, [addRecentFile, currentPath]); + + const handleShowDetails = useCallback((name: string) => { + setDetailName(name); + setShowDetails(true); + }, []); + + const handleToggleDetails = useCallback(() => { + setShowDetails(v => !v); + }, []); + + if (!isAuthenticated) return null; + + return ( +
+ {!isMobile && ( +
+ { logout(); router.push('/login'); }} + /> +
+ )} + +
+
+
+
+
+ +
+
+ +
+ {supportsWebDAV === false ? ( +
+

{t("not_available")}

+
+ ) : ( + + )} +
+
+
+ + {isMobile && ( + + )} +
+ + {/* Image preview modal */} + {previewImage && ( + setPreviewImage(null)} + onDownload={handleDownload} + getImageUrl={getImageUrl} + /> + )} + + {/* File preview modal (text, PDF, audio, video, markdown) */} + {previewFile && ( + setPreviewFile(null)} + onDownload={handleDownload} + getFileContent={getFileContent} + /> + )} + + +
+ ); +} diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 9a23caa1..bacf2ed5 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -31,19 +31,33 @@ export default function SettingsPage() { const router = useRouter(); const t = useTranslations('settings'); const tSidebar = useTranslations('sidebar'); - const { client, isAuthenticated, logout } = useAuthStore(); + const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); + const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const { quota, isPushConnected } = useEmailStore(); const { stalwartFeaturesEnabled } = useConfig(); - const [activeTab, setActiveTab] = useState('appearance'); + const [activeTab, setActiveTab] = useState(() => { + try { + const saved = localStorage.getItem('settings-active-tab'); + if (saved) return saved as Tab; + } catch { /* ignore */ } + return 'appearance'; + }); const [mobileShowContent, setMobileShowContent] = useState(false); const isDesktop = useIsDesktop(); + // Check auth on mount useEffect(() => { - if (!isAuthenticated) { + checkAuth().finally(() => { + setInitialCheckDone(true); + }); + }, [checkAuth]); + + useEffect(() => { + if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } router.push('/login'); } - }, [isAuthenticated, router]); + }, [initialCheckDone, isAuthenticated, authLoading, router]); if (!isAuthenticated) { return null; @@ -70,6 +84,7 @@ export default function SettingsPage() { const handleTabSelect = (tabId: Tab) => { setActiveTab(tabId); + try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ } if (!isDesktop) { setMobileShowContent(true); } diff --git a/app/api/webdav/route.ts b/app/api/webdav/route.ts new file mode 100644 index 00000000..9bda6c1a --- /dev/null +++ b/app/api/webdav/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; + +const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']); + +/** + * POST /api/webdav + * Proxies WebDAV requests to the Stalwart server. + * + * Headers: + * X-WebDAV-Method: The actual WebDAV method (PROPFIND, MKCOL, GET, PUT, DELETE, MOVE, COPY) + * X-WebDAV-Path: Resource path relative to the user's DAV root (default: /) + * X-WebDAV-Destination: Destination path for MOVE/COPY (relative to user's DAV root) + * Depth: WebDAV Depth header (forwarded as-is) + * Content-Type: Forwarded for PROPFIND (XML) and PUT (file upload) + * Overwrite: WebDAV Overwrite header for MOVE/COPY + */ +export async function POST(request: NextRequest) { + try { + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const method = request.headers.get('X-WebDAV-Method')?.toUpperCase(); + if (!method || !ALLOWED_METHODS.has(method)) { + return NextResponse.json({ error: 'Invalid WebDAV method' }, { status: 400 }); + } + + const davPath = request.headers.get('X-WebDAV-Path') || '/'; + const cleanPath = davPath.replace(/^\/+/, ''); + const baseUrl = creds.apiUrl.replace(/\/$/, ''); + const targetUrl = cleanPath + ? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}` + : `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`; + + // Build headers for the upstream request + const upstreamHeaders: Record = { + 'Authorization': creds.authHeader, + }; + + // Forward relevant WebDAV headers + const depth = request.headers.get('Depth'); + if (depth) upstreamHeaders['Depth'] = depth; + + const contentType = request.headers.get('Content-Type'); + if (contentType) upstreamHeaders['Content-Type'] = contentType; + + // For MOVE/COPY, construct the full Destination URL from the relative path + const destination = request.headers.get('X-WebDAV-Destination'); + if (destination) { + const cleanDest = destination.replace(/^\/+/, ''); + upstreamHeaders['Destination'] = cleanDest + ? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}` + : `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`; + } + + const overwrite = request.headers.get('Overwrite'); + if (overwrite) upstreamHeaders['Overwrite'] = overwrite; + + // Forward request body for methods that need it + let body: ArrayBuffer | null = null; + if (method === 'PROPFIND' || method === 'PUT') { + body = await request.arrayBuffer(); + } + + const response = await fetch(targetUrl, { + method, + headers: upstreamHeaders, + body, + redirect: 'follow', + }); + + // For file downloads (GET), stream the response back + if (method === 'GET') { + const headers = new Headers(); + headers.set('Content-Type', response.headers.get('Content-Type') || 'application/octet-stream'); + const contentLength = response.headers.get('Content-Length'); + if (contentLength) headers.set('Content-Length', contentLength); + headers.set('X-WebDAV-Request-URI', targetUrl); + + return new NextResponse(response.body, { + status: response.status, + headers, + }); + } + + // For PROPFIND, return XML with the actual request URI for href comparison + if (method === 'PROPFIND') { + const text = await response.text(); + const headers = new Headers(); + headers.set('Content-Type', 'application/xml; charset=utf-8'); + headers.set('X-WebDAV-Request-URI', targetUrl); + + return new NextResponse(text, { + status: response.status, + headers, + }); + } + + // For other methods (MKCOL, DELETE, MOVE, COPY, PUT), return the status + return new NextResponse(null, { + status: response.status, + }); + } catch (error) { + logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx new file mode 100644 index 00000000..be6d21d0 --- /dev/null +++ b/components/files/file-browser.tsx @@ -0,0 +1,1647 @@ +"use client"; + +import { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { + Folder, File, Upload, FolderPlus, Download, Trash2, + Pencil, RefreshCw, Home, ChevronRight, MoreVertical, + Search, ArrowUp, ArrowDown, X, LayoutGrid, LayoutList, + Copy, Clipboard, Scissors, Info, Image as ImageIcon, + FilePlus, CopyPlus, FileText, FileAudio, FileVideo, + AlertCircle, Star, Clock, FolderUp, +} 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"; + +type SortKey = "name" | "size" | "modified"; +type SortDir = "asc" | "desc"; +type ViewMode = "list" | "grid"; + +interface ClipboardState { + mode: "cut" | "copy"; + paths: string[]; + names: string[]; + sourcePath: string; +} + +interface FileBrowserProps { + currentPath: string; + resources: WebDAVResource[]; + 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; + onCreateFolder: (name: string) => Promise; + onUploadFiles: (files: File[]) => Promise; + onUploadFolder: (files: File[]) => Promise; + onCancelUpload: () => void; + onDelete: (name: string) => Promise; + onBatchDelete: (names: string[]) => Promise; + onRename: (oldName: string, newName: string) => Promise; + onDownload: (name: string) => Promise; + onBatchDownload: (names: string[]) => Promise; + onRefresh: () => Promise; + onSelectResource: (name: string | null) => void; + onToggleSelect: (name: string) => void; + onSelectAll: () => void; + onClearSelection: () => void; + onSetSelection: (names: Set) => void; + onCut: (names: string[]) => void; + onCopy: (names: string[]) => void; + onPaste: () => Promise; + onMoveToFolder: (names: string[], targetFolder: 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; + favorites: string[]; + recentFiles: { name: string; path: string; timestamp: number }[]; + onToggleFavorite: (path: string) => void; + showDetails: boolean; + onToggleDetails: () => void; + detailResource: WebDAVResource | null; +} + +const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]); + +function isImageFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + return IMAGE_EXTENSIONS.has(ext); +} + +const TEXT_EXTENSIONS = new Set([ + "txt", "md", "markdown", "json", "xml", "html", "htm", "css", "js", "ts", + "jsx", "tsx", "py", "rb", "java", "c", "cpp", "h", "hpp", "go", "rs", + "sh", "bash", "zsh", "yaml", "yml", "toml", "ini", "cfg", "conf", "env", + "log", "csv", "sql", "graphql", "vue", "svelte", "astro", "php", "pl", + "swift", "kt", "scala", "r", "lua", "vim", +]); + +function isTextFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + const baseName = name.toLowerCase(); + return TEXT_EXTENSIONS.has(ext) || ["dockerfile", "makefile", "readme", "license", "changelog"].includes(baseName); +} + +const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "flac", "aac", "m4a", "wma", "opus"]); +function isAudioFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + return AUDIO_EXTENSIONS.has(ext); +} + +const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "ogv", "mov", "avi", "mkv", "m4v"]); +function isVideoFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + return VIDEO_EXTENSIONS.has(ext); +} + +const PDF_EXTENSIONS = new Set(["pdf"]); +function isPdfFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + return PDF_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) { + 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 ; +} + +function getGridIcon(resource: WebDAVResource) { + 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 ; +} + +function Thumbnail({ name, getImageUrl: fetchUrl, size = "sm" }: { + name: string; + getImageUrl: (n: string) => Promise; + size?: "sm" | "lg"; +}) { + const [src, setSrc] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + fetchUrl(name).then(url => { if (!cancelled) setSrc(url); }).catch(() => { if (!cancelled) setFailed(true); }); + return () => { cancelled = true; }; + }, [name, fetchUrl]); + + if (failed || !src) { + return size === "sm" + ? + : ; + } + + const cls = size === "sm" + ? "w-5 h-5 rounded object-cover" + : "w-10 h-10 rounded object-cover"; + + return {name}; +} + +function formatDate(dateString: string): string { + if (!dateString) return ""; + try { + return new Date(dateString).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return dateString; + } +} + +function SkeletonRow() { + return ( + + +
+
+
+
+
+ + +
+ + +
+ + +
+ + + ); +} + +export function FileBrowser({ + currentPath, + resources, + isLoading, + error, + selectedResources, + uploadProgress, + onNavigate, + onCreateFolder, + onUploadFiles, + onUploadFolder, + onCancelUpload, + onDelete, + onBatchDelete, + onRename, + onDownload, + onBatchDownload, + onRefresh, + onSelectResource, + onToggleSelect, + onSelectAll, + onClearSelection, + onSetSelection, + onCut, + onCopy, + onPaste, + onMoveToFolder, + onPreviewImage, + onPreviewFile, + onShowDetails, + onCreateTextFile, + onDuplicate, + getImageUrl, + listPath, + favorites, + recentFiles, + onToggleFavorite, + showDetails, + onToggleDetails, + detailResource, + clipboard, +}: FileBrowserProps) { + const t = useTranslations("files"); + const [showNewFolder, setShowNewFolder] = useState(false); + const [renameTarget, setRenameTarget] = useState(null); + const [isDraggingOver, setIsDraggingOver] = useState(false); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null); + const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null); + const [showNewTextFile, setShowNewTextFile] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [showSearch, setShowSearch] = useState(false); + const [sortKey, setSortKey] = useState("name"); + const [sortDir, setSortDir] = useState("asc"); + const [viewMode, setViewMode] = useState(() => { + if (typeof window !== "undefined") { + return (localStorage.getItem("webdav-view-mode") as ViewMode) || "list"; + } + return "list"; + }); + const [dragTarget, setDragTarget] = useState(null); + const [breadcrumbDropdown, setBreadcrumbDropdown] = useState<{ + path: string; + folders: WebDAVResource[]; + x: number; + y: number; + } | null>(null); + const [marquee, setMarquee] = useState<{ + startX: number; + startY: number; + currentX: number; + currentY: number; + } | null>(null); + const marqueeRef = useRef<{ + additive: boolean; + initialSelection: Set; + } | null>(null); + const fileInputRef = useRef(null); + const folderInputRef = useRef(null); + const searchInputRef = useRef(null); + const containerRef = useRef(null); + const scrollAreaRef = useRef(null); + + // Reset search when navigating + useEffect(() => { + setSearchQuery(""); + }, [currentPath]); + + // Focus search input when shown + useEffect(() => { + if (showSearch) searchInputRef.current?.focus(); + }, [showSearch]); + + // Persist view mode + const handleViewModeChange = useCallback((mode: ViewMode) => { + setViewMode(mode); + localStorage.setItem("webdav-view-mode", mode); + }, []); + + // Filter and sort resources + const displayResources = useMemo(() => { + let filtered = resources; + if (searchQuery) { + const q = searchQuery.toLowerCase(); + filtered = resources.filter(r => r.name.toLowerCase().includes(q)); + } + + const sorted = [...filtered].sort((a, b) => { + // Directories always first + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; + + let cmp = 0; + switch (sortKey) { + case "name": + cmp = a.name.localeCompare(b.name); + break; + case "size": + cmp = a.contentLength - b.contentLength; + break; + case "modified": + cmp = new Date(a.lastModified || 0).getTime() - new Date(b.lastModified || 0).getTime(); + break; + } + return sortDir === "asc" ? cmp : -cmp; + }); + return sorted; + }, [resources, searchQuery, sortKey, sortDir]); + + // Build breadcrumb segments + const breadcrumbs = currentPath === '/' + ? [{ name: t("breadcrumb_root"), path: '/' }] + : [ + { name: t("breadcrumb_root"), path: '/' }, + ...currentPath.split('/').filter(Boolean).map((segment, i, arr) => ({ + name: segment, + path: '/' + arr.slice(0, i + 1).join('/'), + })), + ]; + + const handleNavigateUp = () => { + if (currentPath === '/') return; + const segments = currentPath.split('/').filter(Boolean); + segments.pop(); + const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/'); + onNavigate(parentPath); + }; + + const handleResourceClick = (resource: WebDAVResource, e: React.MouseEvent) => { + if (resource.isDirectory) { + // Ctrl/Cmd+click on directories also toggles selection + if (e.ctrlKey || e.metaKey) { + onToggleSelect(resource.name); + return; + } + const newPath = currentPath === '/' + ? `/${resource.name}` + : `${currentPath}/${resource.name}`; + onNavigate(newPath); + } else { + if (e.ctrlKey || e.metaKey) { + onToggleSelect(resource.name); + } else if (e.shiftKey && resources.length > 0) { + // Shift+click range select + handleShiftSelect(resource.name); + } else { + onSelectResource(resource.name === [...selectedResources][0] && selectedResources.size === 1 ? null : resource.name); + } + } + }; + + const handleShiftSelect = (targetName: string) => { + const lastSelected = [...selectedResources].pop(); + if (!lastSelected) { + onToggleSelect(targetName); + return; + } + const names = displayResources.map(r => r.name); + const startIdx = names.indexOf(lastSelected); + const endIdx = names.indexOf(targetName); + if (startIdx === -1 || endIdx === -1) return; + const from = Math.min(startIdx, endIdx); + const to = Math.max(startIdx, endIdx); + for (let i = from; i <= to; i++) { + if (!selectedResources.has(names[i])) { + onToggleSelect(names[i]); + } + } + }; + + // Marquee (rubber-band) selection + const handleMarqueeMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; + const target = e.target as HTMLElement; + if (target.closest('[data-resource]') || target.closest('input') || target.closest('button') || target.closest('thead')) return; + + const scrollArea = scrollAreaRef.current; + if (!scrollArea) return; + + const rect = scrollArea.getBoundingClientRect(); + const x = e.clientX - rect.left + scrollArea.scrollLeft; + const y = e.clientY - rect.top + scrollArea.scrollTop; + + const additive = e.ctrlKey || e.metaKey; + marqueeRef.current = { + additive, + initialSelection: additive ? new Set(selectedResources) : new Set(), + }; + + setMarquee({ startX: x, startY: y, currentX: x, currentY: y }); + + if (!additive) { + onClearSelection(); + } + + e.preventDefault(); + }, [selectedResources, onClearSelection]); + + useEffect(() => { + if (!marquee) return; + + const handleMouseMove = (e: MouseEvent) => { + const scrollArea = scrollAreaRef.current; + if (!scrollArea) return; + + const rect = scrollArea.getBoundingClientRect(); + const x = e.clientX - rect.left + scrollArea.scrollLeft; + const y = e.clientY - rect.top + scrollArea.scrollTop; + + setMarquee(prev => prev ? { ...prev, currentX: x, currentY: y } : null); + + // Calculate marquee rect + const info = marqueeRef.current; + if (!info) return; + + const mx = Math.min(marquee.startX, x); + const my = Math.min(marquee.startY, y); + const mw = Math.abs(x - marquee.startX); + const mh = Math.abs(y - marquee.startY); + + // Find intersecting items + const elements = scrollArea.querySelectorAll('[data-resource]'); + const containerRect = scrollArea.getBoundingClientRect(); + const newSelection = new Set(info.initialSelection); + + elements.forEach(el => { + const name = el.getAttribute('data-resource'); + if (!name) return; + const elRect = el.getBoundingClientRect(); + const elX = elRect.left - containerRect.left + scrollArea.scrollLeft; + const elY = elRect.top - containerRect.top + scrollArea.scrollTop; + + if (mx < elX + elRect.width && mx + mw > elX && my < elY + elRect.height && my + mh > elY) { + newSelection.add(name); + } + }); + + onSetSelection(newSelection); + }; + + const handleMouseUp = () => { + setMarquee(null); + marqueeRef.current = null; + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [marquee, onSetSelection]); + + const handleResourceDoubleClick = (resource: WebDAVResource) => { + if (resource.isDirectory) { + const newPath = currentPath === '/' + ? `/${resource.name}` + : `${currentPath}/${resource.name}`; + onNavigate(newPath); + } else if (isPreviewable(resource.name)) { + if (isImageFile(resource.name)) { + onPreviewImage(resource.name); + } else { + onPreviewFile(resource.name); + } + } else { + onDownload(resource.name); + } + }; + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDraggingOver(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDraggingOver(false); + }, []); + + const handleDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDraggingOver(false); + + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + setIsUploading(true); + try { + await onUploadFiles(files); + } finally { + setIsUploading(false); + } + } + }, [onUploadFiles]); + + const handleFileInputChange = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length > 0) { + setIsUploading(true); + try { + await onUploadFiles(files); + } finally { + setIsUploading(false); + } + } + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleFolderInputChange = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length > 0) { + setIsUploading(true); + try { + await onUploadFolder(files); + } finally { + setIsUploading(false); + } + } + if (folderInputRef.current) { + folderInputRef.current.value = ''; + } + }; + + const contextMenuRef = useRef(null); + + const handleContextMenu = (e: React.MouseEvent, name: string) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, name }); + }; + + // Adjust context menu position to stay within viewport + useEffect(() => { + if (contextMenu && contextMenuRef.current) { + const menu = contextMenuRef.current; + const rect = menu.getBoundingClientRect(); + let { x, y } = contextMenu; + let adjusted = false; + + if (x + rect.width > window.innerWidth) { + x = window.innerWidth - rect.width - 8; + adjusted = true; + } + if (y + rect.height > window.innerHeight) { + y = window.innerHeight - rect.height - 8; + adjusted = true; + } + + if (adjusted) { + setContextMenu({ ...contextMenu, x, y }); + } + } + }, [contextMenu]); + + const handleContainerClick = () => { + if (contextMenu) setContextMenu(null); + if (emptyContextMenu) setEmptyContextMenu(null); + if (breadcrumbDropdown) setBreadcrumbDropdown(null); + }; + + const handleBreadcrumbRightClick = async (e: React.MouseEvent, crumbPath: string) => { + e.preventDefault(); + e.stopPropagation(); + const parentPath = crumbPath === '/' ? '/' : '/' + crumbPath.split('/').filter(Boolean).slice(0, -1).join('/') || '/'; + try { + const items = await listPath(parentPath === '/' ? '/' : parentPath); + const folders = items.filter(r => r.isDirectory); + setBreadcrumbDropdown({ path: parentPath, folders, x: e.clientX, y: e.clientY }); + } catch { + // ignore + } + }; + + const handleSortClick = (key: SortKey) => { + if (sortKey === key) { + setSortDir(d => d === "asc" ? "desc" : "asc"); + } else { + setSortKey(key); + setSortDir("asc"); + } + }; + + const SortIndicator = ({ column }: { column: SortKey }) => { + if (sortKey !== column) return null; + return sortDir === "asc" + ? + : ; + }; + + // Keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + // Don't capture when typing in inputs or dialogs + const tag = (e.target as HTMLElement)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + if (showNewFolder || renameTarget) return; + + // Ctrl+F / Cmd+F: toggle search + if ((e.ctrlKey || e.metaKey) && e.key === 'f') { + e.preventDefault(); + setShowSearch(v => !v); + return; + } + + // Ctrl+A / Cmd+A: select all + if ((e.ctrlKey || e.metaKey) && e.key === 'a') { + e.preventDefault(); + onSelectAll(); + return; + } + + // Ctrl+C / Cmd+C: copy selected + if ((e.ctrlKey || e.metaKey) && e.key === 'c') { + if (selectedResources.size > 0) { + e.preventDefault(); + onCopy([...selectedResources]); + } + return; + } + + // Ctrl+X / Cmd+X: cut selected + if ((e.ctrlKey || e.metaKey) && e.key === 'x') { + if (selectedResources.size > 0) { + e.preventDefault(); + onCut([...selectedResources]); + } + return; + } + + // Ctrl+V / Cmd+V: paste + if ((e.ctrlKey || e.metaKey) && e.key === 'v') { + if (clipboard) { + e.preventDefault(); + onPaste(); + } + return; + } + + // Escape: clear selection, close search + if (e.key === 'Escape') { + if (showSearch) { setShowSearch(false); setSearchQuery(""); } + else if (selectedResources.size > 0) onClearSelection(); + return; + } + + // Delete: delete selected + if (e.key === 'Delete') { + if (selectedResources.size === 1) { + onDelete([...selectedResources][0]); + } else if (selectedResources.size > 1) { + onBatchDelete([...selectedResources]); + } + return; + } + + // F2: rename selected (single) + if (e.key === 'F2' && selectedResources.size === 1) { + setRenameTarget([...selectedResources][0]); + return; + } + + // Enter: open selected directory or download selected file + if (e.key === 'Enter' && selectedResources.size === 1) { + const name = [...selectedResources][0]; + const resource = resources.find(r => r.name === name); + if (resource?.isDirectory) { + const newPath = currentPath === '/' + ? `/${resource.name}` + : `${currentPath}/${resource.name}`; + onNavigate(newPath); + } else if (resource) { + onDownload(resource.name); + } + return; + } + + // Backspace: navigate up + if (e.key === 'Backspace' && currentPath !== '/') { + handleNavigateUp(); + return; + } + }; + + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [selectedResources, resources, currentPath, showSearch, showNewFolder, renameTarget, onDelete, onBatchDelete, onSelectAll, onClearSelection, onNavigate, onDownload, onCut, onCopy, onPaste, clipboard, handleNavigateUp]); + + const allSelected = resources.length > 0 && selectedResources.size === resources.length; + const someSelected = selectedResources.size > 0 && !allSelected; + + return ( +
+ {/* Toolbar */} +
+ {/* Breadcrumbs */} + + + {/* Action buttons */} +
+ {selectedResources.size > 1 && ( + <> + + + + )} + {clipboard && ( + + )} + + + + + + + + +
+
+ + {/* Search bar */} + {showSearch && ( +
+ + setSearchQuery(e.target.value)} + placeholder={t("search_placeholder")} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + onKeyDown={(e) => { + if (e.key === 'Escape') { + setShowSearch(false); + setSearchQuery(""); + } + }} + /> + {searchQuery && ( + + )} +
+ )} + + {/* Hidden file input */} + + {/* Hidden folder input */} + )} + /> + + {/* Error display with retry */} + {error && ( +
+ + {error} + +
+ )} + + {/* Drag overlay */} + {isDraggingOver && ( +
+
+ +

{t("drop_files_here")}

+
+
+ )} + + {/* Upload progress */} + {uploadProgress && ( +
+
+ + + {t("uploading")} {uploadProgress.name} + {uploadProgress.totalFiles > 1 && ( + + ({uploadProgress.current}/{uploadProgress.totalFiles}) + + )} + + + {uploadProgress.total > 0 + ? `${Math.round((uploadProgress.loaded / uploadProgress.total) * 100)}%` + : "…"} + + +
+
+
0 + ? `${(uploadProgress.loaded / uploadProgress.total) * 100}%` + : '0%' }} + /> +
+
+ )} + + {/* File list */} +
+ {/* Favorites & Recent sidebar */} + {(favorites.length > 0 || recentFiles.length > 0) && ( +
+ {favorites.length > 0 && ( +
+

+ + {t("favorites")} +

+
+ {favorites.map((fav) => ( + + ))} +
+
+ )} + {recentFiles.length > 0 && ( +
+

+ + {t("recent")} +

+
+ {recentFiles.slice(0, 10).map((recent) => ( + + ))} +
+
+ )} +
+ )} +
+ {isLoading && resources.length === 0 ? ( + + + + + + + + + + + + + + + + +
+
+
+ {t("name")} +
+
{t("size")}{t("modified")} +
+ ) : resources.length === 0 && !searchQuery ? ( + { + setIsUploading(true); + try { + await onUploadFiles(files); + } finally { + setIsUploading(false); + } + }} + onCreateFolder={() => setShowNewFolder(true)} + onCreateTextFile={() => setShowNewTextFile(true)} + /> + ) : viewMode === "grid" ? ( + /* ======= GRID VIEW ======= */ +
+ {currentPath !== '/' && !searchQuery && ( +
+ + .. +
+ )} + {displayResources.length === 0 && searchQuery ? ( +

{t("no_results")}

+ ) : ( +
{ + if ((e.target as HTMLElement).closest('[data-resource]')) return; + e.preventDefault(); + setEmptyContextMenu({ x: e.clientX, y: e.clientY }); + }} + > + {displayResources.map((resource) => ( +
{ + const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name]; + e.dataTransfer.setData("application/x-webdav-names", JSON.stringify(names)); + e.dataTransfer.effectAllowed = "move"; + }} + onDragOver={(e) => { + if (resource.isDirectory) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragTarget(resource.name); + } + }} + onDragLeave={() => setDragTarget(null)} + onDrop={async (e) => { + e.preventDefault(); + setDragTarget(null); + if (!resource.isDirectory) return; + const raw = e.dataTransfer.getData("application/x-webdav-names"); + if (!raw) return; + const names: string[] = JSON.parse(raw); + if (names.includes(resource.name)) return; + await onMoveToFolder(names, resource.name); + }} + className={cn( + "flex flex-col items-center gap-2 p-3 rounded-lg cursor-pointer transition-colors relative group", + selectedResources.has(resource.name) + ? "bg-primary/10 ring-1 ring-primary/30" + : dragTarget === resource.name + ? "bg-primary/5 ring-1 ring-primary/40" + : "hover:bg-muted/50", + clipboard?.mode === "cut" && clipboard.names.includes(resource.name) && "opacity-50" + )} + onClick={(e) => handleResourceClick(resource, e)} + onDoubleClick={() => handleResourceDoubleClick(resource)} + onContextMenu={(e) => handleContextMenu(e, resource.name)} + > + onToggleSelect(resource.name)} + className="w-3.5 h-3.5 rounded border-border accent-primary cursor-pointer absolute top-2 left-2 opacity-0 group-hover:opacity-100 data-[checked=true]:opacity-100" + data-checked={selectedResources.has(resource.name)} + onClick={(e) => e.stopPropagation()} + /> + {isImageFile(resource.name) + ? + : getGridIcon(resource)} + + {resource.name} + +
+ ))} +
+ )} +
+ ) : ( + /* ======= LIST VIEW ======= */ + { + if ((e.target as HTMLElement).closest('tr[data-resource]')) return; + e.preventDefault(); + setEmptyContextMenu({ x: e.clientX, y: e.clientY }); + }} + > + + + + + + + + + {currentPath !== '/' && !searchQuery && ( + + + + )} + {displayResources.length === 0 && searchQuery ? ( + + + + ) : displayResources.map((resource) => ( + { + const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name]; + e.dataTransfer.setData("application/x-webdav-names", JSON.stringify(names)); + e.dataTransfer.effectAllowed = "move"; + }} + onDragOver={(e) => { + if (resource.isDirectory) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragTarget(resource.name); + } + }} + onDragLeave={() => setDragTarget(null)} + onDrop={async (e) => { + e.preventDefault(); + setDragTarget(null); + if (!resource.isDirectory) return; + const raw = e.dataTransfer.getData("application/x-webdav-names"); + if (!raw) return; + const names: string[] = JSON.parse(raw); + if (names.includes(resource.name)) return; + await onMoveToFolder(names, resource.name); + }} + className={cn( + "border-b border-border cursor-pointer transition-colors", + selectedResources.has(resource.name) + ? "bg-primary/10" + : dragTarget === resource.name + ? "bg-primary/5 ring-1 ring-primary/40" + : "hover:bg-muted/50", + clipboard?.mode === "cut" && clipboard.names.includes(resource.name) && "opacity-50" + )} + onClick={(e) => handleResourceClick(resource, e)} + onDoubleClick={() => handleResourceDoubleClick(resource)} + onContextMenu={(e) => handleContextMenu(e, resource.name)} + > + + + + + + ))} + +
+
+ { if (el) el.indeterminate = someSelected; }} + onChange={() => allSelected ? onClearSelection() : onSelectAll()} + className="w-4 h-4 rounded border-border accent-primary cursor-pointer" + onClick={(e) => e.stopPropagation()} + /> + +
+
+ + + + +
+
+
+ + .. +
+
+ + +
+ {t("no_results")} +
+
+ onToggleSelect(resource.name)} + className="w-4 h-4 rounded border-border accent-primary cursor-pointer shrink-0" + onClick={(e) => e.stopPropagation()} + /> + {isImageFile(resource.name) + ? + : getFileIcon(resource)} + {resource.name} +
+
+ {resource.isDirectory ? "—" : formatFileSize(resource.contentLength)} + + {formatDate(resource.lastModified)} + + +
+ )} + + {/* Context menu */} + {contextMenu && ( +
e.stopPropagation()} + > + {!resources.find(r => r.name === contextMenu.name)?.isDirectory && isPreviewable(contextMenu.name) && ( + + )} + {!resources.find(r => r.name === contextMenu.name)?.isDirectory && ( + + )} + + + {clipboard && ( + + )} + {!resources.find(r => r.name === contextMenu.name)?.isDirectory && ( + + )} +
+ + + +
+ )} + + {/* Empty-area context menu */} + {emptyContextMenu && ( +
e.stopPropagation()} + > + + + + + {clipboard && ( + <> +
+ + + )} +
+ +
+ )} + + {/* Breadcrumb dropdown */} + {breadcrumbDropdown && ( +
e.stopPropagation()} + > + {breadcrumbDropdown.folders.length === 0 ? ( +

{t("no_results")}

+ ) : ( + breadcrumbDropdown.folders.map((folder) => { + const folderPath = breadcrumbDropdown.path === '/' + ? `/${folder.name}` + : `${breadcrumbDropdown.path}/${folder.name}`; + return ( + + ); + }) + )} +
+ )} + + {/* Marquee selection rectangle */} + {marquee && ( +
+ )} +
+ + {/* Details sidebar */} + {showDetails && detailResource && ( +
+
+

{t("details")}

+ +
+
+ {detailResource.isDirectory + ? + : isImageFile(detailResource.name) + ? + : } +

{detailResource.name}

+
+
+
+
{t("type")}
+
{detailResource.isDirectory ? t("folder") : (detailResource.contentType || t("file"))}
+
+ {!detailResource.isDirectory && ( +
+
{t("size")}
+
{formatFileSize(detailResource.contentLength)}
+
+ )} + {detailResource.lastModified && ( +
+
{t("modified")}
+
{formatDate(detailResource.lastModified)}
+
+ )} + {detailResource.etag && ( +
+
ETag
+
{detailResource.etag}
+
+ )} +
+
{t("path")}
+
+ {currentPath === "/" ? `/${detailResource.name}` : `${currentPath}/${detailResource.name}`} +
+
+
+
+ )} +
+ + {/* New folder dialog */} + {showNewFolder && ( + { + await onCreateFolder(name); + setShowNewFolder(false); + }} + onCancel={() => setShowNewFolder(false)} + /> + )} + + {/* New text file dialog */} + {showNewTextFile && ( + { + await onCreateTextFile(name); + setShowNewTextFile(false); + }} + onCancel={() => setShowNewTextFile(false)} + /> + )} + + {/* Rename dialog */} + {renameTarget && ( + { + await onRename(renameTarget, newName); + setRenameTarget(null); + }} + onCancel={() => setRenameTarget(null)} + /> + )} +
+ ); +} diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx new file mode 100644 index 00000000..c021804d --- /dev/null +++ b/components/files/file-preview-modal.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { X, Download, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface FilePreviewModalProps { + name: string; + onClose: () => void; + onDownload: (name: string) => Promise; + getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>; +} + +const TEXT_EXTENSIONS = new Set([ + "txt", "md", "markdown", "json", "xml", "html", "htm", "css", "js", "ts", + "jsx", "tsx", "py", "rb", "java", "c", "cpp", "h", "hpp", "go", "rs", + "sh", "bash", "zsh", "yaml", "yml", "toml", "ini", "cfg", "conf", "env", + "log", "csv", "sql", "graphql", "vue", "svelte", "astro", "php", "pl", + "swift", "kt", "scala", "r", "lua", "vim", +]); + +function getFileType(name: string): "text" | "pdf" | "audio" | "video" | "markdown" | "unknown" { + const ext = name.split(".").pop()?.toLowerCase() || ""; + const baseName = name.toLowerCase(); + + if (ext === "md" || ext === "markdown") return "markdown"; + if (ext === "pdf") return "pdf"; + if (["mp3", "wav", "ogg", "flac", "aac", "m4a", "wma", "opus"].includes(ext)) return "audio"; + if (["mp4", "webm", "ogv", "mov", "avi", "mkv", "m4v"].includes(ext)) return "video"; + if (TEXT_EXTENSIONS.has(ext) || ["dockerfile", "makefile", "readme", "license", "changelog"].includes(baseName)) return "text"; + return "unknown"; +} + +function SimpleMarkdown({ content }: { content: string }) { + const lines = content.split("\n"); + const elements: React.ReactNode[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Headers + if (line.startsWith("### ")) { + elements.push(

{processInline(line.slice(4))}

); + } else if (line.startsWith("## ")) { + elements.push(

{processInline(line.slice(3))}

); + } else if (line.startsWith("# ")) { + elements.push(

{processInline(line.slice(2))}

); + } else if (line.startsWith("---") || line.startsWith("***")) { + elements.push(
); + } else if (line.startsWith("- ") || line.startsWith("* ")) { + elements.push(
  • {processInline(line.slice(2))}
  • ); + } else if (/^\d+\. /.test(line)) { + elements.push(
  • {processInline(line.replace(/^\d+\. /, ""))}
  • ); + } else if (line.startsWith("> ")) { + elements.push(
    {processInline(line.slice(2))}
    ); + } else if (line.startsWith("```")) { + // Code block - collect until closing ``` + const codeLines: string[] = []; + i++; + while (i < lines.length && !lines[i].startsWith("```")) { + codeLines.push(lines[i]); + i++; + } + elements.push( +
    +          {codeLines.join("\n")}
    +        
    + ); + } else if (line.trim() === "") { + elements.push(
    ); + } else { + elements.push(

    {processInline(line)}

    ); + } + } + + return
    {elements}
    ; +} + +function processInline(text: string): React.ReactNode { + // Process bold, italic, code inline + const parts: React.ReactNode[] = []; + let remaining = text; + let key = 0; + + while (remaining.length > 0) { + // Bold + const boldMatch = remaining.match(/\*\*(.+?)\*\*/); + // Inline code + const codeMatch = remaining.match(/`([^`]+)`/); + // Italic + const italicMatch = remaining.match(/(? (a!.match.index ?? 0) - (b!.match.index ?? 0)); + + if (matches.length === 0) { + parts.push(remaining); + break; + } + + const first = matches[0]!; + const idx = first.match.index ?? 0; + + if (idx > 0) { + parts.push(remaining.slice(0, idx)); + } + + if (first.type === "bold") { + parts.push({first.match[1]}); + } else if (first.type === "code") { + parts.push({first.match[1]}); + } else { + parts.push({first.match[1]}); + } + + remaining = remaining.slice(idx + first.match[0].length); + } + + return parts.length === 1 ? parts[0] : <>{parts}; +} + +export function FilePreviewModal({ name, onClose, onDownload, getFileContent }: FilePreviewModalProps) { + const t = useTranslations("files"); + const [content, setContent] = useState(null); + const [objectUrl, setObjectUrl] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + const fileType = getFileType(name); + + useEffect(() => { + let cancelled = false; + + async function load() { + try { + const { blob, contentType } = await getFileContent(name); + + if (cancelled) return; + + if (fileType === "text" || fileType === "markdown") { + const text = await blob.text(); + if (!cancelled) setContent(text); + } else { + const url = URL.createObjectURL(blob); + if (!cancelled) setObjectUrl(url); + } + } catch { + if (!cancelled) setError(true); + } finally { + if (!cancelled) setLoading(false); + } + } + + load(); + + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [name]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + return ( +
    +
    e.stopPropagation()}> +

    {name}

    +
    + + +
    +
    + +
    e.stopPropagation()}> + {loading && ( +
    + +
    + )} + + {error && ( +

    {t("preview_error")}

    + )} + + {!loading && !error && (fileType === "text") && content !== null && ( +
    +            {content}
    +          
    + )} + + {!loading && !error && fileType === "markdown" && content !== null && ( +
    + +
    + )} + + {!loading && !error && fileType === "pdf" && objectUrl && ( +