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:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user