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
This commit is contained in:
@@ -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<string>;
|
||||
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<void>;
|
||||
onUploadFiles: (files: File[]) => Promise<void>;
|
||||
onUploadFolder: (files: File[]) => Promise<void>;
|
||||
@@ -56,19 +62,21 @@ interface FileBrowserProps {
|
||||
onCopy: (names: string[]) => void;
|
||||
onPaste: () => Promise<void>;
|
||||
onMoveToFolder: (names: string[], targetFolder: string) => Promise<void>;
|
||||
onMoveToParent: (names: string[]) => Promise<void>;
|
||||
onPreviewImage: (name: string) => void;
|
||||
onPreviewFile: (name: string) => void;
|
||||
onShowDetails: (name: string) => void;
|
||||
onCreateTextFile: (name: string) => Promise<void>;
|
||||
onDuplicate: (name: string) => Promise<void>;
|
||||
getImageUrl: (name: string) => Promise<string>;
|
||||
listPath: (path: string) => Promise<WebDAVResource[]>;
|
||||
listPath: (path: string) => Promise<FileResource[]>;
|
||||
listByParentId: (parentId: string | null) => Promise<FileResource[]>;
|
||||
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 <PenTool className={`${cls} text-orange-500`} />;
|
||||
if (is3DFile(name)) return <Box className={`${cls} text-cyan-500`} />;
|
||||
if (isImageFile(name)) return <ImageIcon className={`${cls} text-emerald-500`} />;
|
||||
if (isAudioFile(name)) return <FileAudio className={`${cls} text-purple-500`} />;
|
||||
if (isVideoFile(name)) return <FileVideo className={`${cls} text-pink-500`} />;
|
||||
if (isArchiveFile(name)) return <FileArchive className={`${cls} text-amber-600`} />;
|
||||
if (isExecutableFile(name)) return <TerminalIcon className={`${cls} text-red-500`} />;
|
||||
if (isSpreadsheetFile(name)) return <FileSpreadsheet className={`${cls} text-green-600`} />;
|
||||
if (isPresentationFile(name)) return <Presentation className={`${cls} text-orange-600`} />;
|
||||
if (isFontFile(name)) return <TypeIcon className={`${cls} text-indigo-500`} />;
|
||||
if (isDatabaseFile(name)) return <Database className={`${cls} text-slate-500`} />;
|
||||
if (isPdfFile(name)) return <FileText className={`${cls} text-red-600`} />;
|
||||
if (isTextFile(name)) return <FileCode className={`${cls} text-yellow-600`} />;
|
||||
return <File className={`${cls} text-muted-foreground`} />;
|
||||
}
|
||||
|
||||
function getFileIcon(resource: FileResource) {
|
||||
if (resource.isDirectory) {
|
||||
return <Folder className="w-5 h-5 text-blue-500" />;
|
||||
}
|
||||
if (isImageFile(resource.name)) {
|
||||
return <ImageIcon className="w-5 h-5 text-emerald-500" />;
|
||||
}
|
||||
if (isAudioFile(resource.name)) {
|
||||
return <FileAudio className="w-5 h-5 text-purple-500" />;
|
||||
}
|
||||
if (isVideoFile(resource.name)) {
|
||||
return <FileVideo className="w-5 h-5 text-pink-500" />;
|
||||
}
|
||||
if (isTextFile(resource.name)) {
|
||||
return <FileText className="w-5 h-5 text-yellow-600" />;
|
||||
}
|
||||
return <File className="w-5 h-5 text-muted-foreground" />;
|
||||
return getFileIconByName(resource.name, "sm");
|
||||
}
|
||||
|
||||
function getGridIcon(resource: WebDAVResource) {
|
||||
function getGridIcon(resource: FileResource) {
|
||||
if (resource.isDirectory) {
|
||||
return <Folder className="w-10 h-10 text-blue-500" />;
|
||||
}
|
||||
if (isImageFile(resource.name)) {
|
||||
return <ImageIcon className="w-10 h-10 text-emerald-500" />;
|
||||
}
|
||||
if (isAudioFile(resource.name)) {
|
||||
return <FileAudio className="w-10 h-10 text-purple-500" />;
|
||||
}
|
||||
if (isVideoFile(resource.name)) {
|
||||
return <FileVideo className="w-10 h-10 text-pink-500" />;
|
||||
}
|
||||
if (isTextFile(resource.name)) {
|
||||
return <FileText className="w-10 h-10 text-yellow-600" />;
|
||||
}
|
||||
return <File className="w-10 h-10 text-muted-foreground" />;
|
||||
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<SortDir>("asc");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
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<FolderLayout>(() => 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<string | null>(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 */}
|
||||
<div className="flex-1 min-h-0 flex relative">
|
||||
{/* Favorites & Recent sidebar */}
|
||||
{(favorites.length > 0 || recentFiles.length > 0) && (
|
||||
{/* Folder tree sidebar (when layout is sidebar) */}
|
||||
{folderLayout === "sidebar" && (
|
||||
<>
|
||||
<FolderTreeSidebar
|
||||
currentPath={currentPath}
|
||||
onNavigate={onNavigate}
|
||||
listByParentId={listByParentId}
|
||||
width={sidebarWidth}
|
||||
isResizing={isResizing}
|
||||
/>
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { 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) && (
|
||||
<div className="w-48 border-r border-border bg-background overflow-y-auto shrink-0 hidden lg:block">
|
||||
{favorites.length > 0 && (
|
||||
<div className="p-3">
|
||||
@@ -1023,13 +1143,9 @@ export function FileBrowser({
|
||||
<div className="space-y-0.5">
|
||||
{recentFiles.slice(0, 10).map((recent) => (
|
||||
<button
|
||||
key={recent.path}
|
||||
onClick={() => {
|
||||
const dir = recent.path.substring(0, recent.path.lastIndexOf('/')) || '/';
|
||||
onNavigate(dir);
|
||||
}}
|
||||
key={recent.id}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-left"
|
||||
title={recent.path}
|
||||
title={recent.name}
|
||||
>
|
||||
<File className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{recent.name}</span>
|
||||
@@ -1069,7 +1185,7 @@ export function FileBrowser({
|
||||
<SkeletonRow />
|
||||
</tbody>
|
||||
</table>
|
||||
) : resources.length === 0 && !searchQuery ? (
|
||||
) : resources.length === 0 && !searchQuery && currentPath === '/' ? (
|
||||
<FileUploadArea
|
||||
onUpload={async (files: File[]) => {
|
||||
setIsUploading(true);
|
||||
@@ -1087,8 +1203,27 @@ export function FileBrowser({
|
||||
<div className="p-4" role="grid" aria-label={t("file_list")}>
|
||||
{currentPath !== '/' && !searchQuery && (
|
||||
<div
|
||||
className="inline-flex flex-col items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-muted/50 transition-colors w-28"
|
||||
className={cn(
|
||||
"inline-flex flex-col items-center gap-2 p-3 rounded-lg cursor-pointer transition-colors w-28",
|
||||
dragTarget === '..' ? "bg-primary/5 ring-1 ring-primary/40" : "hover:bg-muted/50"
|
||||
)}
|
||||
onClick={handleNavigateUp}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes("application/x-file-names")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragTarget('..');
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => setDragTarget(null)}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
setDragTarget(null);
|
||||
const raw = e.dataTransfer.getData("application/x-file-names");
|
||||
if (!raw) return;
|
||||
const names: string[] = JSON.parse(raw);
|
||||
await onMoveToParent(names);
|
||||
}}
|
||||
>
|
||||
<Folder className="w-10 h-10 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground truncate w-full text-center">..</span>
|
||||
@@ -1107,12 +1242,12 @@ export function FileBrowser({
|
||||
>
|
||||
{displayResources.map((resource) => (
|
||||
<div
|
||||
key={resource.href}
|
||||
key={resource.id}
|
||||
data-resource={resource.name}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name];
|
||||
e.dataTransfer.setData("application/x-webdav-names", JSON.stringify(names));
|
||||
e.dataTransfer.setData("application/x-file-names", JSON.stringify(names));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
@@ -1127,7 +1262,7 @@ export function FileBrowser({
|
||||
e.preventDefault();
|
||||
setDragTarget(null);
|
||||
if (!resource.isDirectory) return;
|
||||
const raw = e.dataTransfer.getData("application/x-webdav-names");
|
||||
const raw = e.dataTransfer.getData("application/x-file-names");
|
||||
if (!raw) return;
|
||||
const names: string[] = JSON.parse(raw);
|
||||
if (names.includes(resource.name)) return;
|
||||
@@ -1154,7 +1289,7 @@ export function FileBrowser({
|
||||
data-checked={selectedResources.has(resource.name)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{isImageFile(resource.name)
|
||||
{showThumbnails && isImageFile(resource.name)
|
||||
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="lg" />
|
||||
: getGridIcon(resource)}
|
||||
<span className="text-xs truncate w-full text-center" title={resource.name}>
|
||||
@@ -1212,8 +1347,27 @@ export function FileBrowser({
|
||||
<tbody>
|
||||
{currentPath !== '/' && !searchQuery && (
|
||||
<tr
|
||||
className="border-b border-border hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
className={cn(
|
||||
"border-b border-border cursor-pointer transition-colors",
|
||||
dragTarget === '..' ? "bg-primary/5 ring-1 ring-primary/40" : "hover:bg-muted/50"
|
||||
)}
|
||||
onClick={handleNavigateUp}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes("application/x-file-names")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragTarget('..');
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => setDragTarget(null)}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
setDragTarget(null);
|
||||
const raw = e.dataTransfer.getData("application/x-file-names");
|
||||
if (!raw) return;
|
||||
const names: string[] = JSON.parse(raw);
|
||||
await onMoveToParent(names);
|
||||
}}
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1235,13 +1389,13 @@ export function FileBrowser({
|
||||
</tr>
|
||||
) : displayResources.map((resource) => (
|
||||
<tr
|
||||
key={resource.href}
|
||||
key={resource.id}
|
||||
data-resource={resource.name}
|
||||
aria-selected={selectedResources.has(resource.name)}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name];
|
||||
e.dataTransfer.setData("application/x-webdav-names", JSON.stringify(names));
|
||||
e.dataTransfer.setData("application/x-file-names", JSON.stringify(names));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
@@ -1256,7 +1410,7 @@ export function FileBrowser({
|
||||
e.preventDefault();
|
||||
setDragTarget(null);
|
||||
if (!resource.isDirectory) return;
|
||||
const raw = e.dataTransfer.getData("application/x-webdav-names");
|
||||
const raw = e.dataTransfer.getData("application/x-file-names");
|
||||
if (!raw) return;
|
||||
const names: string[] = JSON.parse(raw);
|
||||
if (names.includes(resource.name)) return;
|
||||
@@ -1284,7 +1438,7 @@ export function FileBrowser({
|
||||
className="w-4 h-4 rounded border-border accent-primary cursor-pointer shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{isImageFile(resource.name)
|
||||
{showThumbnails && isImageFile(resource.name)
|
||||
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="sm" />
|
||||
: getFileIcon(resource)}
|
||||
<span className="truncate">{resource.name}</span>
|
||||
@@ -1525,10 +1679,10 @@ export function FileBrowser({
|
||||
: `${breadcrumbDropdown.path}/${folder.name}`;
|
||||
return (
|
||||
<button
|
||||
key={folder.href}
|
||||
key={folder.id}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
onClick={() => {
|
||||
onNavigate(folderPath);
|
||||
onNavigate(folderPath, folder.id);
|
||||
setBreadcrumbDropdown(null);
|
||||
}}
|
||||
>
|
||||
@@ -1567,9 +1721,7 @@ export function FileBrowser({
|
||||
<div className="flex flex-col items-center gap-3 mb-4">
|
||||
{detailResource.isDirectory
|
||||
? <Folder className="w-12 h-12 text-blue-500" />
|
||||
: isImageFile(detailResource.name)
|
||||
? <ImageIcon className="w-12 h-12 text-emerald-500" />
|
||||
: <File className="w-12 h-12 text-muted-foreground" />}
|
||||
: getFileIconByName(detailResource.name, "lg")}
|
||||
<p className="text-sm font-medium text-center break-all">{detailResource.name}</p>
|
||||
</div>
|
||||
<dl className="space-y-3 text-sm">
|
||||
@@ -1589,12 +1741,6 @@ export function FileBrowser({
|
||||
<dd className="tabular-nums">{formatDate(detailResource.lastModified)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{detailResource.etag && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-xs">ETag</dt>
|
||||
<dd className="text-xs break-all font-mono">{detailResource.etag}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-xs">{t("path")}</dt>
|
||||
<dd className="text-xs break-all font-mono">
|
||||
|
||||
@@ -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<HTMLDivElement>(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<FilesSettings>) => {
|
||||
const next = { ...settings, ...patch };
|
||||
onSettingsChange(next);
|
||||
saveFilesSettings(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("settings_title")}
|
||||
className="bg-background border border-border rounded-lg shadow-lg w-full max-w-md mx-4 max-h-[80vh] flex flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">{t("settings_title")}</h2>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4 space-y-6">
|
||||
<SettingsSection title={t("settings_display")}>
|
||||
<SettingItem label={t("settings_folder_layout")} description={t("settings_folder_layout_desc")}>
|
||||
<RadioGroup
|
||||
value={settings.folderLayout}
|
||||
onChange={(v) => update({ folderLayout: v as FolderLayout })}
|
||||
options={[
|
||||
{ value: "inline", label: t("settings_folder_layout_inline") },
|
||||
{ value: "sidebar", label: t("settings_folder_layout_sidebar") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("settings_default_view")} description={t("settings_default_view_desc")}>
|
||||
<RadioGroup
|
||||
value={settings.defaultViewMode}
|
||||
onChange={(v) => update({ defaultViewMode: v as "list" | "grid" })}
|
||||
options={[
|
||||
{ value: "list", label: t("list_view") },
|
||||
{ value: "grid", label: t("grid_view") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("settings_default_sort")} description={t("settings_default_sort_desc")}>
|
||||
<RadioGroup
|
||||
value={settings.defaultSortKey}
|
||||
onChange={(v) => update({ defaultSortKey: v as "name" | "size" | "modified" })}
|
||||
options={[
|
||||
{ value: "name", label: t("name") },
|
||||
{ value: "size", label: t("size") },
|
||||
{ value: "modified", label: t("modified") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("settings_sort_direction")} description={t("settings_sort_direction_desc")}>
|
||||
<RadioGroup
|
||||
value={settings.defaultSortDir}
|
||||
onChange={(v) => update({ defaultSortDir: v as "asc" | "desc" })}
|
||||
options={[
|
||||
{ value: "asc", label: t("settings_ascending") },
|
||||
{ value: "desc", label: t("settings_descending") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings_icons")}>
|
||||
<SettingItem label={t("settings_show_icons")} description={t("settings_show_icons_desc")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.showIcons}
|
||||
onChange={(v) => update({ showIcons: v })}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("settings_colored_icons")} description={t("settings_colored_icons_desc")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.coloredIcons}
|
||||
onChange={(v) => update({ coloredIcons: v })}
|
||||
disabled={!settings.showIcons}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("settings_show_thumbnails")} description={t("settings_show_thumbnails_desc")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.showThumbnails}
|
||||
onChange={(v) => update({ showThumbnails: v })}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings_behavior")}>
|
||||
<SettingItem label={t("settings_show_hidden")} description={t("settings_show_hidden_desc")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.showHiddenFiles}
|
||||
onChange={(v) => update({ showHiddenFiles: v })}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<FileResource[]>;
|
||||
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<FolderNode[] | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(["root"]));
|
||||
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
|
||||
// Cache: parentId (or "root") -> FolderNode[]
|
||||
const [childrenCache, setChildrenCache] = useState<Map<string, FolderNode[]>>(new Map());
|
||||
// Map folder path -> id for reverse lookup
|
||||
const pathToIdRef = useRef<Map<string, string>>(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 (
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border bg-secondary overflow-hidden shrink-0 hidden lg:flex flex-col",
|
||||
!isResizing && "transition-[width] duration-300"
|
||||
)}
|
||||
style={{ width: `${width}px` }}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{/* Root / Home entry */}
|
||||
<div
|
||||
style={{ paddingBlock: "var(--density-sidebar-py)" }}
|
||||
className={cn(
|
||||
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 px-2",
|
||||
currentPath === "/"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted text-foreground",
|
||||
"font-medium"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleFolderClick("/", null)}
|
||||
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
|
||||
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
|
||||
>
|
||||
<Home className={cn("w-4 h-4 flex-shrink-0 mr-2 transition-colors")} />
|
||||
<span className="truncate">{t("breadcrumb_root")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Folder tree */}
|
||||
{rootChildren === null && loadingIds.has("root") ? (
|
||||
<div className="px-3 py-2 space-y-2">
|
||||
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 w-20 bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 w-28 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
) : (
|
||||
rootChildren?.map(folder => (
|
||||
<FolderTreeItem
|
||||
key={folder.id}
|
||||
node={folder}
|
||||
depth={0}
|
||||
currentPath={currentPath}
|
||||
expandedIds={expandedIds}
|
||||
loadingIds={loadingIds}
|
||||
childrenCache={childrenCache}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onFolderClick={handleFolderClick}
|
||||
onLoadChildren={loadChildren}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderTreeItem({
|
||||
node,
|
||||
depth,
|
||||
currentPath,
|
||||
expandedIds,
|
||||
loadingIds,
|
||||
childrenCache,
|
||||
onToggleExpand,
|
||||
onFolderClick,
|
||||
onLoadChildren,
|
||||
}: {
|
||||
node: FolderNode;
|
||||
depth: number;
|
||||
currentPath: string;
|
||||
expandedIds: Set<string>;
|
||||
loadingIds: Set<string>;
|
||||
childrenCache: Map<string, FolderNode[]>;
|
||||
onToggleExpand: (folderId: string, folderPath: string) => void;
|
||||
onFolderClick: (path: string, id: string | null) => void;
|
||||
onLoadChildren: (parentId: string, parentPath: string) => Promise<void>;
|
||||
}) {
|
||||
const isExpanded = expandedIds.has(node.id);
|
||||
const isSelected = currentPath === node.path;
|
||||
const isLoading = loadingIds.has(node.id);
|
||||
const children = childrenCache.get(node.id);
|
||||
const hasChildren = children !== undefined && children.length > 0;
|
||||
const indentPx = depth * 16;
|
||||
const Icon = isExpanded && hasChildren ? FolderOpen : Folder;
|
||||
|
||||
// Eagerly load children on mount to know if subfolders exist
|
||||
useEffect(() => {
|
||||
if (children === undefined && !loadingIds.has(node.id)) {
|
||||
onLoadChildren(node.id, node.path);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{ paddingBlock: "var(--density-sidebar-py)" }}
|
||||
className={cn(
|
||||
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 px-2",
|
||||
isSelected
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted text-foreground",
|
||||
depth === 0 && "font-medium"
|
||||
)}
|
||||
>
|
||||
{/* Expand/collapse chevron */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand(node.id, node.path);
|
||||
}}
|
||||
className={cn(
|
||||
"p-0.5 rounded mr-1 transition-all duration-200",
|
||||
"hover:bg-muted active:bg-accent"
|
||||
)}
|
||||
style={{ marginLeft: `${indentPx}px` }}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* Folder name */}
|
||||
<button
|
||||
onClick={() => onFolderClick(node.path, node.id)}
|
||||
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
|
||||
style={{
|
||||
paddingBlock: "var(--density-sidebar-py)",
|
||||
paddingLeft: hasChildren ? "4px" : `${indentPx + 24}px`,
|
||||
}}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"w-4 h-4 flex-shrink-0 mr-2 transition-colors",
|
||||
isExpanded && hasChildren && "text-primary",
|
||||
!hasChildren && depth > 0 && "text-muted-foreground"
|
||||
)} />
|
||||
<span className="truncate">{node.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{isExpanded && children && (
|
||||
<div className="relative">
|
||||
{children.map(child => (
|
||||
<FolderTreeItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
currentPath={currentPath}
|
||||
expandedIds={expandedIds}
|
||||
loadingIds={loadingIds}
|
||||
childrenCache={childrenCache}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onFolderClick={onFolderClick}
|
||||
onLoadChildren={onLoadChildren}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section";
|
||||
import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
|
||||
export function FilesSettingsComponent() {
|
||||
const t = useTranslations("settings.files");
|
||||
const [settings, setSettings] = useState<FilesSettings>(loadFilesSettings);
|
||||
|
||||
// Listen for external changes (e.g. if file-browser updates settings)
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === "files-settings") {
|
||||
setSettings(loadFilesSettings());
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
const update = useCallback((patch: Partial<FilesSettings>) => {
|
||||
setSettings(prev => {
|
||||
const next = { ...prev, ...patch };
|
||||
saveFilesSettings(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsSection title={t("display.title")} description={t("display.description")}>
|
||||
<SettingItem label={t("folder_layout.label")} description={t("folder_layout.description")}>
|
||||
<RadioGroup
|
||||
value={settings.folderLayout}
|
||||
onChange={(v) => update({ folderLayout: v as FolderLayout })}
|
||||
options={[
|
||||
{ value: "inline", label: t("folder_layout.inline") },
|
||||
{ value: "sidebar", label: t("folder_layout.sidebar") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("default_view.label")} description={t("default_view.description")}>
|
||||
<RadioGroup
|
||||
value={settings.defaultViewMode}
|
||||
onChange={(v) => update({ defaultViewMode: v as "list" | "grid" })}
|
||||
options={[
|
||||
{ value: "list", label: t("default_view.list") },
|
||||
{ value: "grid", label: t("default_view.grid") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("default_sort.label")} description={t("default_sort.description")}>
|
||||
<RadioGroup
|
||||
value={settings.defaultSortKey}
|
||||
onChange={(v) => update({ defaultSortKey: v as "name" | "size" | "modified" })}
|
||||
options={[
|
||||
{ value: "name", label: t("default_sort.name") },
|
||||
{ value: "size", label: t("default_sort.size") },
|
||||
{ value: "modified", label: t("default_sort.modified") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("sort_direction.label")} description={t("sort_direction.description")}>
|
||||
<RadioGroup
|
||||
value={settings.defaultSortDir}
|
||||
onChange={(v) => update({ defaultSortDir: v as "asc" | "desc" })}
|
||||
options={[
|
||||
{ value: "asc", label: t("sort_direction.ascending") },
|
||||
{ value: "desc", label: t("sort_direction.descending") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("icons.title")} description={t("icons.description")}>
|
||||
<SettingItem label={t("show_icons.label")} description={t("show_icons.description")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.showIcons}
|
||||
onChange={(v) => update({ showIcons: v })}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("colored_icons.label")} description={t("colored_icons.description")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.coloredIcons}
|
||||
onChange={(v) => update({ coloredIcons: v })}
|
||||
disabled={!settings.showIcons}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("show_thumbnails.label")} description={t("show_thumbnails.description")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.showThumbnails}
|
||||
onChange={(v) => update({ showThumbnails: v })}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("behavior.title")} description={t("behavior.description")}>
|
||||
<SettingItem label={t("show_hidden.label")} description={t("show_hidden.description")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.showHiddenFiles}
|
||||
onChange={(v) => update({ showHiddenFiles: v })}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user