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:
Linus Rath
2026-03-16 01:05:59 +01:00
parent 73d6e581b7
commit 716db8d003
18 changed files with 2984 additions and 202 deletions
+79 -39
View File
@@ -9,7 +9,7 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useWebDAVStore } from "@/stores/webdav-store"; import { useFileStore } from "@/stores/file-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail"; import { NavigationRail } from "@/components/layout/navigation-rail";
@@ -17,11 +17,13 @@ import { useIsMobile } from "@/hooks/use-media-query";
import { FileBrowser } from "@/components/files/file-browser"; import { FileBrowser } from "@/components/files/file-browser";
import { ImagePreviewModal } from "@/components/files/image-preview-modal"; import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
export default function FilesPage() { export default function FilesPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations("files"); const t = useTranslations("files");
const { isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore(); const { quota, isPushConnected } = useEmailStore();
const { const {
@@ -29,13 +31,14 @@ export default function FilesPage() {
resources, resources,
isLoading, isLoading,
error, error,
supportsWebDAV, supportsFiles,
selectedResources, selectedResources,
uploadProgress, uploadProgress,
clipboard, clipboard,
initClient, initClient,
checkSupport, checkSupport,
navigate, navigate,
navigateByPath,
refresh, refresh,
createDirectory, createDirectory,
uploadFile, uploadFile,
@@ -51,6 +54,7 @@ export default function FilesPage() {
duplicateResource, duplicateResource,
downloadResources, downloadResources,
moveToFolder, moveToFolder,
moveToParent,
cutResources, cutResources,
copyResources, copyResources,
pasteResources, pasteResources,
@@ -60,6 +64,7 @@ export default function FilesPage() {
clearSelection, clearSelection,
setSelection, setSelection,
listPath, listPath,
listByParentId,
favorites, favorites,
recentFiles, recentFiles,
toggleFavorite, toggleFavorite,
@@ -67,10 +72,23 @@ export default function FilesPage() {
cancelUpload, cancelUpload,
undoLastAction, undoLastAction,
lastAction, lastAction,
} = useWebDAVStore(); } = useFileStore();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
const hasFetched = useRef(false); const hasFetched = useRef(false);
// Sync folderLayout when settings change
useEffect(() => {
const reload = () => setFolderLayout(loadFilesSettings().folderLayout);
const handleStorage = (e: StorageEvent) => { if (e.key === "files-settings") reload(); };
window.addEventListener("storage", handleStorage);
window.addEventListener("files-settings-changed", reload);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener("files-settings-changed", reload);
};
}, []);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewFile, setPreviewFile] = useState<string | null>(null); const [previewFile, setPreviewFile] = useState<string | null>(null);
@@ -94,34 +112,35 @@ export default function FilesPage() {
} }
}, [initialCheckDone, isAuthenticated, authLoading, router]); }, [initialCheckDone, isAuthenticated, authLoading, router]);
// Initialize WebDAV client // Initialize JMAP files client
useEffect(() => { useEffect(() => {
if (isAuthenticated && !hasFetched.current) { if (isAuthenticated && client && !hasFetched.current) {
hasFetched.current = true; hasFetched.current = true;
initClient(); initClient(client);
} }
}, [isAuthenticated, initClient]); }, [isAuthenticated, client, initClient]);
// Check support and load root after client is initialized // Check support and load root after client is initialized
const { webdavClient } = useWebDAVStore(); const storeClient = useFileStore(s => s.client);
useEffect(() => { useEffect(() => {
if (webdavClient && supportsWebDAV === null) { if (storeClient && supportsFiles === null) {
checkSupport().then((supported) => { checkSupport().then((supported) => {
if (supported) { if (supported) {
let initialPath = '/'; navigate(null);
try {
const saved = localStorage.getItem('webdav-last-path');
if (saved) initialPath = saved;
} catch { /* ignore */ }
navigate(initialPath);
} }
}); });
} }
}, [webdavClient, supportsWebDAV, checkSupport, navigate]); }, [storeClient, supportsFiles, checkSupport, navigate]);
const handleNavigate = useCallback((path: string) => { const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
navigate(path); if (resourceId !== undefined) {
}, [navigate]); // Direct ID-based navigation (directory click, breadcrumb dropdown folder)
navigate(resourceId, path.split('/').pop() || '');
} else {
// Path-based navigation (breadcrumbs, favorites, recent files)
navigateByPath(path);
}
}, [navigate, navigateByPath]);
const handleCreateFolder = useCallback(async (name: string) => { const handleCreateFolder = useCallback(async (name: string) => {
try { try {
@@ -225,15 +244,20 @@ export default function FilesPage() {
} }
}, [renameResource, t, handleUndo]); }, [renameResource, t, handleUndo]);
const findResourceId = useCallback((name: string) => {
const r = resources.find(res => res.name === name);
return r?.id || name;
}, [resources]);
const handleDownload = useCallback(async (name: string) => { const handleDownload = useCallback(async (name: string) => {
try { try {
await downloadResource(name); await downloadResource(name);
addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name); addRecentFile(name, findResourceId(name));
} catch (err) { } catch (err) {
console.error("Failed to download:", err); console.error("Failed to download:", err);
toast.error(t("download_error")); toast.error(t("download_error"));
} }
}, [downloadResource, addRecentFile, currentPath, t]); }, [downloadResource, addRecentFile, findResourceId, t]);
const handleBatchDownload = useCallback(async (names: string[]) => { const handleBatchDownload = useCallback(async (names: string[]) => {
try { try {
@@ -276,6 +300,18 @@ export default function FilesPage() {
} }
}, [moveToFolder, t, handleUndo]); }, [moveToFolder, t, handleUndo]);
const handleMoveToParent = useCallback(async (names: string[]) => {
try {
await moveToParent(names);
toast.success(t("move_success", { count: names.length }), {
action: { label: t("undo"), onClick: handleUndo },
});
} catch (err) {
console.error("Failed to move:", err);
toast.error(t("move_error"));
}
}, [moveToParent, t, handleUndo]);
const handlePaste = useCallback(async () => { const handlePaste = useCallback(async () => {
try { try {
await pasteResources(); await pasteResources();
@@ -290,13 +326,13 @@ export default function FilesPage() {
const handlePreviewImage = useCallback((name: string) => { const handlePreviewImage = useCallback((name: string) => {
setPreviewImage(name); setPreviewImage(name);
addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name); addRecentFile(name, findResourceId(name));
}, [addRecentFile, currentPath]); }, [addRecentFile, findResourceId]);
const handlePreviewFile = useCallback((name: string) => { const handlePreviewFile = useCallback((name: string) => {
setPreviewFile(name); setPreviewFile(name);
addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name); addRecentFile(name, findResourceId(name));
}, [addRecentFile, currentPath]); }, [addRecentFile, findResourceId]);
const handleShowDetails = useCallback((name: string) => { const handleShowDetails = useCallback((name: string) => {
setDetailName(name); setDetailName(name);
@@ -325,22 +361,24 @@ export default function FilesPage() {
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<div className="flex-1 min-w-0 flex flex-col"> <div className="flex-1 min-w-0 flex flex-col">
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}> {folderLayout !== "sidebar" && (
<div className="flex items-center justify-between"> <div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
<Button <div className="flex items-center justify-between">
variant="ghost" <Button
size="sm" variant="ghost"
onClick={() => router.push("/")} size="sm"
className="justify-start" onClick={() => router.push("/")}
> className="justify-start"
<ArrowLeft className="w-4 h-4 mr-2" /> >
{t("title")} <ArrowLeft className="w-4 h-4 mr-2" />
</Button> {t("title")}
</Button>
</div>
</div> </div>
</div> )}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
{supportsWebDAV === false ? ( {supportsFiles === false ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">{t("not_available")}</p> <p className="text-sm text-muted-foreground">{t("not_available")}</p>
</div> </div>
@@ -373,6 +411,7 @@ export default function FilesPage() {
onCopy={copyResources} onCopy={copyResources}
onPaste={handlePaste} onPaste={handlePaste}
onMoveToFolder={handleMoveToFolder} onMoveToFolder={handleMoveToFolder}
onMoveToParent={handleMoveToParent}
onPreviewImage={handlePreviewImage} onPreviewImage={handlePreviewImage}
onPreviewFile={handlePreviewFile} onPreviewFile={handlePreviewFile}
onShowDetails={handleShowDetails} onShowDetails={handleShowDetails}
@@ -380,6 +419,7 @@ export default function FilesPage() {
onDuplicate={handleDuplicate} onDuplicate={handleDuplicate}
getImageUrl={getImageUrl} getImageUrl={getImageUrl}
listPath={listPath} listPath={listPath}
listByParentId={listByParentId}
favorites={favorites} favorites={favorites}
recentFiles={recentFiles} recentFiles={recentFiles}
onToggleFavorite={toggleFavorite} onToggleFavorite={toggleFavorite}
+5 -1
View File
@@ -18,6 +18,7 @@ import { AdvancedSettings } from '@/components/settings/advanced-settings';
import { FolderSettings } from '@/components/settings/folder-settings'; import { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings'; import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-settings'; import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store'; import { useEmailStore } from '@/stores/email-store';
import { useIsDesktop } from '@/hooks/use-media-query'; import { useIsDesktop } from '@/hooks/use-media-query';
@@ -25,7 +26,7 @@ import { NavigationRail } from '@/components/layout/navigation-rail';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'folders' | 'keywords' | 'advanced'; type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'advanced';
export default function SettingsPage() { export default function SettingsPage() {
const router = useRouter(); const router = useRouter();
@@ -66,6 +67,7 @@ export default function SettingsPage() {
const supportsVacation = client?.supportsVacationResponse() ?? false; const supportsVacation = client?.supportsVacationResponse() ?? false;
const supportsCalendar = client?.supportsCalendars() ?? false; const supportsCalendar = client?.supportsCalendars() ?? false;
const supportsSieve = client?.supportsSieve() ?? false; const supportsSieve = client?.supportsSieve() ?? false;
const supportsFiles = client?.supportsFiles() ?? false;
const tabs: { id: Tab; label: string }[] = [ const tabs: { id: Tab; label: string }[] = [
{ id: 'appearance', label: t('tabs.appearance') }, { id: 'appearance', label: t('tabs.appearance') },
@@ -79,6 +81,7 @@ export default function SettingsPage() {
{ id: 'templates', label: t('tabs.templates') }, { id: 'templates', label: t('tabs.templates') },
{ id: 'folders', label: t('tabs.folders') }, { id: 'folders', label: t('tabs.folders') },
{ id: 'keywords', label: t('tabs.keywords') }, { id: 'keywords', label: t('tabs.keywords') },
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files') }] : []),
{ id: 'advanced', label: t('tabs.advanced') }, { id: 'advanced', label: t('tabs.advanced') },
]; ];
@@ -105,6 +108,7 @@ export default function SettingsPage() {
{activeTab === 'templates' && <TemplateSettings />} {activeTab === 'templates' && <TemplateSettings />}
{activeTab === 'folders' && <FolderSettings />} {activeTab === 'folders' && <FolderSettings />}
{activeTab === 'keywords' && <KeywordSettings />} {activeTab === 'keywords' && <KeywordSettings />}
{activeTab === 'files' && <FilesSettingsComponent />}
{activeTab === 'advanced' && <AdvancedSettings />} {activeTab === 'advanced' && <AdvancedSettings />}
</> </>
); );
+224 -78
View File
@@ -9,13 +9,19 @@ import {
Copy, Clipboard, Scissors, Info, Image as ImageIcon, Copy, Clipboard, Scissors, Info, Image as ImageIcon,
FilePlus, CopyPlus, FileText, FileAudio, FileVideo, FilePlus, CopyPlus, FileText, FileAudio, FileVideo,
AlertCircle, Star, Clock, FolderUp, AlertCircle, Star, Clock, FolderUp,
FileArchive, FileSpreadsheet, Presentation, FileCode,
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { NewFolderDialog } from "@/components/files/new-folder-dialog"; import { NewFolderDialog } from "@/components/files/new-folder-dialog";
import { RenameDialog } from "@/components/files/rename-dialog"; import { RenameDialog } from "@/components/files/rename-dialog";
import { FileUploadArea } from "@/components/files/file-upload-area"; 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 SortKey = "name" | "size" | "modified";
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
@@ -23,20 +29,20 @@ type ViewMode = "list" | "grid";
interface ClipboardState { interface ClipboardState {
mode: "cut" | "copy"; mode: "cut" | "copy";
paths: string[]; ids: string[];
names: string[]; names: string[];
sourcePath: string; sourceParentId: string | null;
} }
interface FileBrowserProps { interface FileBrowserProps {
currentPath: string; currentPath: string;
resources: WebDAVResource[]; resources: FileResource[];
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
selectedResources: Set<string>; selectedResources: Set<string>;
uploadProgress: { name: string; loaded: number; total: number; current: number; totalFiles: number } | null; uploadProgress: { name: string; loaded: number; total: number; current: number; totalFiles: number } | null;
clipboard: ClipboardState | null; clipboard: ClipboardState | null;
onNavigate: (path: string) => void; onNavigate: (path: string, resourceId?: string | null) => void;
onCreateFolder: (name: string) => Promise<void>; onCreateFolder: (name: string) => Promise<void>;
onUploadFiles: (files: File[]) => Promise<void>; onUploadFiles: (files: File[]) => Promise<void>;
onUploadFolder: (files: File[]) => Promise<void>; onUploadFolder: (files: File[]) => Promise<void>;
@@ -56,19 +62,21 @@ interface FileBrowserProps {
onCopy: (names: string[]) => void; onCopy: (names: string[]) => void;
onPaste: () => Promise<void>; onPaste: () => Promise<void>;
onMoveToFolder: (names: string[], targetFolder: string) => Promise<void>; onMoveToFolder: (names: string[], targetFolder: string) => Promise<void>;
onMoveToParent: (names: string[]) => Promise<void>;
onPreviewImage: (name: string) => void; onPreviewImage: (name: string) => void;
onPreviewFile: (name: string) => void; onPreviewFile: (name: string) => void;
onShowDetails: (name: string) => void; onShowDetails: (name: string) => void;
onCreateTextFile: (name: string) => Promise<void>; onCreateTextFile: (name: string) => Promise<void>;
onDuplicate: (name: string) => Promise<void>; onDuplicate: (name: string) => Promise<void>;
getImageUrl: (name: string) => Promise<string>; getImageUrl: (name: string) => Promise<string>;
listPath: (path: string) => Promise<WebDAVResource[]>; listPath: (path: string) => Promise<FileResource[]>;
listByParentId: (parentId: string | null) => Promise<FileResource[]>;
favorites: string[]; favorites: string[];
recentFiles: { name: string; path: string; timestamp: number }[]; recentFiles: { name: string; id: string; timestamp: number }[];
onToggleFavorite: (path: string) => void; onToggleFavorite: (path: string) => void;
showDetails: boolean; showDetails: boolean;
onToggleDetails: () => void; onToggleDetails: () => void;
detailResource: WebDAVResource | null; detailResource: FileResource | null;
} }
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]); 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); 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 { function isPreviewable(name: string): boolean {
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name); return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
} }
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB 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) { if (resource.isDirectory) {
return <Folder className="w-5 h-5 text-blue-500" />; return <Folder className="w-5 h-5 text-blue-500" />;
} }
if (isImageFile(resource.name)) { return getFileIconByName(resource.name, "sm");
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" />;
} }
function getGridIcon(resource: WebDAVResource) { function getGridIcon(resource: FileResource) {
if (resource.isDirectory) { if (resource.isDirectory) {
return <Folder className="w-10 h-10 text-blue-500" />; return <Folder className="w-10 h-10 text-blue-500" />;
} }
if (isImageFile(resource.name)) { return getFileIconByName(resource.name, "lg");
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" />;
} }
function Thumbnail({ name, getImageUrl: fetchUrl, size = "sm" }: { function Thumbnail({ name, getImageUrl: fetchUrl, size = "sm" }: {
@@ -246,6 +306,7 @@ export function FileBrowser({
onCopy, onCopy,
onPaste, onPaste,
onMoveToFolder, onMoveToFolder,
onMoveToParent,
onPreviewImage, onPreviewImage,
onPreviewFile, onPreviewFile,
onShowDetails, onShowDetails,
@@ -253,6 +314,7 @@ export function FileBrowser({
onDuplicate, onDuplicate,
getImageUrl, getImageUrl,
listPath, listPath,
listByParentId,
favorites, favorites,
recentFiles, recentFiles,
onToggleFavorite, onToggleFavorite,
@@ -275,14 +337,44 @@ export function FileBrowser({
const [sortDir, setSortDir] = useState<SortDir>("asc"); const [sortDir, setSortDir] = useState<SortDir>("asc");
const [viewMode, setViewMode] = useState<ViewMode>(() => { const [viewMode, setViewMode] = useState<ViewMode>(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
return (localStorage.getItem("webdav-view-mode") as ViewMode) || "list"; return (localStorage.getItem("files-view-mode") as ViewMode) || "list";
} }
return "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); 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<{ const [breadcrumbDropdown, setBreadcrumbDropdown] = useState<{
path: string; path: string;
folders: WebDAVResource[]; folders: FileResource[];
x: number; x: number;
y: number; y: number;
} | null>(null); } | null>(null);
@@ -315,15 +407,19 @@ export function FileBrowser({
// Persist view mode // Persist view mode
const handleViewModeChange = useCallback((mode: ViewMode) => { const handleViewModeChange = useCallback((mode: ViewMode) => {
setViewMode(mode); setViewMode(mode);
localStorage.setItem("webdav-view-mode", mode); localStorage.setItem("files-view-mode", mode);
}, []); }, []);
// Filter and sort resources // Filter and sort resources
const displayResources = useMemo(() => { const displayResources = useMemo(() => {
let filtered = resources; 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) { if (searchQuery) {
const q = searchQuery.toLowerCase(); 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) => { const sorted = [...filtered].sort((a, b) => {
@@ -345,7 +441,7 @@ export function FileBrowser({
return sortDir === "asc" ? cmp : -cmp; return sortDir === "asc" ? cmp : -cmp;
}); });
return sorted; return sorted;
}, [resources, searchQuery, sortKey, sortDir]); }, [resources, searchQuery, sortKey, sortDir, folderLayout]);
// Build breadcrumb segments // Build breadcrumb segments
const breadcrumbs = currentPath === '/' const breadcrumbs = currentPath === '/'
@@ -363,10 +459,10 @@ export function FileBrowser({
const segments = currentPath.split('/').filter(Boolean); const segments = currentPath.split('/').filter(Boolean);
segments.pop(); segments.pop();
const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/'); 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) { if (resource.isDirectory) {
// Ctrl/Cmd+click on directories also toggles selection // Ctrl/Cmd+click on directories also toggles selection
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
@@ -376,7 +472,7 @@ export function FileBrowser({
const newPath = currentPath === '/' const newPath = currentPath === '/'
? `/${resource.name}` ? `/${resource.name}`
: `${currentPath}/${resource.name}`; : `${currentPath}/${resource.name}`;
onNavigate(newPath); onNavigate(newPath, resource.id);
} else { } else {
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
onToggleSelect(resource.name); onToggleSelect(resource.name);
@@ -491,12 +587,12 @@ export function FileBrowser({
}; };
}, [marquee, onSetSelection]); }, [marquee, onSetSelection]);
const handleResourceDoubleClick = (resource: WebDAVResource) => { const handleResourceDoubleClick = (resource: FileResource) => {
if (resource.isDirectory) { if (resource.isDirectory) {
const newPath = currentPath === '/' const newPath = currentPath === '/'
? `/${resource.name}` ? `/${resource.name}`
: `${currentPath}/${resource.name}`; : `${currentPath}/${resource.name}`;
onNavigate(newPath); onNavigate(newPath, resource.id);
} else if (isPreviewable(resource.name)) { } else if (isPreviewable(resource.name)) {
if (isImageFile(resource.name)) { if (isImageFile(resource.name)) {
onPreviewImage(resource.name); onPreviewImage(resource.name);
@@ -511,7 +607,10 @@ export function FileBrowser({
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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) => { const handleDragLeave = useCallback((e: React.DragEvent) => {
@@ -711,7 +810,7 @@ export function FileBrowser({
const newPath = currentPath === '/' const newPath = currentPath === '/'
? `/${resource.name}` ? `/${resource.name}`
: `${currentPath}/${resource.name}`; : `${currentPath}/${resource.name}`;
onNavigate(newPath); onNavigate(newPath, resource.id);
} else if (resource) { } else if (resource) {
onDownload(resource.name); onDownload(resource.name);
} }
@@ -988,8 +1087,29 @@ export function FileBrowser({
{/* File list */} {/* File list */}
<div className="flex-1 min-h-0 flex relative"> <div className="flex-1 min-h-0 flex relative">
{/* Favorites & Recent sidebar */} {/* Folder tree sidebar (when layout is sidebar) */}
{(favorites.length > 0 || recentFiles.length > 0) && ( {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"> <div className="w-48 border-r border-border bg-background overflow-y-auto shrink-0 hidden lg:block">
{favorites.length > 0 && ( {favorites.length > 0 && (
<div className="p-3"> <div className="p-3">
@@ -1023,13 +1143,9 @@ export function FileBrowser({
<div className="space-y-0.5"> <div className="space-y-0.5">
{recentFiles.slice(0, 10).map((recent) => ( {recentFiles.slice(0, 10).map((recent) => (
<button <button
key={recent.path} key={recent.id}
onClick={() => {
const dir = recent.path.substring(0, recent.path.lastIndexOf('/')) || '/';
onNavigate(dir);
}}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-left" 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" /> <File className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="truncate">{recent.name}</span> <span className="truncate">{recent.name}</span>
@@ -1069,7 +1185,7 @@ export function FileBrowser({
<SkeletonRow /> <SkeletonRow />
</tbody> </tbody>
</table> </table>
) : resources.length === 0 && !searchQuery ? ( ) : resources.length === 0 && !searchQuery && currentPath === '/' ? (
<FileUploadArea <FileUploadArea
onUpload={async (files: File[]) => { onUpload={async (files: File[]) => {
setIsUploading(true); setIsUploading(true);
@@ -1087,8 +1203,27 @@ export function FileBrowser({
<div className="p-4" role="grid" aria-label={t("file_list")}> <div className="p-4" role="grid" aria-label={t("file_list")}>
{currentPath !== '/' && !searchQuery && ( {currentPath !== '/' && !searchQuery && (
<div <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} 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" /> <Folder className="w-10 h-10 text-muted-foreground" />
<span className="text-xs text-muted-foreground truncate w-full text-center">..</span> <span className="text-xs text-muted-foreground truncate w-full text-center">..</span>
@@ -1107,12 +1242,12 @@ export function FileBrowser({
> >
{displayResources.map((resource) => ( {displayResources.map((resource) => (
<div <div
key={resource.href} key={resource.id}
data-resource={resource.name} data-resource={resource.name}
draggable draggable
onDragStart={(e) => { onDragStart={(e) => {
const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name]; 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"; e.dataTransfer.effectAllowed = "move";
}} }}
onDragOver={(e) => { onDragOver={(e) => {
@@ -1127,7 +1262,7 @@ export function FileBrowser({
e.preventDefault(); e.preventDefault();
setDragTarget(null); setDragTarget(null);
if (!resource.isDirectory) return; 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; if (!raw) return;
const names: string[] = JSON.parse(raw); const names: string[] = JSON.parse(raw);
if (names.includes(resource.name)) return; if (names.includes(resource.name)) return;
@@ -1154,7 +1289,7 @@ export function FileBrowser({
data-checked={selectedResources.has(resource.name)} data-checked={selectedResources.has(resource.name)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
{isImageFile(resource.name) {showThumbnails && isImageFile(resource.name)
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="lg" /> ? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="lg" />
: getGridIcon(resource)} : getGridIcon(resource)}
<span className="text-xs truncate w-full text-center" title={resource.name}> <span className="text-xs truncate w-full text-center" title={resource.name}>
@@ -1212,8 +1347,27 @@ export function FileBrowser({
<tbody> <tbody>
{currentPath !== '/' && !searchQuery && ( {currentPath !== '/' && !searchQuery && (
<tr <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} 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"> <td className="px-4 py-2.5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -1235,13 +1389,13 @@ export function FileBrowser({
</tr> </tr>
) : displayResources.map((resource) => ( ) : displayResources.map((resource) => (
<tr <tr
key={resource.href} key={resource.id}
data-resource={resource.name} data-resource={resource.name}
aria-selected={selectedResources.has(resource.name)} aria-selected={selectedResources.has(resource.name)}
draggable draggable
onDragStart={(e) => { onDragStart={(e) => {
const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name]; 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"; e.dataTransfer.effectAllowed = "move";
}} }}
onDragOver={(e) => { onDragOver={(e) => {
@@ -1256,7 +1410,7 @@ export function FileBrowser({
e.preventDefault(); e.preventDefault();
setDragTarget(null); setDragTarget(null);
if (!resource.isDirectory) return; 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; if (!raw) return;
const names: string[] = JSON.parse(raw); const names: string[] = JSON.parse(raw);
if (names.includes(resource.name)) return; 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" className="w-4 h-4 rounded border-border accent-primary cursor-pointer shrink-0"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
{isImageFile(resource.name) {showThumbnails && isImageFile(resource.name)
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="sm" /> ? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="sm" />
: getFileIcon(resource)} : getFileIcon(resource)}
<span className="truncate">{resource.name}</span> <span className="truncate">{resource.name}</span>
@@ -1525,10 +1679,10 @@ export function FileBrowser({
: `${breadcrumbDropdown.path}/${folder.name}`; : `${breadcrumbDropdown.path}/${folder.name}`;
return ( return (
<button <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" className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
onClick={() => { onClick={() => {
onNavigate(folderPath); onNavigate(folderPath, folder.id);
setBreadcrumbDropdown(null); setBreadcrumbDropdown(null);
}} }}
> >
@@ -1567,9 +1721,7 @@ export function FileBrowser({
<div className="flex flex-col items-center gap-3 mb-4"> <div className="flex flex-col items-center gap-3 mb-4">
{detailResource.isDirectory {detailResource.isDirectory
? <Folder className="w-12 h-12 text-blue-500" /> ? <Folder className="w-12 h-12 text-blue-500" />
: isImageFile(detailResource.name) : getFileIconByName(detailResource.name, "lg")}
? <ImageIcon className="w-12 h-12 text-emerald-500" />
: <File className="w-12 h-12 text-muted-foreground" />}
<p className="text-sm font-medium text-center break-all">{detailResource.name}</p> <p className="text-sm font-medium text-center break-all">{detailResource.name}</p>
</div> </div>
<dl className="space-y-3 text-sm"> <dl className="space-y-3 text-sm">
@@ -1589,12 +1741,6 @@ export function FileBrowser({
<dd className="tabular-nums">{formatDate(detailResource.lastModified)}</dd> <dd className="tabular-nums">{formatDate(detailResource.lastModified)}</dd>
</div> </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> <div>
<dt className="text-muted-foreground text-xs">{t("path")}</dt> <dt className="text-muted-foreground text-xs">{t("path")}</dt>
<dd className="text-xs break-all font-mono"> <dd className="text-xs break-all font-mono">
+180
View File
@@ -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>
);
}
+297
View File
@@ -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>
)}
</>
);
}
+109
View File
@@ -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>
);
}
+86 -2
View File
@@ -2,7 +2,8 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
const localesDir = path.resolve(__dirname, '../../locales'); const rootDir = path.resolve(__dirname, '../..');
const localesDir = path.join(rootDir, 'locales');
const referenceLocale = 'en'; const referenceLocale = 'en';
function getLeafKeys(obj: Record<string, unknown>, prefix = ''): string[] { function getLeafKeys(obj: Record<string, unknown>, prefix = ''): string[] {
@@ -24,11 +25,74 @@ function loadLocale(locale: string): Record<string, unknown> {
return JSON.parse(fs.readFileSync(filePath, 'utf-8')); return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} }
function resolveKey(obj: Record<string, unknown>, key: string): unknown {
return key.split('.').reduce<unknown>((o, p) => (o && typeof o === 'object' ? (o as Record<string, unknown>)[p] : undefined), obj);
}
// Collect source files recursively
function getSourceFiles(dir: string): string[] {
const results: string[] = [];
let entries: fs.Dirent[];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules' && entry.name !== '__tests__') {
results.push(...getSourceFiles(fullPath));
} else if (entry.isFile() && /\.(tsx?|jsx?)$/.test(entry.name)) {
results.push(fullPath);
}
}
return results;
}
/**
* Extract translation keys from source, respecting which variable maps to which namespace.
* Handles multiple useTranslations calls per file (even reusing the same variable name
* in different functions) by finding, for each t("key") call, the nearest preceding
* useTranslations assignment to that variable.
*/
function extractUsedKeys(filePath: string): string[] {
const content = fs.readFileSync(filePath, 'utf-8');
const keys: string[] = [];
// Collect all variable→namespace assignments with their positions
const assignRegex = /const\s+(\w+)\s*=\s*useTranslations\(\s*["']([^"']*)["']\s*\)/g;
const assignments: { varName: string; namespace: string; index: number }[] = [];
let m: RegExpExecArray | null;
while ((m = assignRegex.exec(content)) !== null) {
assignments.push({ varName: m[1], namespace: m[2], index: m.index });
}
if (assignments.length === 0) return keys;
// Get unique variable names
const varNames = [...new Set(assignments.map((a) => a.varName))];
// For each variable, find its t("key") calls and resolve namespace by position
for (const varName of varNames) {
const varAssignments = assignments.filter((a) => a.varName === varName);
const callRegex = new RegExp(`\\b${varName}\\(\\s*["']([^"'{}]+)["']`, 'g');
while ((m = callRegex.exec(content)) !== null) {
const key = m[1];
if (key.startsWith('.')) continue;
// Find the nearest preceding assignment for this variable
const ns = varAssignments
.filter((a) => a.index < m!.index)
.sort((a, b) => b.index - a.index)[0]?.namespace;
if (ns === undefined) continue;
keys.push(ns ? `${ns}.${key}` : key);
}
}
return [...new Set(keys)];
}
const locales = fs const locales = fs
.readdirSync(localesDir) .readdirSync(localesDir)
.filter((entry) => fs.statSync(path.join(localesDir, entry)).isDirectory()); .filter((entry) => fs.statSync(path.join(localesDir, entry)).isDirectory());
const referenceKeys = getLeafKeys(loadLocale(referenceLocale)); const referenceData = loadLocale(referenceLocale);
const referenceKeys = getLeafKeys(referenceData);
describe('translations completeness', () => { describe('translations completeness', () => {
it('reference locale (en) should have keys', () => { it('reference locale (en) should have keys', () => {
@@ -52,3 +116,23 @@ describe('translations completeness', () => {
expect(extra, `Extra ${extra.length} keys in "${locale}":\n${extra.join('\n')}`).toEqual([]); expect(extra, `Extra ${extra.length} keys in "${locale}":\n${extra.join('\n')}`).toEqual([]);
}); });
}); });
describe('translations used in source code exist in en locale', () => {
const srcDirs = ['components', 'app', 'hooks', 'lib', 'stores', 'contexts'].map((d) => path.join(rootDir, d));
const allFiles = srcDirs.flatMap((d) => getSourceFiles(d));
const usedKeys = new Set<string>();
for (const f of allFiles) {
for (const k of extractUsedKeys(f)) {
usedKeys.add(k);
}
}
it('all translation keys referenced in source should exist in en locale', () => {
const missing = [...usedKeys].sort().filter((key) => resolveKey(referenceData, key) === undefined);
expect(
missing,
`${missing.length} translation key(s) used in source code but missing from en locale:\n${missing.join('\n')}`,
).toEqual([]);
});
});
+264 -1
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter } from "./types"; import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode, FileNodeFilter } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { SieveScript, SieveCapabilities } from "./sieve-types";
import { toWildcardQuery } from "./search-utils"; import { toWildcardQuery } from "./search-utils";
@@ -2350,6 +2350,269 @@ export class JMAPClient {
return { destroyed, notDestroyed }; return { destroyed, notDestroyed };
} }
// ─── JMAP FileNode methods (draft-ietf-jmap-filenode) ───
supportsFiles(): boolean {
return this.hasCapability("urn:ietf:params:jmap:filenode");
}
async probeFileNodeSupport(): Promise<boolean> {
// Some servers support FileNode without advertising a specific capability.
// Try a minimal FileNode/query to detect support at runtime.
if (this.supportsFiles()) return true;
if (!this.apiUrl) return false;
try {
const accountId = this.getFilesAccountId();
const response = await this.authenticatedFetch(this.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
using: ["urn:ietf:params:jmap:core"],
methodCalls: [["FileNode/query", { accountId, filter: {}, limit: 1 }, "probe0"]],
}),
});
if (!response.ok) return false;
const data = await response.json();
const result = data.methodResponses?.[0];
return result && result[0] === "FileNode/query";
} catch {
return false;
}
}
getFilesAccountId(): string {
const filesAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:filenode"];
return filesAccount || this.accountId;
}
private fileUsing(): string[] {
const using = ["urn:ietf:params:jmap:core"];
if (this.hasCapability("urn:ietf:params:jmap:filenode")) {
using.push("urn:ietf:params:jmap:filenode");
}
return using;
}
private static FILE_NODE_PROPERTIES = ["id", "parentId", "name", "type", "blobId", "size", "created", "updated"];
async getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]> {
const accountId = this.getFilesAccountId();
const args: Record<string, unknown> = { accountId, ids, properties: properties || JMAPClient.FILE_NODE_PROPERTIES };
const response = await this.request(
[["FileNode/get", args, "fn0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/get failed");
}
return (result[1].list || []) as FileNode[];
}
async queryFileNodes(filter: FileNodeFilter, sort?: { property: string; isAscending: boolean }[]): Promise<string[]> {
const accountId = this.getFilesAccountId();
const args: Record<string, unknown> = { accountId, filter };
if (sort) args.sort = sort;
const response = await this.request(
[["FileNode/query", args, "fnq0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/query failed");
}
return (result[1].ids || []) as string[];
}
async listFileNodes(parentId: string | null): Promise<FileNode[]> {
const accountId = this.getFilesAccountId();
const filter: Record<string, unknown> = {};
if (parentId !== null) {
filter.parentId = parentId;
}
// When parentId is null (root level), use empty filter to get all nodes.
// Stalwart's FileNode/query does not support parentId: null as a filter value.
const response = await this.request(
[
["FileNode/query", { accountId, filter }, "fnq0"],
["FileNode/get", { accountId, "#ids": { resultOf: "fnq0", name: "FileNode/query", path: "/ids" }, properties: JMAPClient.FILE_NODE_PROPERTIES }, "fng0"],
],
this.fileUsing(),
);
// Check if query failed first
const queryResult = response.methodResponses?.find(r => r[0] === "FileNode/query" || (r[0] === "error" && r[2] === "fnq0"));
if (queryResult && queryResult[0] === "error") {
console.error('[Files] FileNode/query error:', queryResult[1]);
throw new Error(queryResult[1]?.description || "FileNode/query failed");
}
const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get" || (r[0] === "error" && r[2] === "fnq0"));
if (!getResult) {
console.error('[Files] No FileNode/get response. Full response:', JSON.stringify(response.methodResponses));
throw new Error("FileNode list failed - no response");
}
if (getResult[0] === "error") {
console.error('[Files] FileNode/get error:', getResult[1]);
throw new Error(getResult[1]?.description || "FileNode list failed");
}
const nodes = (getResult[1].list || []) as FileNode[];
// When listing root, filter client-side to only show root-level items
if (parentId === null) {
return nodes.filter(n => n.parentId === null);
}
return nodes;
}
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
const accountId = this.getFilesAccountId();
// Stalwart requires a blobId even for directories — upload an empty blob
const emptyBlob = new File([], name, { type: 'application/x-directory' });
const { blobId } = await this.uploadBlob(emptyBlob);
const dirProps: Record<string, unknown> = { name, type: "d", blobId, size: 0 };
if (parentId !== null) {
dirProps.parentId = parentId;
}
const response = await this.request(
[["FileNode/set", {
accountId,
create: {
dir0: dirProps,
},
}, "fns0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set create failed");
}
const created = result[1].created?.dir0;
if (!created) {
const err = result[1].notCreated?.dir0;
throw new Error(err?.description || "Failed to create directory");
}
return created as FileNode;
}
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode> {
const accountId = this.getFilesAccountId();
const fileProps: Record<string, unknown> = { name, type, blobId, size };
if (parentId !== null) {
fileProps.parentId = parentId;
}
const response = await this.request(
[["FileNode/set", {
accountId,
create: {
file0: fileProps,
},
}, "fns0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set create failed");
}
const created = result[1].created?.file0;
if (!created) {
const err = result[1].notCreated?.file0;
throw new Error(err?.description || "Failed to create file node");
}
return created as FileNode;
}
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void> {
const accountId = this.getFilesAccountId();
const response = await this.request(
[["FileNode/set", {
accountId,
update: { [id]: updates },
}, "fns0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set update failed");
}
if (result[1].notUpdated?.[id]) {
throw new Error(result[1].notUpdated[id].description || "Failed to update file node");
}
}
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
const accountId = this.getFilesAccountId();
const response = await this.request(
[["FileNode/set", {
accountId,
destroy: ids,
}, "fns0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set destroy failed");
}
return {
destroyed: result[1].destroyed || [],
notDestroyed: result[1].notDestroyed ? Object.keys(result[1].notDestroyed) : [],
};
}
async copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode> {
// Copy: get original, upload blob reference, create new node
const nodes = await this.getFileNodes([id]);
if (nodes.length === 0) throw new Error('File node not found');
const original = nodes[0];
const accountId = this.getFilesAccountId();
const createProps: Record<string, unknown> = {
name: newName,
type: original.type,
blobId: original.blobId,
size: original.size,
};
if (parentId !== null) {
createProps.parentId = parentId;
}
const response = await this.request(
[["FileNode/set", {
accountId,
create: {
copy0: createProps,
},
}, "fns0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode copy failed");
}
const created = result[1].created?.copy0;
if (!created) {
const err = result[1].notCreated?.copy0;
throw new Error(err?.description || "Failed to copy file node");
}
return created as FileNode;
}
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> { async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
const url = this.getBlobDownloadUrl(blobId, name, type); const url = this.getBlobDownloadUrl(blobId, name, type);
const response = await this.authenticatedFetch(url, {}); const response = await this.authenticatedFetch(url, {});
+19
View File
@@ -619,3 +619,22 @@ export interface AccountStates {
Thread?: string; Thread?: string;
}; };
} }
// JMAP FileNode types (draft-ietf-jmap-filenode / Stalwart implementation)
export interface FileNode {
id: string;
parentId: string | null;
name: string;
type: string; // "d" for directory, MIME type for files
blobId: string | null;
size: number;
created: string;
updated: string;
}
export interface FileNodeFilter {
parentId?: string | null;
name?: string;
type?: string;
}
+124 -10
View File
@@ -260,7 +260,9 @@
"delete": "Löschen", "delete": "Löschen",
"star": "Markieren (s)", "star": "Markieren (s)",
"unstar": "Markierung entfernen (s)", "unstar": "Markierung entfernen (s)",
"compose": "Verfassen (c)" "compose": "Verfassen (c)",
"previous": "Vorherige E-Mail",
"next": "Nächste E-Mail"
}, },
"spam": { "spam": {
"button_title": "Spam melden", "button_title": "Spam melden",
@@ -300,7 +302,10 @@
"no_calendar": "Kalender nicht verfügbar", "no_calendar": "Kalender nicht verfügbar",
"select_calendar": "Kalender auswählen", "select_calendar": "Kalender auswählen",
"already_in_calendar": "Bereits in deinem Kalender" "already_in_calendar": "Bereits in deinem Kalender"
} },
"previous": "Zurück",
"next": "Weiter",
"send": "Senden"
}, },
"email_composer": { "email_composer": {
"new_message": "Neue Nachricht", "new_message": "Neue Nachricht",
@@ -362,7 +367,9 @@
"continue_draft": "Entwurf fortsetzen", "continue_draft": "Entwurf fortsetzen",
"close_draft_title": "Entwurf speichern oder verwerfen?", "close_draft_title": "Entwurf speichern oder verwerfen?",
"close_draft_message": "Sie haben ungespeicherte Änderungen. Möchten Sie diese als Entwurf speichern oder verwerfen?", "close_draft_message": "Sie haben ungespeicherte Änderungen. Möchten Sie diese als Entwurf speichern oder verwerfen?",
"save_draft": "Entwurf speichern" "save_draft": "Entwurf speichern",
"drop_files": "Dateien zum Anhängen ablegen",
"show_less": "Weniger anzeigen"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Bestätigen", "confirm": "Bestätigen",
@@ -385,7 +392,8 @@
"yes": "Ja", "yes": "Ja",
"no": "Nein", "no": "Nein",
"unknown": "Unbekannt", "unknown": "Unbekannt",
"app_title": "Webmail" "app_title": "Webmail",
"reconnecting": "Verbindung verloren. Verbindung wird wiederhergestellt…"
}, },
"notifications": { "notifications": {
"email_sent": "E-Mail erfolgreich gesendet", "email_sent": "E-Mail erfolgreich gesendet",
@@ -491,7 +499,8 @@
"templates": "Vorlagen", "templates": "Vorlagen",
"folders": "Ordner", "folders": "Ordner",
"keywords": "Schlüsselwörter", "keywords": "Schlüsselwörter",
"security": "Sicherheit" "security": "Sicherheit",
"files": "Dateien"
}, },
"tab_groups": { "tab_groups": {
"general": "Allgemein", "general": "Allgemein",
@@ -1055,6 +1064,55 @@
"empty": "Vorlagenname ist erforderlich", "empty": "Vorlagenname ist erforderlich",
"too_long": "Vorlagenname darf maximal 200 Zeichen haben" "too_long": "Vorlagenname darf maximal 200 Zeichen haben"
} }
},
"files": {
"display": {
"title": "Anzeige",
"description": "Konfigurieren Sie die Anzeige von Dateien und Ordnern"
},
"default_view": {
"label": "Standardansicht",
"description": "Wählen Sie zwischen Raster- und Listenansicht",
"list": "Liste",
"grid": "Raster"
},
"default_sort": {
"label": "Standardsortierung",
"description": "Wählen Sie die Standardsortierung für Dateien",
"name": "Name",
"size": "Größe",
"modified": "Geändert"
},
"sort_direction": {
"label": "Sortierrichtung",
"description": "Wählen Sie auf- oder absteigende Reihenfolge",
"ascending": "Aufsteigend",
"descending": "Absteigend"
},
"icons": {
"title": "Symbole",
"description": "Darstellung der Dateisymbole konfigurieren"
},
"show_icons": {
"label": "Dateisymbole anzeigen",
"description": "Symbole neben Dateien und Ordnern anzeigen"
},
"colored_icons": {
"label": "Farbige Symbole",
"description": "Farbige statt einfarbige Symbole verwenden"
},
"show_thumbnails": {
"label": "Vorschaubilder anzeigen",
"description": "Bildvorschauen statt Symbole für Bilddateien anzeigen"
},
"behavior": {
"title": "Verhalten",
"description": "Verhalten des Dateibrowsers konfigurieren"
},
"show_hidden": {
"label": "Versteckte Dateien anzeigen",
"description": "Dateien und Ordner anzeigen, die mit einem Punkt beginnen"
}
} }
}, },
"errors": { "errors": {
@@ -1208,7 +1266,9 @@
"identity_name": "Gesendet mit Identität: {name}", "identity_name": "Gesendet mit Identität: {name}",
"identity_short": "über {name}", "identity_short": "über {name}",
"subaddress_tag": "+{tag}" "subaddress_tag": "+{tag}"
} },
"delete_button": "Löschen",
"delete_confirm_title": "Identität löschen"
}, },
"templates": { "templates": {
"picker_title": "Vorlage wählen", "picker_title": "Vorlage wählen",
@@ -1385,7 +1445,8 @@
"name_required": "Mindestens ein Vor- oder Nachname ist erforderlich", "name_required": "Mindestens ein Vor- oder Nachname ist erforderlich",
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein", "email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"email_error_inline": "Ungültiges E-Mail-Format", "email_error_inline": "Ungültiges E-Mail-Format",
"save_failed": "Kontakt konnte nicht gespeichert werden" "save_failed": "Kontakt konnte nicht gespeichert werden",
"delete": "Löschen"
}, },
"groups": { "groups": {
"create": "Neue Gruppe", "create": "Neue Gruppe",
@@ -1663,7 +1724,40 @@
"error_delete": "Kalender konnte nicht gelöscht werden", "error_delete": "Kalender konnte nicht gelöscht werden",
"caldav_url": "CalDAV-URL", "caldav_url": "CalDAV-URL",
"copy_url": "CalDAV-URL kopieren", "copy_url": "CalDAV-URL kopieren",
"url_copied": "CalDAV-URL in die Zwischenablage kopiert" "url_copied": "CalDAV-URL in die Zwischenablage kopiert",
"confirm_clear": "Alle Ereignisse aus \"{name}\" löschen? Dies kann nicht rückgängig gemacht werden.",
"clear_events": "Ereignisse löschen",
"events_cleared": "{count} Ereignisse gelöscht",
"error_clear": "Kalenderereignisse konnten nicht gelöscht werden"
},
"subscription": {
"title": "iCal-Abonnement",
"section_title": "iCal-Abonnements",
"description": "Abonnieren Sie einen externen iCalendar-Feed. Ereignisse werden automatisch in einen eigenen Kalender synchronisiert. Unterstützt https://- und webcal://-URLs.",
"url_label": "Kalender-URL",
"url_placeholder": "https://example.com/calendar.ics oder webcal://...",
"name_label": "Kalendername",
"name_placeholder": "z.B. Feiertage",
"color_label": "Farbe",
"refresh_interval": "Aktualisierungsintervall",
"interval_15": "Alle 15 Minuten",
"interval_30": "Alle 30 Minuten",
"interval_60": "Jede Stunde",
"interval_360": "Alle 6 Stunden",
"interval_1440": "Jeden Tag",
"subscribe": "Abonnieren",
"subscribing": "Abonniere...",
"invalid_url": "Bitte geben Sie eine gültige URL ein",
"success": "\"{name}\" abonniert",
"error": "Abonnement konnte nicht hinzugefügt werden",
"refresh": "Jetzt aktualisieren",
"refresh_success": "Abonnement aktualisiert",
"refresh_error": "Abonnement konnte nicht aktualisiert werden",
"unsubscribe": "Abbestellen",
"confirm_delete": "\"{name}\" abbestellen? Der Kalender und alle seine Ereignisse werden entfernt.",
"deleted": "Abonnement entfernt",
"delete_error": "Abonnement konnte nicht entfernt werden",
"last_refreshed": "Zuletzt aktualisiert: {time}"
} }
}, },
"advanced_search": { "advanced_search": {
@@ -1741,7 +1835,7 @@
"rename_success": "Erfolgreich umbenannt", "rename_success": "Erfolgreich umbenannt",
"rename_error": "Umbenennen fehlgeschlagen", "rename_error": "Umbenennen fehlgeschlagen",
"download_error": "Herunterladen fehlgeschlagen", "download_error": "Herunterladen fehlgeschlagen",
"not_available": "WebDAV-Dateispeicher ist auf diesem Server nicht verfügbar", "not_available": "Dateispeicher ist auf diesem Server nicht verfügbar",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"create": "Erstellen", "create": "Erstellen",
"save": "Speichern", "save": "Speichern",
@@ -1782,6 +1876,26 @@
"undo_error": "Rückgängig machen fehlgeschlagen", "undo_error": "Rückgängig machen fehlgeschlagen",
"toolbar": "Dateiaktionen", "toolbar": "Dateiaktionen",
"file_list": "Dateien und Ordner", "file_list": "Dateien und Ordner",
"context_menu": "Aktionen" "context_menu": "Aktionen",
"settings_title": "Dateieinstellungen",
"settings_display": "Anzeige",
"settings_default_view": "Standardansicht",
"settings_default_view_desc": "Wählen Sie zwischen Raster- und Listenansicht",
"settings_default_sort": "Standardsortierung",
"settings_default_sort_desc": "Wählen Sie die Standardsortierung für Dateien",
"settings_sort_direction": "Sortierrichtung",
"settings_sort_direction_desc": "Wählen Sie auf- oder absteigende Reihenfolge",
"settings_ascending": "Aufsteigend",
"settings_descending": "Absteigend",
"settings_icons": "Symbole",
"settings_show_icons": "Dateisymbole anzeigen",
"settings_show_icons_desc": "Symbole neben Dateien und Ordnern anzeigen",
"settings_colored_icons": "Farbige Symbole",
"settings_colored_icons_desc": "Farbige statt einfarbige Symbole verwenden",
"settings_show_thumbnails": "Vorschaubilder anzeigen",
"settings_show_thumbnails_desc": "Bildvorschauen statt Symbole für Bilddateien anzeigen",
"settings_behavior": "Verhalten",
"settings_show_hidden": "Versteckte Dateien anzeigen",
"settings_show_hidden_desc": "Dateien und Ordner anzeigen, die mit einem Punkt beginnen"
} }
} }
+96 -11
View File
@@ -8,7 +8,7 @@
"sign_in": "Sign in", "sign_in": "Sign in",
"signing_in": "Signing in...", "signing_in": "Signing in...",
"loading": "Loading...", "loading": "Loading...",
"reconnecting": "Connection lost. Attempting to reconnect\u2026", "reconnecting": "Connection lost. Attempting to reconnect",
"error": { "error": {
"invalid_credentials": "Invalid email or password. Please check your credentials and try again.", "invalid_credentials": "Invalid email or password. Please check your credentials and try again.",
"connection_failed": "Unable to reach the server. Check your internet connection and try again.", "connection_failed": "Unable to reach the server. Check your internet connection and try again.",
@@ -304,7 +304,8 @@
"no_calendar": "Calendar not available", "no_calendar": "Calendar not available",
"select_calendar": "Select calendar", "select_calendar": "Select calendar",
"already_in_calendar": "Already in your calendar" "already_in_calendar": "Already in your calendar"
} },
"send": "Send"
}, },
"email_composer": { "email_composer": {
"new_message": "New Message", "new_message": "New Message",
@@ -391,7 +392,8 @@
"yes": "Yes", "yes": "Yes",
"no": "No", "no": "No",
"unknown": "Unknown", "unknown": "Unknown",
"app_title": "Webmail" "app_title": "Webmail",
"reconnecting": "Connection lost. Attempting to reconnect…"
}, },
"notifications": { "notifications": {
"email_sent": "Email sent successfully", "email_sent": "Email sent successfully",
@@ -497,7 +499,8 @@
"templates": "Templates", "templates": "Templates",
"folders": "Folders", "folders": "Folders",
"keywords": "Keywords", "keywords": "Keywords",
"security": "Security" "security": "Security",
"files": "Files"
}, },
"tab_groups": { "tab_groups": {
"general": "General", "general": "General",
@@ -617,11 +620,11 @@
"description": "Display email preview in the list" "description": "Display email preview in the list"
}, },
"emails_per_page": { "emails_per_page": {
"label": "Emails Per Page",
"description": "Number of emails to load at once",
"25": "25 emails", "25": "25 emails",
"50": "50 emails", "50": "50 emails",
"100": "100 emails" "100": "100 emails",
"label": "Emails Per Page",
"description": "Number of emails to load at once"
}, },
"external_content": { "external_content": {
"label": "External Content", "label": "External Content",
@@ -1061,6 +1064,61 @@
"empty": "Template name is required", "empty": "Template name is required",
"too_long": "Template name must be 200 characters or less" "too_long": "Template name must be 200 characters or less"
} }
},
"files": {
"display": {
"title": "Display",
"description": "Configure how files and folders are displayed"
},
"folder_layout": {
"label": "Folder Navigation",
"description": "Choose how folders are displayed: inline with files or in a sidebar tree",
"inline": "Inline",
"sidebar": "Sidebar"
},
"default_view": {
"label": "Default View",
"description": "Choose between grid and list layout",
"list": "List",
"grid": "Grid"
},
"default_sort": {
"label": "Default Sort",
"description": "Choose the default sorting for files",
"name": "Name",
"size": "Size",
"modified": "Modified"
},
"sort_direction": {
"label": "Sort Direction",
"description": "Choose ascending or descending order",
"ascending": "Ascending",
"descending": "Descending"
},
"icons": {
"title": "Icons",
"description": "Configure file icon appearance"
},
"show_icons": {
"label": "Show File Icons",
"description": "Display icons next to files and folders"
},
"colored_icons": {
"label": "Colored Icons",
"description": "Use colorful icons instead of monochrome"
},
"show_thumbnails": {
"label": "Show Thumbnails",
"description": "Display image previews instead of icons for image files"
},
"behavior": {
"title": "Behavior",
"description": "Configure file browser behavior"
},
"show_hidden": {
"label": "Show Hidden Files",
"description": "Display files and folders that start with a dot"
}
} }
}, },
"errors": { "errors": {
@@ -1214,7 +1272,9 @@
"identity_name": "Sent using identity: {name}", "identity_name": "Sent using identity: {name}",
"identity_short": "via {name}", "identity_short": "via {name}",
"subaddress_tag": "+{tag}" "subaddress_tag": "+{tag}"
} },
"delete_button": "Delete",
"delete_confirm_title": "Delete Identity"
}, },
"templates": { "templates": {
"picker_title": "Choose a Template", "picker_title": "Choose a Template",
@@ -1391,7 +1451,8 @@
"name_required": "At least a first name or last name is required", "name_required": "At least a first name or last name is required",
"email_invalid": "Please enter a valid email address", "email_invalid": "Please enter a valid email address",
"email_error_inline": "Invalid email format", "email_error_inline": "Invalid email format",
"save_failed": "Failed to save contact" "save_failed": "Failed to save contact",
"delete": "Delete"
}, },
"groups": { "groups": {
"create": "New Group", "create": "New Group",
@@ -1780,7 +1841,7 @@
"rename_success": "Renamed successfully", "rename_success": "Renamed successfully",
"rename_error": "Failed to rename", "rename_error": "Failed to rename",
"download_error": "Failed to download", "download_error": "Failed to download",
"not_available": "WebDAV file storage is not available on this server", "not_available": "File storage is not available on this server",
"cancel": "Cancel", "cancel": "Cancel",
"create": "Create", "create": "Create",
"save": "Save", "save": "Save",
@@ -1821,6 +1882,30 @@
"undo_error": "Failed to undo", "undo_error": "Failed to undo",
"toolbar": "File actions", "toolbar": "File actions",
"file_list": "Files and folders", "file_list": "Files and folders",
"context_menu": "Actions" "context_menu": "Actions",
"settings_title": "File Settings",
"settings_display": "Display",
"settings_default_view": "Default View",
"settings_default_view_desc": "Choose between grid and list layout",
"settings_default_sort": "Default Sort",
"settings_default_sort_desc": "Choose the default sorting for files",
"settings_sort_direction": "Sort Direction",
"settings_sort_direction_desc": "Choose ascending or descending order",
"settings_ascending": "Ascending",
"settings_descending": "Descending",
"settings_icons": "Icons",
"settings_show_icons": "Show File Icons",
"settings_show_icons_desc": "Display icons next to files and folders",
"settings_colored_icons": "Colored Icons",
"settings_colored_icons_desc": "Use colorful icons instead of monochrome",
"settings_show_thumbnails": "Show Thumbnails",
"settings_show_thumbnails_desc": "Display image previews instead of icons for image files",
"settings_behavior": "Behavior",
"settings_show_hidden": "Show Hidden Files",
"settings_show_hidden_desc": "Display files and folders that start with a dot",
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
"settings_folder_layout_sidebar": "Sidebar"
} }
} }
+124 -10
View File
@@ -260,7 +260,9 @@
"delete": "Eliminar", "delete": "Eliminar",
"star": "Destacar (s)", "star": "Destacar (s)",
"unstar": "Quitar estrella (s)", "unstar": "Quitar estrella (s)",
"compose": "Redactar (c)" "compose": "Redactar (c)",
"previous": "Correo anterior",
"next": "Correo siguiente"
}, },
"spam": { "spam": {
"button_title": "Reportar spam", "button_title": "Reportar spam",
@@ -300,7 +302,10 @@
"no_calendar": "Calendario no disponible", "no_calendar": "Calendario no disponible",
"select_calendar": "Seleccionar calendario", "select_calendar": "Seleccionar calendario",
"already_in_calendar": "Ya está en tu calendario" "already_in_calendar": "Ya está en tu calendario"
} },
"previous": "Anterior",
"next": "Siguiente",
"send": "Enviar"
}, },
"email_composer": { "email_composer": {
"new_message": "Nuevo Mensaje", "new_message": "Nuevo Mensaje",
@@ -362,7 +367,9 @@
"continue_draft": "Continuar borrador", "continue_draft": "Continuar borrador",
"close_draft_title": "¿Guardar o descartar borrador?", "close_draft_title": "¿Guardar o descartar borrador?",
"close_draft_message": "Tiene cambios sin guardar. ¿Desea guardar esto como borrador o descartarlo?", "close_draft_message": "Tiene cambios sin guardar. ¿Desea guardar esto como borrador o descartarlo?",
"save_draft": "Guardar borrador" "save_draft": "Guardar borrador",
"drop_files": "Suelta archivos para adjuntar",
"show_less": "Mostrar menos"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Confirmar", "confirm": "Confirmar",
@@ -385,7 +392,8 @@
"yes": "Sí", "yes": "Sí",
"no": "No", "no": "No",
"unknown": "Desconocido", "unknown": "Desconocido",
"app_title": "Correo Web" "app_title": "Correo Web",
"reconnecting": "Conexión perdida. Intentando reconectar…"
}, },
"notifications": { "notifications": {
"email_sent": "Correo enviado exitosamente", "email_sent": "Correo enviado exitosamente",
@@ -491,7 +499,8 @@
"templates": "Plantillas", "templates": "Plantillas",
"folders": "Carpetas", "folders": "Carpetas",
"keywords": "Palabras clave", "keywords": "Palabras clave",
"security": "Seguridad" "security": "Seguridad",
"files": "Archivos"
}, },
"tab_groups": { "tab_groups": {
"general": "General", "general": "General",
@@ -1055,6 +1064,55 @@
"empty": "El nombre de la plantilla es obligatorio", "empty": "El nombre de la plantilla es obligatorio",
"too_long": "El nombre de la plantilla no debe superar los 200 caracteres" "too_long": "El nombre de la plantilla no debe superar los 200 caracteres"
} }
},
"files": {
"display": {
"title": "Visualización",
"description": "Configura cómo se muestran los archivos y carpetas"
},
"default_view": {
"label": "Vista predeterminada",
"description": "Elige entre diseño de cuadrícula y lista",
"list": "Lista",
"grid": "Cuadrícula"
},
"default_sort": {
"label": "Orden predeterminado",
"description": "Elige el orden predeterminado para los archivos",
"name": "Nombre",
"size": "Tamaño",
"modified": "Modificado"
},
"sort_direction": {
"label": "Dirección de orden",
"description": "Elige orden ascendente o descendente",
"ascending": "Ascendente",
"descending": "Descendente"
},
"icons": {
"title": "Iconos",
"description": "Configura la apariencia de los iconos de archivos"
},
"show_icons": {
"label": "Mostrar iconos de archivos",
"description": "Mostrar iconos junto a archivos y carpetas"
},
"colored_icons": {
"label": "Iconos de colores",
"description": "Usar iconos de colores en lugar de monocromáticos"
},
"show_thumbnails": {
"label": "Mostrar miniaturas",
"description": "Mostrar vistas previas de imágenes en lugar de iconos"
},
"behavior": {
"title": "Comportamiento",
"description": "Configura el comportamiento del explorador de archivos"
},
"show_hidden": {
"label": "Mostrar archivos ocultos",
"description": "Mostrar archivos y carpetas que comienzan con un punto"
}
} }
}, },
"errors": { "errors": {
@@ -1208,7 +1266,9 @@
"identity_name": "Enviado usando identidad: {name}", "identity_name": "Enviado usando identidad: {name}",
"identity_short": "vía {name}", "identity_short": "vía {name}",
"subaddress_tag": "+{tag}" "subaddress_tag": "+{tag}"
} },
"delete_button": "Eliminar",
"delete_confirm_title": "Eliminar identidad"
}, },
"templates": { "templates": {
"picker_title": "Elegir una plantilla", "picker_title": "Elegir una plantilla",
@@ -1385,7 +1445,8 @@
"name_required": "Se requiere al menos un nombre o apellido", "name_required": "Se requiere al menos un nombre o apellido",
"email_invalid": "Introduce una dirección de correo válida", "email_invalid": "Introduce una dirección de correo válida",
"email_error_inline": "Formato de correo inválido", "email_error_inline": "Formato de correo inválido",
"save_failed": "Error al guardar el contacto" "save_failed": "Error al guardar el contacto",
"delete": "Eliminar"
}, },
"groups": { "groups": {
"create": "Nuevo grupo", "create": "Nuevo grupo",
@@ -1663,7 +1724,40 @@
"error_delete": "Error al eliminar el calendario", "error_delete": "Error al eliminar el calendario",
"caldav_url": "URL de CalDAV", "caldav_url": "URL de CalDAV",
"copy_url": "Copiar URL de CalDAV", "copy_url": "Copiar URL de CalDAV",
"url_copied": "URL de CalDAV copiada al portapapeles" "url_copied": "URL de CalDAV copiada al portapapeles",
"confirm_clear": "¿Borrar todos los eventos de \"{name}\"? Esta acción no se puede deshacer.",
"clear_events": "Borrar eventos",
"events_cleared": "{count} eventos borrados",
"error_clear": "No se pudieron borrar los eventos del calendario"
},
"subscription": {
"title": "Suscripción iCal",
"section_title": "Suscripciones iCal",
"description": "Suscríbete a un feed externo de iCalendar. Los eventos se sincronizarán automáticamente en su propio calendario. Compatible con URLs https:// y webcal://.",
"url_label": "URL del calendario",
"url_placeholder": "https://example.com/calendar.ics o webcal://...",
"name_label": "Nombre del calendario",
"name_placeholder": "p. ej., Días festivos",
"color_label": "Color",
"refresh_interval": "Intervalo de actualización",
"interval_15": "Cada 15 minutos",
"interval_30": "Cada 30 minutos",
"interval_60": "Cada hora",
"interval_360": "Cada 6 horas",
"interval_1440": "Cada día",
"subscribe": "Suscribirse",
"subscribing": "Suscribiendo...",
"invalid_url": "Por favor, introduce una URL válida",
"success": "Suscrito a \"{name}\"",
"error": "No se pudo añadir la suscripción",
"refresh": "Actualizar ahora",
"refresh_success": "Suscripción actualizada",
"refresh_error": "No se pudo actualizar la suscripción",
"unsubscribe": "Cancelar suscripción",
"confirm_delete": "¿Cancelar la suscripción a \"{name}\"? El calendario y todos sus eventos serán eliminados.",
"deleted": "Suscripción eliminada",
"delete_error": "No se pudo eliminar la suscripción",
"last_refreshed": "Última actualización: {time}"
} }
}, },
"advanced_search": { "advanced_search": {
@@ -1741,7 +1835,7 @@
"rename_success": "Renombrado correctamente", "rename_success": "Renombrado correctamente",
"rename_error": "Error al renombrar", "rename_error": "Error al renombrar",
"download_error": "Error al descargar", "download_error": "Error al descargar",
"not_available": "El almacenamiento de archivos WebDAV no está disponible en este servidor", "not_available": "El almacenamiento de archivos no está disponible en este servidor",
"cancel": "Cancelar", "cancel": "Cancelar",
"create": "Crear", "create": "Crear",
"save": "Guardar", "save": "Guardar",
@@ -1782,6 +1876,26 @@
"undo_error": "Error al deshacer", "undo_error": "Error al deshacer",
"toolbar": "Acciones de archivo", "toolbar": "Acciones de archivo",
"file_list": "Archivos y carpetas", "file_list": "Archivos y carpetas",
"context_menu": "Acciones" "context_menu": "Acciones",
"settings_title": "Configuración de archivos",
"settings_display": "Visualización",
"settings_default_view": "Vista predeterminada",
"settings_default_view_desc": "Elige entre diseño de cuadrícula y lista",
"settings_default_sort": "Orden predeterminado",
"settings_default_sort_desc": "Elige el orden predeterminado para los archivos",
"settings_sort_direction": "Dirección de orden",
"settings_sort_direction_desc": "Elige orden ascendente o descendente",
"settings_ascending": "Ascendente",
"settings_descending": "Descendente",
"settings_icons": "Iconos",
"settings_show_icons": "Mostrar iconos de archivos",
"settings_show_icons_desc": "Mostrar iconos junto a archivos y carpetas",
"settings_colored_icons": "Iconos de colores",
"settings_colored_icons_desc": "Usar iconos de colores en lugar de monocromáticos",
"settings_show_thumbnails": "Mostrar miniaturas",
"settings_show_thumbnails_desc": "Mostrar vistas previas de imágenes en lugar de iconos",
"settings_behavior": "Comportamiento",
"settings_show_hidden": "Mostrar archivos ocultos",
"settings_show_hidden_desc": "Mostrar archivos y carpetas que comienzan con un punto"
} }
} }
+124 -10
View File
@@ -260,7 +260,9 @@
"delete": "Supprimer", "delete": "Supprimer",
"star": "Suivre (s)", "star": "Suivre (s)",
"unstar": "Ne plus suivre (s)", "unstar": "Ne plus suivre (s)",
"compose": "Rédiger (c)" "compose": "Rédiger (c)",
"previous": "E-mail précédent",
"next": "E-mail suivant"
}, },
"spam": { "spam": {
"button_title": "Signaler comme spam", "button_title": "Signaler comme spam",
@@ -300,7 +302,10 @@
"no_calendar": "Calendrier non disponible", "no_calendar": "Calendrier non disponible",
"select_calendar": "Choisir un calendrier", "select_calendar": "Choisir un calendrier",
"already_in_calendar": "Déjà dans votre calendrier" "already_in_calendar": "Déjà dans votre calendrier"
} },
"previous": "Précédent",
"next": "Suivant",
"send": "Envoyer"
}, },
"email_composer": { "email_composer": {
"new_message": "Nouveau message", "new_message": "Nouveau message",
@@ -362,7 +367,9 @@
"continue_draft": "Continuer le brouillon", "continue_draft": "Continuer le brouillon",
"close_draft_title": "Enregistrer ou supprimer le brouillon ?", "close_draft_title": "Enregistrer ou supprimer le brouillon ?",
"close_draft_message": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer comme brouillon ou supprimer ?", "close_draft_message": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer comme brouillon ou supprimer ?",
"save_draft": "Enregistrer le brouillon" "save_draft": "Enregistrer le brouillon",
"drop_files": "Déposez les fichiers à joindre",
"show_less": "Afficher moins"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Confirmer", "confirm": "Confirmer",
@@ -385,7 +392,8 @@
"yes": "Oui", "yes": "Oui",
"no": "Non", "no": "Non",
"unknown": "Inconnu", "unknown": "Inconnu",
"app_title": "Webmail" "app_title": "Webmail",
"reconnecting": "Connexion perdue. Tentative de reconnexion…"
}, },
"notifications": { "notifications": {
"email_sent": "Email envoyé avec succès", "email_sent": "Email envoyé avec succès",
@@ -491,7 +499,8 @@
"templates": "Modèles", "templates": "Modèles",
"folders": "Dossiers", "folders": "Dossiers",
"keywords": "Mots-clés", "keywords": "Mots-clés",
"security": "Sécurité" "security": "Sécurité",
"files": "Fichiers"
}, },
"tab_groups": { "tab_groups": {
"general": "Général", "general": "Général",
@@ -1055,6 +1064,55 @@
"empty": "Le nom du modèle est requis", "empty": "Le nom du modèle est requis",
"too_long": "Le nom du modèle ne doit pas dépasser 200 caractères" "too_long": "Le nom du modèle ne doit pas dépasser 200 caractères"
} }
},
"files": {
"display": {
"title": "Affichage",
"description": "Configurez l'affichage des fichiers et dossiers"
},
"default_view": {
"label": "Vue par défaut",
"description": "Choisissez entre la disposition en grille et en liste",
"list": "Liste",
"grid": "Grille"
},
"default_sort": {
"label": "Tri par défaut",
"description": "Choisissez le tri par défaut pour les fichiers",
"name": "Nom",
"size": "Taille",
"modified": "Modifié"
},
"sort_direction": {
"label": "Sens du tri",
"description": "Choisissez l'ordre croissant ou décroissant",
"ascending": "Croissant",
"descending": "Décroissant"
},
"icons": {
"title": "Icônes",
"description": "Configurez l'apparence des icônes de fichiers"
},
"show_icons": {
"label": "Afficher les icônes",
"description": "Afficher les icônes à côté des fichiers et dossiers"
},
"colored_icons": {
"label": "Icônes colorées",
"description": "Utiliser des icônes colorées au lieu de monochromes"
},
"show_thumbnails": {
"label": "Afficher les miniatures",
"description": "Afficher les aperçus d'images au lieu des icônes"
},
"behavior": {
"title": "Comportement",
"description": "Configurez le comportement du gestionnaire de fichiers"
},
"show_hidden": {
"label": "Afficher les fichiers cachés",
"description": "Afficher les fichiers et dossiers commençant par un point"
}
} }
}, },
"errors": { "errors": {
@@ -1208,7 +1266,9 @@
"identity_name": "Envoyé avec l'identité: {name}", "identity_name": "Envoyé avec l'identité: {name}",
"identity_short": "via {name}", "identity_short": "via {name}",
"subaddress_tag": "+{tag}" "subaddress_tag": "+{tag}"
} },
"delete_button": "Supprimer",
"delete_confirm_title": "Supprimer l'identité"
}, },
"templates": { "templates": {
"picker_title": "Choisir un modèle", "picker_title": "Choisir un modèle",
@@ -1385,7 +1445,8 @@
"name_required": "Un prénom ou un nom est requis", "name_required": "Un prénom ou un nom est requis",
"email_invalid": "Veuillez saisir une adresse e-mail valide", "email_invalid": "Veuillez saisir une adresse e-mail valide",
"email_error_inline": "Format d'e-mail invalide", "email_error_inline": "Format d'e-mail invalide",
"save_failed": "Échec de l'enregistrement du contact" "save_failed": "Échec de l'enregistrement du contact",
"delete": "Supprimer"
}, },
"groups": { "groups": {
"create": "Nouveau groupe", "create": "Nouveau groupe",
@@ -1663,7 +1724,40 @@
"error_delete": "Échec de la suppression du calendrier", "error_delete": "Échec de la suppression du calendrier",
"caldav_url": "URL CalDAV", "caldav_url": "URL CalDAV",
"copy_url": "Copier l'URL CalDAV", "copy_url": "Copier l'URL CalDAV",
"url_copied": "URL CalDAV copiée dans le presse-papiers" "url_copied": "URL CalDAV copiée dans le presse-papiers",
"confirm_clear": "Supprimer tous les événements de \"{name}\" ? Cette action est irréversible.",
"clear_events": "Supprimer les événements",
"events_cleared": "{count} événements supprimés",
"error_clear": "Impossible de supprimer les événements du calendrier"
},
"subscription": {
"title": "Abonnement iCal",
"section_title": "Abonnements iCal",
"description": "Abonnez-vous à un flux iCalendar externe. Les événements seront synchronisés automatiquement dans leur propre calendrier. Prend en charge les URL https:// et webcal://.",
"url_label": "URL du calendrier",
"url_placeholder": "https://example.com/calendar.ics ou webcal://...",
"name_label": "Nom du calendrier",
"name_placeholder": "ex. Jours fériés",
"color_label": "Couleur",
"refresh_interval": "Intervalle de rafraîchissement",
"interval_15": "Toutes les 15 minutes",
"interval_30": "Toutes les 30 minutes",
"interval_60": "Toutes les heures",
"interval_360": "Toutes les 6 heures",
"interval_1440": "Tous les jours",
"subscribe": "S'abonner",
"subscribing": "Abonnement en cours...",
"invalid_url": "Veuillez entrer une URL valide",
"success": "Abonné à \"{name}\"",
"error": "Impossible d'ajouter l'abonnement",
"refresh": "Rafraîchir maintenant",
"refresh_success": "Abonnement rafraîchi",
"refresh_error": "Impossible de rafraîchir l'abonnement",
"unsubscribe": "Se désabonner",
"confirm_delete": "Se désabonner de \"{name}\" ? Le calendrier et tous ses événements seront supprimés.",
"deleted": "Abonnement supprimé",
"delete_error": "Impossible de supprimer l'abonnement",
"last_refreshed": "Dernière mise à jour : {time}"
} }
}, },
"advanced_search": { "advanced_search": {
@@ -1741,7 +1835,7 @@
"rename_success": "Renommé avec succès", "rename_success": "Renommé avec succès",
"rename_error": "Échec du renommage", "rename_error": "Échec du renommage",
"download_error": "Échec du téléchargement", "download_error": "Échec du téléchargement",
"not_available": "Le stockage de fichiers WebDAV n'est pas disponible sur ce serveur", "not_available": "Le stockage de fichiers n'est pas disponible sur ce serveur",
"cancel": "Annuler", "cancel": "Annuler",
"create": "Créer", "create": "Créer",
"save": "Enregistrer", "save": "Enregistrer",
@@ -1782,6 +1876,26 @@
"undo_error": "Échec de l'annulation", "undo_error": "Échec de l'annulation",
"toolbar": "Actions sur les fichiers", "toolbar": "Actions sur les fichiers",
"file_list": "Fichiers et dossiers", "file_list": "Fichiers et dossiers",
"context_menu": "Actions" "context_menu": "Actions",
"settings_title": "Paramètres des fichiers",
"settings_display": "Affichage",
"settings_default_view": "Vue par défaut",
"settings_default_view_desc": "Choisissez entre la disposition en grille et en liste",
"settings_default_sort": "Tri par défaut",
"settings_default_sort_desc": "Choisissez le tri par défaut pour les fichiers",
"settings_sort_direction": "Sens du tri",
"settings_sort_direction_desc": "Choisissez l'ordre croissant ou décroissant",
"settings_ascending": "Croissant",
"settings_descending": "Décroissant",
"settings_icons": "Icônes",
"settings_show_icons": "Afficher les icônes",
"settings_show_icons_desc": "Afficher les icônes à côté des fichiers et dossiers",
"settings_colored_icons": "Icônes colorées",
"settings_colored_icons_desc": "Utiliser des icônes colorées au lieu de monochromes",
"settings_show_thumbnails": "Afficher les miniatures",
"settings_show_thumbnails_desc": "Afficher les aperçus d'images au lieu des icônes",
"settings_behavior": "Comportement",
"settings_show_hidden": "Afficher les fichiers cachés",
"settings_show_hidden_desc": "Afficher les fichiers et dossiers commençant par un point"
} }
} }
+124 -10
View File
@@ -260,7 +260,9 @@
"delete": "Elimina", "delete": "Elimina",
"star": "Aggiungi stella (s)", "star": "Aggiungi stella (s)",
"unstar": "Rimuovi stella (s)", "unstar": "Rimuovi stella (s)",
"compose": "Scrivi (c)" "compose": "Scrivi (c)",
"previous": "Email precedente",
"next": "Email successiva"
}, },
"spam": { "spam": {
"button_title": "Segnala come spam", "button_title": "Segnala come spam",
@@ -300,7 +302,10 @@
"no_calendar": "Calendario non disponibile", "no_calendar": "Calendario non disponibile",
"select_calendar": "Seleziona calendario", "select_calendar": "Seleziona calendario",
"already_in_calendar": "Già nel tuo calendario" "already_in_calendar": "Già nel tuo calendario"
} },
"previous": "Precedente",
"next": "Successivo",
"send": "Invia"
}, },
"email_composer": { "email_composer": {
"new_message": "Nuovo messaggio", "new_message": "Nuovo messaggio",
@@ -362,7 +367,9 @@
"continue_draft": "Continua bozza", "continue_draft": "Continua bozza",
"close_draft_title": "Salvare o eliminare la bozza?", "close_draft_title": "Salvare o eliminare la bozza?",
"close_draft_message": "Hai modifiche non salvate. Vuoi salvare come bozza o eliminare?", "close_draft_message": "Hai modifiche non salvate. Vuoi salvare come bozza o eliminare?",
"save_draft": "Salva bozza" "save_draft": "Salva bozza",
"drop_files": "Trascina i file per allegarli",
"show_less": "Mostra meno"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Conferma", "confirm": "Conferma",
@@ -385,7 +392,8 @@
"yes": "Sì", "yes": "Sì",
"no": "No", "no": "No",
"unknown": "Sconosciuto", "unknown": "Sconosciuto",
"app_title": "Webmail" "app_title": "Webmail",
"reconnecting": "Connessione persa. Tentativo di riconnessione…"
}, },
"notifications": { "notifications": {
"email_sent": "Messaggio inviato con successo", "email_sent": "Messaggio inviato con successo",
@@ -491,7 +499,8 @@
"templates": "Modelli", "templates": "Modelli",
"folders": "Cartelle", "folders": "Cartelle",
"keywords": "Parole chiave", "keywords": "Parole chiave",
"security": "Sicurezza" "security": "Sicurezza",
"files": "File"
}, },
"tab_groups": { "tab_groups": {
"general": "Generale", "general": "Generale",
@@ -1055,6 +1064,55 @@
"empty": "Il nome del modello è obbligatorio", "empty": "Il nome del modello è obbligatorio",
"too_long": "Il nome del modello non deve superare i 200 caratteri" "too_long": "Il nome del modello non deve superare i 200 caratteri"
} }
},
"files": {
"display": {
"title": "Visualizzazione",
"description": "Configura come vengono visualizzati file e cartelle"
},
"default_view": {
"label": "Vista predefinita",
"description": "Scegli tra layout a griglia e a lista",
"list": "Lista",
"grid": "Griglia"
},
"default_sort": {
"label": "Ordinamento predefinito",
"description": "Scegli l'ordinamento predefinito per i file",
"name": "Nome",
"size": "Dimensione",
"modified": "Modificato"
},
"sort_direction": {
"label": "Direzione ordinamento",
"description": "Scegli ordine crescente o decrescente",
"ascending": "Crescente",
"descending": "Decrescente"
},
"icons": {
"title": "Icone",
"description": "Configura l'aspetto delle icone dei file"
},
"show_icons": {
"label": "Mostra icone file",
"description": "Visualizza le icone accanto a file e cartelle"
},
"colored_icons": {
"label": "Icone colorate",
"description": "Usa icone colorate invece che monocromatiche"
},
"show_thumbnails": {
"label": "Mostra miniature",
"description": "Mostra anteprime delle immagini al posto delle icone"
},
"behavior": {
"title": "Comportamento",
"description": "Configura il comportamento del gestore file"
},
"show_hidden": {
"label": "Mostra file nascosti",
"description": "Visualizza file e cartelle che iniziano con un punto"
}
} }
}, },
"errors": { "errors": {
@@ -1208,7 +1266,9 @@
"identity_name": "Inviato usando identità: {name}", "identity_name": "Inviato usando identità: {name}",
"identity_short": "tramite {name}", "identity_short": "tramite {name}",
"subaddress_tag": "+{tag}" "subaddress_tag": "+{tag}"
} },
"delete_button": "Elimina",
"delete_confirm_title": "Elimina identità"
}, },
"templates": { "templates": {
"picker_title": "Scegli un modello", "picker_title": "Scegli un modello",
@@ -1385,7 +1445,8 @@
"name_required": "È richiesto almeno un nome o cognome", "name_required": "È richiesto almeno un nome o cognome",
"email_invalid": "Inserisci un indirizzo email valido", "email_invalid": "Inserisci un indirizzo email valido",
"email_error_inline": "Formato email non valido", "email_error_inline": "Formato email non valido",
"save_failed": "Impossibile salvare il contatto" "save_failed": "Impossibile salvare il contatto",
"delete": "Elimina"
}, },
"groups": { "groups": {
"create": "Nuovo gruppo", "create": "Nuovo gruppo",
@@ -1663,7 +1724,40 @@
"error_delete": "Impossibile eliminare il calendario", "error_delete": "Impossibile eliminare il calendario",
"caldav_url": "URL CalDAV", "caldav_url": "URL CalDAV",
"copy_url": "Copia URL CalDAV", "copy_url": "Copia URL CalDAV",
"url_copied": "URL CalDAV copiato negli appunti" "url_copied": "URL CalDAV copiato negli appunti",
"confirm_clear": "Cancellare tutti gli eventi da \"{name}\"? Questa azione non può essere annullata.",
"clear_events": "Cancella eventi",
"events_cleared": "{count} eventi cancellati",
"error_clear": "Impossibile cancellare gli eventi del calendario"
},
"subscription": {
"title": "Abbonamento iCal",
"section_title": "Abbonamenti iCal",
"description": "Abbonati a un feed iCalendar esterno. Gli eventi verranno sincronizzati automaticamente nel proprio calendario. Supporta URL https:// e webcal://.",
"url_label": "URL del calendario",
"url_placeholder": "https://example.com/calendar.ics o webcal://...",
"name_label": "Nome del calendario",
"name_placeholder": "es. Festività",
"color_label": "Colore",
"refresh_interval": "Intervallo di aggiornamento",
"interval_15": "Ogni 15 minuti",
"interval_30": "Ogni 30 minuti",
"interval_60": "Ogni ora",
"interval_360": "Ogni 6 ore",
"interval_1440": "Ogni giorno",
"subscribe": "Abbonati",
"subscribing": "Abbonamento in corso...",
"invalid_url": "Inserisci un URL valido",
"success": "Abbonato a \"{name}\"",
"error": "Impossibile aggiungere l'abbonamento",
"refresh": "Aggiorna ora",
"refresh_success": "Abbonamento aggiornato",
"refresh_error": "Impossibile aggiornare l'abbonamento",
"unsubscribe": "Annulla abbonamento",
"confirm_delete": "Annullare l'abbonamento a \"{name}\"? Il calendario e tutti i suoi eventi verranno rimossi.",
"deleted": "Abbonamento rimosso",
"delete_error": "Impossibile rimuovere l'abbonamento",
"last_refreshed": "Ultimo aggiornamento: {time}"
} }
}, },
"advanced_search": { "advanced_search": {
@@ -1741,7 +1835,7 @@
"rename_success": "Rinominato con successo", "rename_success": "Rinominato con successo",
"rename_error": "Rinominazione non riuscita", "rename_error": "Rinominazione non riuscita",
"download_error": "Download non riuscito", "download_error": "Download non riuscito",
"not_available": "L'archiviazione file WebDAV non è disponibile su questo server", "not_available": "L'archiviazione file non è disponibile su questo server",
"cancel": "Annulla", "cancel": "Annulla",
"create": "Crea", "create": "Crea",
"save": "Salva", "save": "Salva",
@@ -1782,6 +1876,26 @@
"undo_error": "Annullamento non riuscito", "undo_error": "Annullamento non riuscito",
"toolbar": "Azioni file", "toolbar": "Azioni file",
"file_list": "File e cartelle", "file_list": "File e cartelle",
"context_menu": "Azioni" "context_menu": "Azioni",
"settings_title": "Impostazioni file",
"settings_display": "Visualizzazione",
"settings_default_view": "Vista predefinita",
"settings_default_view_desc": "Scegli tra layout a griglia e a lista",
"settings_default_sort": "Ordinamento predefinito",
"settings_default_sort_desc": "Scegli l'ordinamento predefinito per i file",
"settings_sort_direction": "Direzione ordinamento",
"settings_sort_direction_desc": "Scegli ordine crescente o decrescente",
"settings_ascending": "Crescente",
"settings_descending": "Decrescente",
"settings_icons": "Icone",
"settings_show_icons": "Mostra icone file",
"settings_show_icons_desc": "Visualizza le icone accanto a file e cartelle",
"settings_colored_icons": "Icone colorate",
"settings_colored_icons_desc": "Usa icone colorate invece che monocromatiche",
"settings_show_thumbnails": "Mostra miniature",
"settings_show_thumbnails_desc": "Mostra anteprime delle immagini al posto delle icone",
"settings_behavior": "Comportamento",
"settings_show_hidden": "Mostra file nascosti",
"settings_show_hidden_desc": "Visualizza file e cartelle che iniziano con un punto"
} }
} }
+124 -10
View File
@@ -260,7 +260,9 @@
"delete": "削除", "delete": "削除",
"star": "スター (s)", "star": "スター (s)",
"unstar": "スター解除 (s)", "unstar": "スター解除 (s)",
"compose": "新規作成 (c)" "compose": "新規作成 (c)",
"previous": "前のメール",
"next": "次のメール"
}, },
"spam": { "spam": {
"button_title": "迷惑メールを報告", "button_title": "迷惑メールを報告",
@@ -300,7 +302,10 @@
"no_calendar": "カレンダーが利用できません", "no_calendar": "カレンダーが利用できません",
"select_calendar": "カレンダーを選択", "select_calendar": "カレンダーを選択",
"already_in_calendar": "カレンダーに登録済み" "already_in_calendar": "カレンダーに登録済み"
} },
"previous": "前へ",
"next": "次へ",
"send": "送信"
}, },
"email_composer": { "email_composer": {
"new_message": "新規メッセージ", "new_message": "新規メッセージ",
@@ -362,7 +367,9 @@
"continue_draft": "下書きを続ける", "continue_draft": "下書きを続ける",
"close_draft_title": "下書きを保存または破棄しますか?", "close_draft_title": "下書きを保存または破棄しますか?",
"close_draft_message": "未保存の変更があります。下書きとして保存しますか、それとも破棄しますか?", "close_draft_message": "未保存の変更があります。下書きとして保存しますか、それとも破棄しますか?",
"save_draft": "下書きを保存" "save_draft": "下書きを保存",
"drop_files": "ファイルをドロップして添付",
"show_less": "折りたたむ"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "確認", "confirm": "確認",
@@ -385,7 +392,8 @@
"yes": "はい", "yes": "はい",
"no": "いいえ", "no": "いいえ",
"unknown": "不明", "unknown": "不明",
"app_title": "ウェブメール" "app_title": "ウェブメール",
"reconnecting": "接続が切れました。再接続を試みています…"
}, },
"notifications": { "notifications": {
"email_sent": "メールを送信しました", "email_sent": "メールを送信しました",
@@ -491,7 +499,8 @@
"templates": "テンプレート", "templates": "テンプレート",
"folders": "フォルダー", "folders": "フォルダー",
"keywords": "キーワード", "keywords": "キーワード",
"security": "セキュリティ" "security": "セキュリティ",
"files": "ファイル"
}, },
"tab_groups": { "tab_groups": {
"general": "一般", "general": "一般",
@@ -1055,6 +1064,55 @@
"empty": "テンプレート名は必須です", "empty": "テンプレート名は必須です",
"too_long": "テンプレート名は200文字以内にしてください" "too_long": "テンプレート名は200文字以内にしてください"
} }
},
"files": {
"display": {
"title": "表示",
"description": "ファイルとフォルダーの表示方法を設定します"
},
"default_view": {
"label": "デフォルトビュー",
"description": "グリッドまたはリストレイアウトを選択します",
"list": "リスト",
"grid": "グリッド"
},
"default_sort": {
"label": "デフォルトの並べ替え",
"description": "ファイルのデフォルトの並べ替えを選択します",
"name": "名前",
"size": "サイズ",
"modified": "更新日"
},
"sort_direction": {
"label": "並べ替え方向",
"description": "昇順または降順を選択します",
"ascending": "昇順",
"descending": "降順"
},
"icons": {
"title": "アイコン",
"description": "ファイルアイコンの外観を設定します"
},
"show_icons": {
"label": "ファイルアイコンを表示",
"description": "ファイルとフォルダーの横にアイコンを表示します"
},
"colored_icons": {
"label": "カラーアイコン",
"description": "モノクロの代わりにカラフルなアイコンを使用します"
},
"show_thumbnails": {
"label": "サムネイルを表示",
"description": "画像ファイルのアイコンの代わりにプレビューを表示します"
},
"behavior": {
"title": "動作",
"description": "ファイルブラウザーの動作を設定します"
},
"show_hidden": {
"label": "隠しファイルを表示",
"description": "ドットで始まるファイルとフォルダーを表示します"
}
} }
}, },
"errors": { "errors": {
@@ -1208,7 +1266,9 @@
"identity_name": "送信者情報を使用して送信: {name}", "identity_name": "送信者情報を使用して送信: {name}",
"identity_short": "{name}経由", "identity_short": "{name}経由",
"subaddress_tag": "+{tag}" "subaddress_tag": "+{tag}"
} },
"delete_button": "削除",
"delete_confirm_title": "IDの削除"
}, },
"templates": { "templates": {
"picker_title": "テンプレートを選択", "picker_title": "テンプレートを選択",
@@ -1385,7 +1445,8 @@
"name_required": "名前は必須です", "name_required": "名前は必須です",
"email_invalid": "有効なメールアドレスを入力してください", "email_invalid": "有効なメールアドレスを入力してください",
"email_error_inline": "メールアドレスの形式が正しくありません", "email_error_inline": "メールアドレスの形式が正しくありません",
"save_failed": "連絡先の保存に失敗しました" "save_failed": "連絡先の保存に失敗しました",
"delete": "削除"
}, },
"groups": { "groups": {
"create": "新しいグループ", "create": "新しいグループ",
@@ -1663,7 +1724,40 @@
"error_delete": "カレンダーの削除に失敗しました", "error_delete": "カレンダーの削除に失敗しました",
"caldav_url": "CalDAV URL", "caldav_url": "CalDAV URL",
"copy_url": "CalDAV URLをコピー", "copy_url": "CalDAV URLをコピー",
"url_copied": "CalDAV URLをクリップボードにコピーしました" "url_copied": "CalDAV URLをクリップボードにコピーしました",
"confirm_clear": "\"{name}\"のすべてのイベントを削除しますか?この操作は元に戻せません。",
"clear_events": "イベントを削除",
"events_cleared": "{count}件のイベントを削除しました",
"error_clear": "カレンダーイベントの削除に失敗しました"
},
"subscription": {
"title": "iCal購読",
"section_title": "iCal購読",
"description": "外部のiCalendarフィードを購読します。イベントは自動的に専用のカレンダーに同期されます。https://およびwebcal://のURLに対応しています。",
"url_label": "カレンダーURL",
"url_placeholder": "https://example.com/calendar.ics または webcal://...",
"name_label": "カレンダー名",
"name_placeholder": "例:祝日",
"color_label": "色",
"refresh_interval": "更新間隔",
"interval_15": "15分ごと",
"interval_30": "30分ごと",
"interval_60": "1時間ごと",
"interval_360": "6時間ごと",
"interval_1440": "毎日",
"subscribe": "購読する",
"subscribing": "購読中...",
"invalid_url": "有効なURLを入力してください",
"success": "\"{name}\"を購読しました",
"error": "購読の追加に失敗しました",
"refresh": "今すぐ更新",
"refresh_success": "購読を更新しました",
"refresh_error": "購読の更新に失敗しました",
"unsubscribe": "購読解除",
"confirm_delete": "\"{name}\"の購読を解除しますか?カレンダーとすべてのイベントが削除されます。",
"deleted": "購読を解除しました",
"delete_error": "購読の解除に失敗しました",
"last_refreshed": "最終更新: {time}"
} }
}, },
"advanced_search": { "advanced_search": {
@@ -1741,7 +1835,7 @@
"rename_success": "正常に名前を変更しました", "rename_success": "正常に名前を変更しました",
"rename_error": "名前の変更に失敗しました", "rename_error": "名前の変更に失敗しました",
"download_error": "ダウンロードに失敗しました", "download_error": "ダウンロードに失敗しました",
"not_available": "WebDAVファイルストレージはこのサーバーで利用できません", "not_available": "ファイルストレージはこのサーバーで利用できません",
"cancel": "キャンセル", "cancel": "キャンセル",
"create": "作成", "create": "作成",
"save": "保存", "save": "保存",
@@ -1782,6 +1876,26 @@
"undo_error": "元に戻すのに失敗しました", "undo_error": "元に戻すのに失敗しました",
"toolbar": "ファイル操作", "toolbar": "ファイル操作",
"file_list": "ファイルとフォルダ", "file_list": "ファイルとフォルダ",
"context_menu": "操作" "context_menu": "操作",
"settings_title": "ファイル設定",
"settings_display": "表示",
"settings_default_view": "デフォルトビュー",
"settings_default_view_desc": "グリッドまたはリストレイアウトを選択します",
"settings_default_sort": "デフォルトの並べ替え",
"settings_default_sort_desc": "ファイルのデフォルトの並べ替えを選択します",
"settings_sort_direction": "並べ替え方向",
"settings_sort_direction_desc": "昇順または降順を選択します",
"settings_ascending": "昇順",
"settings_descending": "降順",
"settings_icons": "アイコン",
"settings_show_icons": "ファイルアイコンを表示",
"settings_show_icons_desc": "ファイルとフォルダーの横にアイコンを表示します",
"settings_colored_icons": "カラーアイコン",
"settings_colored_icons_desc": "モノクロの代わりにカラフルなアイコンを使用します",
"settings_show_thumbnails": "サムネイルを表示",
"settings_show_thumbnails_desc": "画像ファイルのアイコンの代わりにプレビューを表示します",
"settings_behavior": "動作",
"settings_show_hidden": "隠しファイルを表示",
"settings_show_hidden_desc": "ドットで始まるファイルとフォルダーを表示します"
} }
} }
+124 -10
View File
@@ -260,7 +260,9 @@
"delete": "Verwijderen", "delete": "Verwijderen",
"star": "Ster toevoegen (s)", "star": "Ster toevoegen (s)",
"unstar": "Ster verwijderen (s)", "unstar": "Ster verwijderen (s)",
"compose": "Opstellen (c)" "compose": "Opstellen (c)",
"previous": "Vorige e-mail",
"next": "Volgende e-mail"
}, },
"spam": { "spam": {
"button_title": "Spam melden", "button_title": "Spam melden",
@@ -300,7 +302,10 @@
"no_calendar": "Agenda niet beschikbaar", "no_calendar": "Agenda niet beschikbaar",
"select_calendar": "Agenda selecteren", "select_calendar": "Agenda selecteren",
"already_in_calendar": "Staat al in je agenda" "already_in_calendar": "Staat al in je agenda"
} },
"previous": "Vorige",
"next": "Volgende",
"send": "Verzenden"
}, },
"email_composer": { "email_composer": {
"new_message": "Nieuw bericht", "new_message": "Nieuw bericht",
@@ -362,7 +367,9 @@
"continue_draft": "Concept voortzetten", "continue_draft": "Concept voortzetten",
"close_draft_title": "Concept opslaan of verwijderen?", "close_draft_title": "Concept opslaan of verwijderen?",
"close_draft_message": "U heeft niet-opgeslagen wijzigingen. Wilt u dit als concept opslaan of verwijderen?", "close_draft_message": "U heeft niet-opgeslagen wijzigingen. Wilt u dit als concept opslaan of verwijderen?",
"save_draft": "Concept opslaan" "save_draft": "Concept opslaan",
"drop_files": "Sleep bestanden om bij te voegen",
"show_less": "Minder tonen"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Bevestigen", "confirm": "Bevestigen",
@@ -385,7 +392,8 @@
"yes": "Ja", "yes": "Ja",
"no": "Nee", "no": "Nee",
"unknown": "Onbekend", "unknown": "Onbekend",
"app_title": "Webmail" "app_title": "Webmail",
"reconnecting": "Verbinding verloren. Opnieuw verbinden…"
}, },
"notifications": { "notifications": {
"email_sent": "E-mail succesvol verzonden", "email_sent": "E-mail succesvol verzonden",
@@ -491,7 +499,8 @@
"templates": "Sjablonen", "templates": "Sjablonen",
"folders": "Mappen", "folders": "Mappen",
"keywords": "Sleutelwoorden", "keywords": "Sleutelwoorden",
"security": "Beveiliging" "security": "Beveiliging",
"files": "Bestanden"
}, },
"tab_groups": { "tab_groups": {
"general": "Algemeen", "general": "Algemeen",
@@ -1055,6 +1064,55 @@
"empty": "Sjabloonnaam is verplicht", "empty": "Sjabloonnaam is verplicht",
"too_long": "Sjabloonnaam mag maximaal 200 tekens zijn" "too_long": "Sjabloonnaam mag maximaal 200 tekens zijn"
} }
},
"files": {
"display": {
"title": "Weergave",
"description": "Configureer hoe bestanden en mappen worden weergegeven"
},
"default_view": {
"label": "Standaardweergave",
"description": "Kies tussen raster- en lijstweergave",
"list": "Lijst",
"grid": "Raster"
},
"default_sort": {
"label": "Standaardsortering",
"description": "Kies de standaardsortering voor bestanden",
"name": "Naam",
"size": "Grootte",
"modified": "Gewijzigd"
},
"sort_direction": {
"label": "Sorteerrichting",
"description": "Kies oplopende of aflopende volgorde",
"ascending": "Oplopend",
"descending": "Aflopend"
},
"icons": {
"title": "Pictogrammen",
"description": "Configureer de weergave van bestandspictogrammen"
},
"show_icons": {
"label": "Bestandspictogrammen tonen",
"description": "Pictogrammen naast bestanden en mappen weergeven"
},
"colored_icons": {
"label": "Gekleurde pictogrammen",
"description": "Gebruik gekleurde pictogrammen in plaats van monochroom"
},
"show_thumbnails": {
"label": "Miniaturen weergeven",
"description": "Toon afbeeldingsvoorbeelden in plaats van pictogrammen"
},
"behavior": {
"title": "Gedrag",
"description": "Configureer het gedrag van de bestandsbrowser"
},
"show_hidden": {
"label": "Verborgen bestanden tonen",
"description": "Bestanden en mappen weergeven die beginnen met een punt"
}
} }
}, },
"errors": { "errors": {
@@ -1208,7 +1266,9 @@
"identity_name": "Verzonden met identiteit: {name}", "identity_name": "Verzonden met identiteit: {name}",
"identity_short": "via {name}", "identity_short": "via {name}",
"subaddress_tag": "+{tag}" "subaddress_tag": "+{tag}"
} },
"delete_button": "Verwijderen",
"delete_confirm_title": "Identiteit verwijderen"
}, },
"templates": { "templates": {
"picker_title": "Kies een sjabloon", "picker_title": "Kies een sjabloon",
@@ -1385,7 +1445,8 @@
"name_required": "Ten minste een voor- of achternaam is vereist", "name_required": "Ten minste een voor- of achternaam is vereist",
"email_invalid": "Voer een geldig e-mailadres in", "email_invalid": "Voer een geldig e-mailadres in",
"email_error_inline": "Ongeldig e-mailformaat", "email_error_inline": "Ongeldig e-mailformaat",
"save_failed": "Kon contact niet opslaan" "save_failed": "Kon contact niet opslaan",
"delete": "Verwijderen"
}, },
"groups": { "groups": {
"create": "Nieuwe groep", "create": "Nieuwe groep",
@@ -1663,7 +1724,40 @@
"error_delete": "Agenda verwijderen mislukt", "error_delete": "Agenda verwijderen mislukt",
"caldav_url": "CalDAV-URL", "caldav_url": "CalDAV-URL",
"copy_url": "CalDAV-URL kopiëren", "copy_url": "CalDAV-URL kopiëren",
"url_copied": "CalDAV-URL gekopieerd naar klembord" "url_copied": "CalDAV-URL gekopieerd naar klembord",
"confirm_clear": "Alle afspraken uit \"{name}\" verwijderen? Dit kan niet ongedaan worden gemaakt.",
"clear_events": "Afspraken verwijderen",
"events_cleared": "{count} afspraken verwijderd",
"error_clear": "Kan agendagebeurtenissen niet verwijderen"
},
"subscription": {
"title": "iCal-abonnement",
"section_title": "iCal-abonnementen",
"description": "Abonneer op een externe iCalendar-feed. Afspraken worden automatisch gesynchroniseerd in een eigen agenda. Ondersteunt https://- en webcal://-URL's.",
"url_label": "Agenda-URL",
"url_placeholder": "https://example.com/calendar.ics of webcal://...",
"name_label": "Agendanaam",
"name_placeholder": "bijv. Feestdagen",
"color_label": "Kleur",
"refresh_interval": "Verversingsinterval",
"interval_15": "Elke 15 minuten",
"interval_30": "Elke 30 minuten",
"interval_60": "Elk uur",
"interval_360": "Elke 6 uur",
"interval_1440": "Elke dag",
"subscribe": "Abonneren",
"subscribing": "Bezig met abonneren...",
"invalid_url": "Voer een geldige URL in",
"success": "Geabonneerd op \"{name}\"",
"error": "Kan abonnement niet toevoegen",
"refresh": "Nu vernieuwen",
"refresh_success": "Abonnement vernieuwd",
"refresh_error": "Kan abonnement niet vernieuwen",
"unsubscribe": "Afmelden",
"confirm_delete": "Afmelden van \"{name}\"? De agenda en alle afspraken worden verwijderd.",
"deleted": "Abonnement verwijderd",
"delete_error": "Kan abonnement niet verwijderen",
"last_refreshed": "Laatst bijgewerkt: {time}"
} }
}, },
"advanced_search": { "advanced_search": {
@@ -1741,7 +1835,7 @@
"rename_success": "Succesvol hernoemd", "rename_success": "Succesvol hernoemd",
"rename_error": "Hernoemen mislukt", "rename_error": "Hernoemen mislukt",
"download_error": "Downloaden mislukt", "download_error": "Downloaden mislukt",
"not_available": "WebDAV-bestandsopslag is niet beschikbaar op deze server", "not_available": "Bestandsopslag is niet beschikbaar op deze server",
"cancel": "Annuleren", "cancel": "Annuleren",
"create": "Aanmaken", "create": "Aanmaken",
"save": "Opslaan", "save": "Opslaan",
@@ -1782,6 +1876,26 @@
"undo_error": "Ongedaan maken mislukt", "undo_error": "Ongedaan maken mislukt",
"toolbar": "Bestandsacties", "toolbar": "Bestandsacties",
"file_list": "Bestanden en mappen", "file_list": "Bestanden en mappen",
"context_menu": "Acties" "context_menu": "Acties",
"settings_title": "Bestandsinstellingen",
"settings_display": "Weergave",
"settings_default_view": "Standaardweergave",
"settings_default_view_desc": "Kies tussen raster- en lijstweergave",
"settings_default_sort": "Standaardsortering",
"settings_default_sort_desc": "Kies de standaardsortering voor bestanden",
"settings_sort_direction": "Sorteerrichting",
"settings_sort_direction_desc": "Kies oplopende of aflopende volgorde",
"settings_ascending": "Oplopend",
"settings_descending": "Aflopend",
"settings_icons": "Pictogrammen",
"settings_show_icons": "Bestandspictogrammen tonen",
"settings_show_icons_desc": "Pictogrammen naast bestanden en mappen weergeven",
"settings_colored_icons": "Gekleurde pictogrammen",
"settings_colored_icons_desc": "Gebruik gekleurde pictogrammen in plaats van monochroom",
"settings_show_thumbnails": "Miniaturen weergeven",
"settings_show_thumbnails_desc": "Toon afbeeldingsvoorbeelden in plaats van pictogrammen",
"settings_behavior": "Gedrag",
"settings_show_hidden": "Verborgen bestanden tonen",
"settings_show_hidden_desc": "Bestanden en mappen weergeven die beginnen met een punt"
} }
} }
+124 -10
View File
@@ -260,7 +260,9 @@
"delete": "Excluir", "delete": "Excluir",
"star": "Favoritar (s)", "star": "Favoritar (s)",
"unstar": "Remover favorito (s)", "unstar": "Remover favorito (s)",
"compose": "Compor (c)" "compose": "Compor (c)",
"previous": "E-mail anterior",
"next": "Próximo e-mail"
}, },
"spam": { "spam": {
"button_title": "Reportar spam", "button_title": "Reportar spam",
@@ -300,7 +302,10 @@
"no_calendar": "Calendário não disponível", "no_calendar": "Calendário não disponível",
"select_calendar": "Selecionar calendário", "select_calendar": "Selecionar calendário",
"already_in_calendar": "Já está no seu calendário" "already_in_calendar": "Já está no seu calendário"
} },
"previous": "Anterior",
"next": "Próximo",
"send": "Enviar"
}, },
"email_composer": { "email_composer": {
"new_message": "Nova Mensagem", "new_message": "Nova Mensagem",
@@ -362,7 +367,9 @@
"continue_draft": "Continuar rascunho", "continue_draft": "Continuar rascunho",
"close_draft_title": "Salvar ou descartar rascunho?", "close_draft_title": "Salvar ou descartar rascunho?",
"close_draft_message": "Você tem alterações não salvas. Deseja salvar como rascunho ou descartar?", "close_draft_message": "Você tem alterações não salvas. Deseja salvar como rascunho ou descartar?",
"save_draft": "Salvar rascunho" "save_draft": "Salvar rascunho",
"drop_files": "Solte arquivos para anexar",
"show_less": "Mostrar menos"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Confirmar", "confirm": "Confirmar",
@@ -385,7 +392,8 @@
"yes": "Sim", "yes": "Sim",
"no": "Não", "no": "Não",
"unknown": "Desconhecido", "unknown": "Desconhecido",
"app_title": "Webmail" "app_title": "Webmail",
"reconnecting": "Conexão perdida. Tentando reconectar…"
}, },
"notifications": { "notifications": {
"email_sent": "E-mail enviado com sucesso", "email_sent": "E-mail enviado com sucesso",
@@ -491,7 +499,8 @@
"templates": "Modelos", "templates": "Modelos",
"folders": "Pastas", "folders": "Pastas",
"keywords": "Palavras-chave", "keywords": "Palavras-chave",
"security": "Segurança" "security": "Segurança",
"files": "Arquivos"
}, },
"tab_groups": { "tab_groups": {
"general": "Geral", "general": "Geral",
@@ -1055,6 +1064,55 @@
"empty": "O nome do modelo é obrigatório", "empty": "O nome do modelo é obrigatório",
"too_long": "O nome do modelo não pode exceder 200 caracteres" "too_long": "O nome do modelo não pode exceder 200 caracteres"
} }
},
"files": {
"display": {
"title": "Exibição",
"description": "Configure como arquivos e pastas são exibidos"
},
"default_view": {
"label": "Visualização padrão",
"description": "Escolha entre layout em grade e lista",
"list": "Lista",
"grid": "Grade"
},
"default_sort": {
"label": "Ordenação padrão",
"description": "Escolha a ordenação padrão para os arquivos",
"name": "Nome",
"size": "Tamanho",
"modified": "Modificado"
},
"sort_direction": {
"label": "Direção da ordenação",
"description": "Escolha ordem crescente ou decrescente",
"ascending": "Crescente",
"descending": "Decrescente"
},
"icons": {
"title": "Ícones",
"description": "Configure a aparência dos ícones de arquivos"
},
"show_icons": {
"label": "Mostrar ícones de arquivos",
"description": "Exibir ícones ao lado de arquivos e pastas"
},
"colored_icons": {
"label": "Ícones coloridos",
"description": "Usar ícones coloridos em vez de monocromáticos"
},
"show_thumbnails": {
"label": "Mostrar miniaturas",
"description": "Exibir pré-visualizações de imagens em vez de ícones"
},
"behavior": {
"title": "Comportamento",
"description": "Configure o comportamento do gerenciador de arquivos"
},
"show_hidden": {
"label": "Mostrar arquivos ocultos",
"description": "Exibir arquivos e pastas que começam com um ponto"
}
} }
}, },
"errors": { "errors": {
@@ -1208,7 +1266,9 @@
"identity_name": "Enviado usando identidade: {name}", "identity_name": "Enviado usando identidade: {name}",
"identity_short": "via {name}", "identity_short": "via {name}",
"subaddress_tag": "+{tag}" "subaddress_tag": "+{tag}"
} },
"delete_button": "Excluir",
"delete_confirm_title": "Excluir identidade"
}, },
"templates": { "templates": {
"picker_title": "Escolher um modelo", "picker_title": "Escolher um modelo",
@@ -1385,7 +1445,8 @@
"name_required": "É necessário pelo menos um nome ou sobrenome", "name_required": "É necessário pelo menos um nome ou sobrenome",
"email_invalid": "Por favor, insira um endereço de e-mail válido", "email_invalid": "Por favor, insira um endereço de e-mail válido",
"email_error_inline": "Formato de e-mail inválido", "email_error_inline": "Formato de e-mail inválido",
"save_failed": "Falha ao salvar contato" "save_failed": "Falha ao salvar contato",
"delete": "Excluir"
}, },
"groups": { "groups": {
"create": "Novo grupo", "create": "Novo grupo",
@@ -1663,7 +1724,40 @@
"error_delete": "Falha ao excluir calendário", "error_delete": "Falha ao excluir calendário",
"caldav_url": "URL CalDAV", "caldav_url": "URL CalDAV",
"copy_url": "Copiar URL CalDAV", "copy_url": "Copiar URL CalDAV",
"url_copied": "URL CalDAV copiada para a área de transferência" "url_copied": "URL CalDAV copiada para a área de transferência",
"confirm_clear": "Limpar todos os eventos de \"{name}\"? Esta ação não pode ser desfeita.",
"clear_events": "Limpar eventos",
"events_cleared": "{count} eventos removidos",
"error_clear": "Falha ao limpar os eventos do calendário"
},
"subscription": {
"title": "Assinatura iCal",
"section_title": "Assinaturas iCal",
"description": "Assine um feed externo do iCalendar. Os eventos serão sincronizados automaticamente em seu próprio calendário. Suporta URLs https:// e webcal://.",
"url_label": "URL do calendário",
"url_placeholder": "https://example.com/calendar.ics ou webcal://...",
"name_label": "Nome do calendário",
"name_placeholder": "ex. Feriados",
"color_label": "Cor",
"refresh_interval": "Intervalo de atualização",
"interval_15": "A cada 15 minutos",
"interval_30": "A cada 30 minutos",
"interval_60": "A cada hora",
"interval_360": "A cada 6 horas",
"interval_1440": "Diariamente",
"subscribe": "Assinar",
"subscribing": "Assinando...",
"invalid_url": "Por favor, insira uma URL válida",
"success": "Assinado \"{name}\"",
"error": "Falha ao adicionar assinatura",
"refresh": "Atualizar agora",
"refresh_success": "Assinatura atualizada",
"refresh_error": "Falha ao atualizar assinatura",
"unsubscribe": "Cancelar assinatura",
"confirm_delete": "Cancelar assinatura de \"{name}\"? O calendário e todos os seus eventos serão removidos.",
"deleted": "Assinatura removida",
"delete_error": "Falha ao remover assinatura",
"last_refreshed": "Última atualização: {time}"
} }
}, },
"advanced_search": { "advanced_search": {
@@ -1741,7 +1835,7 @@
"rename_success": "Renomeado com sucesso", "rename_success": "Renomeado com sucesso",
"rename_error": "Falha ao renomear", "rename_error": "Falha ao renomear",
"download_error": "Falha ao transferir", "download_error": "Falha ao transferir",
"not_available": "O armazenamento de ficheiros WebDAV não está disponível neste servidor", "not_available": "O armazenamento de ficheiros não está disponível neste servidor",
"cancel": "Cancelar", "cancel": "Cancelar",
"create": "Criar", "create": "Criar",
"save": "Guardar", "save": "Guardar",
@@ -1782,6 +1876,26 @@
"undo_error": "Falha ao desfazer", "undo_error": "Falha ao desfazer",
"toolbar": "Ações de arquivo", "toolbar": "Ações de arquivo",
"file_list": "Ficheiros e pastas", "file_list": "Ficheiros e pastas",
"context_menu": "Ações" "context_menu": "Ações",
"settings_title": "Configurações de arquivos",
"settings_display": "Exibição",
"settings_default_view": "Visualização padrão",
"settings_default_view_desc": "Escolha entre layout em grade e lista",
"settings_default_sort": "Ordenação padrão",
"settings_default_sort_desc": "Escolha a ordenação padrão para os arquivos",
"settings_sort_direction": "Direção da ordenação",
"settings_sort_direction_desc": "Escolha ordem crescente ou decrescente",
"settings_ascending": "Crescente",
"settings_descending": "Decrescente",
"settings_icons": "Ícones",
"settings_show_icons": "Mostrar ícones de arquivos",
"settings_show_icons_desc": "Exibir ícones ao lado de arquivos e pastas",
"settings_colored_icons": "Ícones coloridos",
"settings_colored_icons_desc": "Usar ícones coloridos em vez de monocromáticos",
"settings_show_thumbnails": "Mostrar miniaturas",
"settings_show_thumbnails_desc": "Exibir pré-visualizações de imagens em vez de ícones",
"settings_behavior": "Comportamento",
"settings_show_hidden": "Mostrar arquivos ocultos",
"settings_show_hidden_desc": "Exibir arquivos e pastas que começam com um ponto"
} }
} }
+757
View File
@@ -0,0 +1,757 @@
import { create } from 'zustand';
import type { JMAPClient } from '@/lib/jmap/client';
import type { FileNode } from '@/lib/jmap/types';
export interface FileResource {
id: string;
name: string;
serverName: string;
isDirectory: boolean;
contentType: string;
contentLength: number;
lastModified: string;
blobId: string | null;
parentId: string | null;
}
interface UploadProgress {
name: string;
loaded: number;
total: number;
current: number;
totalFiles: number;
}
interface ClipboardState {
mode: 'cut' | 'copy';
ids: string[];
names: string[];
serverNames: string[];
sourceParentId: string | null;
sourcePath: string;
}
interface UndoAction {
type: 'rename' | 'move';
entries: { id: string; from: Partial<Pick<FileNode, 'name' | 'parentId'>>; to: Partial<Pick<FileNode, 'name' | 'parentId'>> }[];
sourceParentId: string | null;
}
interface FileState {
currentParentId: string | null;
currentPath: string;
pathStack: { id: string | null; name: string }[];
resources: FileResource[];
isLoading: boolean;
error: string | null;
supportsFiles: boolean | null;
selectedResources: Set<string>;
uploadProgress: UploadProgress | null;
client: JMAPClient | null;
clipboard: ClipboardState | null;
uploadAbortController: AbortController | null;
favorites: string[];
recentFiles: { name: string; id: string; timestamp: number }[];
lastAction: UndoAction | null;
// Actions
initClient: (client: JMAPClient) => void;
checkSupport: () => Promise<boolean>;
navigate: (parentId: string | null, name?: string) => Promise<void>;
navigateByPath: (path: string) => Promise<void>;
navigateUp: () => Promise<void>;
refresh: () => Promise<void>;
createDirectory: (name: string) => Promise<void>;
uploadFile: (file: File) => Promise<void>;
uploadFiles: (files: File[]) => Promise<void>;
uploadFolder: (files: File[]) => Promise<void>;
cancelUpload: () => void;
deleteResource: (name: string) => Promise<void>;
deleteResources: (names: string[]) => Promise<void>;
renameResource: (oldName: string, newName: string) => Promise<void>;
downloadResource: (name: string) => Promise<void>;
downloadResources: (names: string[]) => Promise<void>;
getImageUrl: (name: string) => Promise<string>;
getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>;
createTextFile: (name: string) => Promise<void>;
duplicateResource: (name: string) => Promise<void>;
moveToFolder: (names: string[], targetFolder: string) => Promise<void>;
moveToParent: (names: string[]) => Promise<void>;
cutResources: (names: string[]) => void;
copyResources: (names: string[]) => void;
pasteResources: () => Promise<void>;
selectResource: (name: string | null) => void;
toggleSelect: (name: string) => void;
selectAll: () => void;
clearSelection: () => void;
setSelection: (names: Set<string>) => void;
listPath: (path: string) => Promise<FileResource[]>;
listByParentId: (parentId: string | null) => Promise<FileResource[]>;
toggleFavorite: (path: string) => void;
addRecentFile: (name: string, id: string) => void;
undoLastAction: () => Promise<void>;
}
const DIRECTORY_TYPES = new Set(['d', 'application/x-directory', 'text/directory', 'httpd/unix-directory', 'inode/directory']);
// Stalwart rejects "/" in file names, so we use Unicode DIVISION SLASH as the
// path separator when encoding folder hierarchy into flat file names.
const PATH_SEP = '\u2215'; //
function isDirectoryType(type: string | undefined): boolean {
if (!type) return false;
return DIRECTORY_TYPES.has(type) || type.includes('directory');
}
// Convert currentPath to a server-side name prefix for filtering
// "/" -> "", "/test" -> "test", "/test/sub" -> "testsub"
function getPathPrefix(currentPath: string): string {
if (currentPath === '/') return '';
return currentPath.slice(1).replace(/\//g, PATH_SEP) + PATH_SEP;
}
// Filter nodes to only direct children of a path prefix
function filterNodesByPrefix(nodes: FileNode[], prefix: string): FileNode[] {
if (prefix === '') {
// Root: nodes whose names have no PATH_SEP
return nodes.filter(n => !n.name.includes(PATH_SEP));
}
// Subfolder: nodes starting with prefix, with no additional PATH_SEP after the prefix
return nodes.filter(n => {
if (!n.name.startsWith(prefix)) return false;
const remaining = n.name.slice(prefix.length);
return remaining.length > 0 && !remaining.includes(PATH_SEP);
});
}
function nodeToResource(node: FileNode, pathPrefix: string = ''): FileResource {
const displayName = pathPrefix && node.name.startsWith(pathPrefix)
? node.name.slice(pathPrefix.length)
: node.name;
const isDir = isDirectoryType(node.type);
return {
id: node.id,
name: displayName,
serverName: node.name,
isDirectory: isDir,
contentType: isDir ? '' : node.type,
contentLength: node.size,
lastModified: node.updated || node.created,
blobId: node.blobId,
parentId: node.parentId,
};
}
function getUniqueName(name: string, existingNames: Set<string>): string {
if (!existingNames.has(name)) return name;
const dotIndex = name.lastIndexOf('.');
const base = dotIndex > 0 ? name.substring(0, dotIndex) : name;
const ext = dotIndex > 0 ? name.substring(dotIndex) : '';
let counter = 1;
while (existingNames.has(`${base} (${counter})${ext}`)) counter++;
return `${base} (${counter})${ext}`;
}
function buildPathFromStack(stack: { id: string | null; name: string }[]): string {
if (stack.length <= 1) return '/';
return '/' + stack.slice(1).map(s => s.name).join('/');
}
export const useFileStore = create<FileState>((set, get) => ({
currentParentId: null,
currentPath: '/',
pathStack: [{ id: null, name: '' }],
resources: [],
isLoading: false,
error: null,
supportsFiles: null,
selectedResources: new Set<string>(),
uploadProgress: null,
client: null,
clipboard: null,
uploadAbortController: null,
lastAction: null,
favorites: (() => {
try { return JSON.parse(localStorage.getItem('files-favorites') || '[]'); } catch { return []; }
})(),
recentFiles: (() => {
try { return JSON.parse(localStorage.getItem('files-recent-files') || '[]'); } catch { return []; }
})(),
initClient: (client: JMAPClient) => {
set({ client });
},
checkSupport: async () => {
const { client } = get();
if (!client) {
set({ supportsFiles: false });
return false;
}
// First check capability, then probe with a real request
const supported = await client.probeFileNodeSupport();
if (!supported) {
console.warn('[Files] JMAP FileNode not supported. Available capabilities:', Object.keys(client.getCapabilities()));
}
set({ supportsFiles: supported });
return supported;
},
navigate: async (parentId: string | null, name?: string) => {
const { client, pathStack } = get();
if (!client) return;
set({ isLoading: true, error: null, currentParentId: parentId, selectedResources: new Set() });
// Update path stack
let newStack: { id: string | null; name: string }[];
if (parentId === null) {
newStack = [{ id: null, name: '' }];
} else {
// Check if navigating to a parent in the stack
const existingIdx = pathStack.findIndex(s => s.id === parentId);
if (existingIdx >= 0) {
newStack = pathStack.slice(0, existingIdx + 1);
} else {
newStack = [...pathStack, { id: parentId, name: name || parentId }];
}
}
const newPath = buildPathFromStack(newStack);
set({ pathStack: newStack, currentPath: newPath });
try { localStorage.setItem('files-last-parent-id', parentId || ''); } catch { /* ignore */ }
try { localStorage.setItem('files-path-stack', JSON.stringify(newStack)); } catch { /* ignore */ }
try {
// Always fetch all nodes from root — Stalwart doesn't support parentId nesting
const allNodes = await client.listFileNodes(null);
const prefix = getPathPrefix(newPath);
const filteredNodes = filterNodesByPrefix(allNodes, prefix);
const resources = filteredNodes.map(n => nodeToResource(n, prefix));
// Sort: directories first, then alphabetically
resources.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
set({ resources, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to list directory',
isLoading: false,
resources: [],
});
}
},
navigateByPath: async (path: string) => {
const { pathStack, navigate } = get();
if (path === '/') {
await navigate(null);
return;
}
// Try to match the path against the current pathStack
const segments = path.split('/').filter(Boolean);
const targetDepth = segments.length;
// pathStack[0] is root (id: null, name: ''), subsequent entries match path segments
if (targetDepth < pathStack.length) {
const entry = pathStack[targetDepth];
// Verify the names match
const stackPath = pathStack.slice(1, targetDepth + 1).map(s => s.name).join('/');
if (stackPath === segments.join('/')) {
await navigate(entry.id, entry.name);
return;
}
}
// Fallback: if we can't resolve, stay at current location
},
navigateUp: async () => {
const { pathStack, navigate } = get();
if (pathStack.length <= 1) return;
const parent = pathStack[pathStack.length - 2];
await navigate(parent.id, parent.name);
},
refresh: async () => {
const { currentParentId, navigate, pathStack } = get();
const currentEntry = pathStack[pathStack.length - 1];
await navigate(currentParentId, currentEntry?.name);
},
createDirectory: async (name: string) => {
const { client, currentPath, refresh } = get();
if (!client) return;
const prefix = getPathPrefix(currentPath);
const fullName = prefix + name;
await client.createFileDirectory(fullName, null);
await refresh();
},
uploadFile: async (file: File) => {
const { client, currentPath } = get();
if (!client) return;
const prefix = getPathPrefix(currentPath);
const fullName = prefix + file.name;
const abortController = new AbortController();
set({ uploadAbortController: abortController });
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: 1, totalFiles: 1 } });
try {
if (abortController.signal.aborted) return;
const { blobId, type } = await client.uploadBlob(file);
if (abortController.signal.aborted) return;
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: 1, totalFiles: 1 } });
await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
} finally {
set({ uploadProgress: null, uploadAbortController: null });
}
},
uploadFiles: async (files: File[]) => {
const { client, currentPath, resources } = get();
if (!client) return;
const prefix = getPathPrefix(currentPath);
const abortController = new AbortController();
set({ uploadAbortController: abortController });
const totalFiles = files.length;
const existingNames = new Set(resources.map(r => r.name));
for (let i = 0; i < files.length; i++) {
if (abortController.signal.aborted) break;
const file = files[i];
const uniqueName = getUniqueName(file.name, existingNames);
existingNames.add(uniqueName);
const fullName = prefix + uniqueName;
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: i + 1, totalFiles } });
try {
const { blobId, type } = await client.uploadBlob(file);
if (abortController.signal.aborted) break;
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') break;
set({ uploadProgress: null, uploadAbortController: null });
throw err;
}
}
set({ uploadProgress: null, uploadAbortController: null });
await get().refresh();
},
cancelUpload: () => {
const { uploadAbortController } = get();
if (uploadAbortController) {
uploadAbortController.abort();
set({ uploadProgress: null, uploadAbortController: null });
}
},
uploadFolder: async (files: File[]) => {
const { client, currentPath } = get();
if (!client || files.length === 0) return;
const prefix = getPathPrefix(currentPath);
const abortController = new AbortController();
set({ uploadAbortController: abortController });
const totalFiles = files.length;
// Collect unique directory paths from the uploaded folder structure
const dirs = new Set<string>();
for (const file of files) {
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
const parts = relativePath.split('/');
for (let i = 1; i < parts.length; i++) {
dirs.add(parts.slice(0, i).join('/'));
}
}
// Create directories as flat entries with prefixed names (no parentId nesting)
const sortedDirs = [...dirs].sort((a, b) => a.split('/').length - b.split('/').length);
for (const dir of sortedDirs) {
if (abortController.signal.aborted) break;
const fullDirName = prefix + dir;
try {
await client.createFileDirectory(fullDirName, null);
} catch {
// Directory may already exist — ignore
}
}
// Upload files with full prefixed paths
for (let i = 0; i < files.length; i++) {
if (abortController.signal.aborted) break;
const file = files[i];
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
const fullName = prefix + relativePath;
set({ uploadProgress: { name: relativePath, loaded: 0, total: file.size, current: i + 1, totalFiles } });
try {
const { blobId, type } = await client.uploadBlob(file);
if (abortController.signal.aborted) break;
set({ uploadProgress: { name: relativePath, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') break;
set({ uploadProgress: null, uploadAbortController: null });
throw err;
}
}
set({ uploadProgress: null, uploadAbortController: null });
await get().refresh();
},
deleteResource: async (name: string) => {
const { client, resources, refresh } = get();
if (!client) return;
const resource = resources.find(r => r.name === name);
if (!resource) return;
const idsToDelete = [resource.id];
// If deleting a folder, also delete all files inside it
if (resource.isDirectory) {
const allNodes = await client.listFileNodes(null);
const folderPrefix = resource.serverName + PATH_SEP;
for (const node of allNodes) {
if (node.name.startsWith(folderPrefix)) {
idsToDelete.push(node.id);
}
}
}
await client.destroyFileNodes(idsToDelete);
await refresh();
},
deleteResources: async (names: string[]) => {
const { client, resources, refresh } = get();
if (!client) return;
const idsToDelete: string[] = [];
let allNodes: FileNode[] | null = null;
for (const name of names) {
const resource = resources.find(r => r.name === name);
if (!resource) continue;
idsToDelete.push(resource.id);
if (resource.isDirectory) {
if (!allNodes) allNodes = await client.listFileNodes(null);
const folderPrefix = resource.serverName + PATH_SEP;
for (const node of allNodes) {
if (node.name.startsWith(folderPrefix)) {
idsToDelete.push(node.id);
}
}
}
}
if (idsToDelete.length === 0) return;
await client.destroyFileNodes(idsToDelete);
set({ selectedResources: new Set() });
await refresh();
},
renameResource: async (oldName: string, newName: string) => {
const { client, resources, currentPath, refresh } = get();
if (!client) return;
const resource = resources.find(r => r.name === oldName);
if (!resource) return;
const prefix = getPathPrefix(currentPath);
const oldServerName = resource.serverName;
const newServerName = prefix + newName;
await client.updateFileNode(resource.id, { name: newServerName });
// If renaming a folder, also rename all files inside it
if (resource.isDirectory) {
const allNodes = await client.listFileNodes(null);
const oldFolderPrefix = oldServerName + PATH_SEP;
const newFolderPrefix = newServerName + PATH_SEP;
for (const node of allNodes) {
if (node.name.startsWith(oldFolderPrefix)) {
const newNodeName = newFolderPrefix + node.name.slice(oldFolderPrefix.length);
await client.updateFileNode(node.id, { name: newNodeName });
}
}
}
set({
lastAction: {
type: 'rename',
entries: [{ id: resource.id, from: { name: oldServerName }, to: { name: newServerName } }],
sourceParentId: null,
},
});
await refresh();
},
downloadResource: async (name: string) => {
const { client, resources } = get();
if (!client) return;
const resource = resources.find(r => r.name === name);
if (!resource?.blobId) return;
await client.downloadBlob(resource.blobId, resource.name, resource.contentType);
},
downloadResources: async (names: string[]) => {
const { downloadResource } = get();
for (const name of names) {
await downloadResource(name);
}
},
getImageUrl: async (name: string) => {
const { client, resources } = get();
if (!client) throw new Error('No client');
const resource = resources.find(r => r.name === name);
if (!resource?.blobId) throw new Error('No blob');
return client.fetchBlobAsObjectUrl(resource.blobId, resource.name, resource.contentType);
},
getFileContent: async (name: string) => {
const { client, resources } = get();
if (!client) throw new Error('No client');
const resource = resources.find(r => r.name === name);
if (!resource?.blobId) throw new Error('No blob');
const url = client.getBlobDownloadUrl(resource.blobId, resource.name, resource.contentType);
const response = await fetch(url, {
headers: { 'Authorization': client.getAuthHeader() },
});
if (!response.ok) throw new Error(`Failed to fetch file: ${response.status}`);
const blob = await response.blob();
return { blob, contentType: resource.contentType || 'application/octet-stream' };
},
createTextFile: async (name: string) => {
const { client, currentPath, refresh } = get();
if (!client) return;
const prefix = getPathPrefix(currentPath);
const fullName = prefix + name;
const emptyBlob = new File([''], name, { type: 'text/plain' });
const { blobId } = await client.uploadBlob(emptyBlob);
await client.createFileNode(fullName, blobId, 'text/plain', 0, null);
await refresh();
},
duplicateResource: async (name: string) => {
const { client, resources, currentPath, refresh } = get();
if (!client) return;
const resource = resources.find(r => r.name === name);
if (!resource) return;
const prefix = getPathPrefix(currentPath);
const dotIdx = name.lastIndexOf('.');
const copyName = dotIdx > 0
? `${name.substring(0, dotIdx)} (copy)${name.substring(dotIdx)}`
: `${name} (copy)`;
const fullCopyName = prefix + copyName;
await client.copyFileNode(resource.id, fullCopyName, null);
await refresh();
},
moveToFolder: async (names: string[], targetFolder: string) => {
const { client, resources, refresh } = get();
if (!client) return;
const targetResource = resources.find(r => r.name === targetFolder && r.isDirectory);
if (!targetResource) return;
const entries: UndoAction['entries'] = [];
for (const name of names) {
const resource = resources.find(r => r.name === name);
if (!resource) continue;
const newServerName = targetResource.serverName + PATH_SEP + resource.name;
await client.updateFileNode(resource.id, { name: newServerName });
entries.push({ id: resource.id, from: { name: resource.serverName }, to: { name: newServerName } });
}
set({
selectedResources: new Set(),
lastAction: { type: 'move', entries, sourceParentId: null },
});
await refresh();
},
moveToParent: async (names: string[]) => {
const { client, resources, currentPath, refresh } = get();
if (!client || currentPath === '/') return;
const prefix = getPathPrefix(currentPath);
// Parent prefix: strip the last segment from the current prefix
// e.g. "foldersub" → "folder", "folder" → ""
const parentPrefix = prefix.slice(0, prefix.lastIndexOf(PATH_SEP, prefix.length - 2) + 1);
const entries: UndoAction['entries'] = [];
for (const name of names) {
const resource = resources.find(r => r.name === name);
if (!resource) continue;
const newServerName = parentPrefix + resource.name;
await client.updateFileNode(resource.id, { name: newServerName });
entries.push({ id: resource.id, from: { name: resource.serverName }, to: { name: newServerName } });
}
set({
selectedResources: new Set(),
lastAction: { type: 'move', entries, sourceParentId: null },
});
await refresh();
},
cutResources: (names: string[]) => {
const { currentPath, resources } = get();
const ids = names.map(n => resources.find(r => r.name === n)?.id).filter(Boolean) as string[];
const serverNames = names.map(n => resources.find(r => r.name === n)?.serverName).filter(Boolean) as string[];
set({ clipboard: { mode: 'cut', ids, names, serverNames, sourceParentId: null, sourcePath: currentPath } });
},
copyResources: (names: string[]) => {
const { currentPath, resources } = get();
const ids = names.map(n => resources.find(r => r.name === n)?.id).filter(Boolean) as string[];
const serverNames = names.map(n => resources.find(r => r.name === n)?.serverName).filter(Boolean) as string[];
set({ clipboard: { mode: 'copy', ids, names, serverNames, sourceParentId: null, sourcePath: currentPath } });
},
pasteResources: async () => {
const { client, currentPath, clipboard, refresh } = get();
if (!client || !clipboard) return;
const prefix = getPathPrefix(currentPath);
const entries: UndoAction['entries'] = [];
for (let i = 0; i < clipboard.ids.length; i++) {
const id = clipboard.ids[i];
const displayName = clipboard.names[i];
const oldServerName = clipboard.serverNames?.[i];
if (clipboard.mode === 'cut') {
const newServerName = prefix + displayName;
await client.updateFileNode(id, { name: newServerName });
entries.push({ id, from: { name: oldServerName }, to: { name: newServerName } });
} else {
const fullName = prefix + displayName;
await client.copyFileNode(id, fullName, null);
}
}
if (clipboard.mode === 'cut') {
set({
clipboard: null,
lastAction: { type: 'move', entries, sourceParentId: null },
});
}
await refresh();
},
selectResource: (name: string | null) => {
set({ selectedResources: name ? new Set([name]) : new Set() });
},
toggleSelect: (name: string) => {
const { selectedResources } = get();
const next = new Set(selectedResources);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
set({ selectedResources: next });
},
selectAll: () => {
const { resources } = get();
set({ selectedResources: new Set(resources.map(r => r.name)) });
},
clearSelection: () => {
set({ selectedResources: new Set() });
},
setSelection: (names: Set<string>) => {
set({ selectedResources: new Set(names) });
},
listPath: async (path: string) => {
const { client } = get();
if (!client) return [];
try {
const allNodes = await client.listFileNodes(null);
const prefix = getPathPrefix(path);
const filtered = filterNodesByPrefix(allNodes, prefix);
return filtered.map(n => nodeToResource(n, prefix));
} catch {
return [];
}
},
listByParentId: async (parentId: string | null) => {
const { client } = get();
if (!client) return [];
try {
const allNodes = await client.listFileNodes(null);
if (parentId === null) {
// Root level: nodes with simple names (no "/")
const rootNodes = allNodes.filter(n => !n.name.includes('/'));
return rootNodes.map(n => nodeToResource(n));
}
// Find the folder node to get its server name
const folder = allNodes.find(n => n.id === parentId);
if (!folder) return [];
const prefix = folder.name + PATH_SEP;
const filtered = filterNodesByPrefix(allNodes, prefix);
return filtered.map(n => nodeToResource(n, prefix));
} catch {
return [];
}
},
toggleFavorite: (path: string) => {
const { favorites } = get();
const next = favorites.includes(path)
? favorites.filter(f => f !== path)
: [...favorites, path];
set({ favorites: next });
try { localStorage.setItem('files-favorites', JSON.stringify(next)); } catch { /* ignore */ }
},
addRecentFile: (name: string, id: string) => {
const { recentFiles } = get();
const entry = { name, id, timestamp: Date.now() };
const filtered = recentFiles.filter(r => r.id !== id);
const next = [entry, ...filtered].slice(0, 20);
set({ recentFiles: next });
try { localStorage.setItem('files-recent-files', JSON.stringify(next)); } catch { /* ignore */ }
},
undoLastAction: async () => {
const { client, lastAction, refresh } = get();
if (!client || !lastAction) return;
for (const entry of lastAction.entries) {
await client.updateFileNode(entry.id, entry.from);
}
set({ lastAction: null });
await refresh();
},
}));