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 { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useWebDAVStore } from "@/stores/webdav-store";
import { useFileStore } from "@/stores/file-store";
import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail";
@@ -17,11 +17,13 @@ import { useIsMobile } from "@/hooks/use-media-query";
import { FileBrowser } from "@/components/files/file-browser";
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
export default function FilesPage() {
const router = useRouter();
const t = useTranslations("files");
const { isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const {
@@ -29,13 +31,14 @@ export default function FilesPage() {
resources,
isLoading,
error,
supportsWebDAV,
supportsFiles,
selectedResources,
uploadProgress,
clipboard,
initClient,
checkSupport,
navigate,
navigateByPath,
refresh,
createDirectory,
uploadFile,
@@ -51,6 +54,7 @@ export default function FilesPage() {
duplicateResource,
downloadResources,
moveToFolder,
moveToParent,
cutResources,
copyResources,
pasteResources,
@@ -60,6 +64,7 @@ export default function FilesPage() {
clearSelection,
setSelection,
listPath,
listByParentId,
favorites,
recentFiles,
toggleFavorite,
@@ -67,10 +72,23 @@ export default function FilesPage() {
cancelUpload,
undoLastAction,
lastAction,
} = useWebDAVStore();
} = useFileStore();
const isMobile = useIsMobile();
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
const hasFetched = useRef(false);
// Sync folderLayout when settings change
useEffect(() => {
const reload = () => setFolderLayout(loadFilesSettings().folderLayout);
const handleStorage = (e: StorageEvent) => { if (e.key === "files-settings") reload(); };
window.addEventListener("storage", handleStorage);
window.addEventListener("files-settings-changed", reload);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener("files-settings-changed", reload);
};
}, []);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewFile, setPreviewFile] = useState<string | null>(null);
@@ -94,34 +112,35 @@ export default function FilesPage() {
}
}, [initialCheckDone, isAuthenticated, authLoading, router]);
// Initialize WebDAV client
// Initialize JMAP files client
useEffect(() => {
if (isAuthenticated && !hasFetched.current) {
if (isAuthenticated && client && !hasFetched.current) {
hasFetched.current = true;
initClient();
initClient(client);
}
}, [isAuthenticated, initClient]);
}, [isAuthenticated, client, initClient]);
// Check support and load root after client is initialized
const { webdavClient } = useWebDAVStore();
const storeClient = useFileStore(s => s.client);
useEffect(() => {
if (webdavClient && supportsWebDAV === null) {
if (storeClient && supportsFiles === null) {
checkSupport().then((supported) => {
if (supported) {
let initialPath = '/';
try {
const saved = localStorage.getItem('webdav-last-path');
if (saved) initialPath = saved;
} catch { /* ignore */ }
navigate(initialPath);
navigate(null);
}
});
}
}, [webdavClient, supportsWebDAV, checkSupport, navigate]);
}, [storeClient, supportsFiles, checkSupport, navigate]);
const handleNavigate = useCallback((path: string) => {
navigate(path);
}, [navigate]);
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
if (resourceId !== undefined) {
// Direct ID-based navigation (directory click, breadcrumb dropdown folder)
navigate(resourceId, path.split('/').pop() || '');
} else {
// Path-based navigation (breadcrumbs, favorites, recent files)
navigateByPath(path);
}
}, [navigate, navigateByPath]);
const handleCreateFolder = useCallback(async (name: string) => {
try {
@@ -225,15 +244,20 @@ export default function FilesPage() {
}
}, [renameResource, t, handleUndo]);
const findResourceId = useCallback((name: string) => {
const r = resources.find(res => res.name === name);
return r?.id || name;
}, [resources]);
const handleDownload = useCallback(async (name: string) => {
try {
await downloadResource(name);
addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name);
addRecentFile(name, findResourceId(name));
} catch (err) {
console.error("Failed to download:", err);
toast.error(t("download_error"));
}
}, [downloadResource, addRecentFile, currentPath, t]);
}, [downloadResource, addRecentFile, findResourceId, t]);
const handleBatchDownload = useCallback(async (names: string[]) => {
try {
@@ -276,6 +300,18 @@ export default function FilesPage() {
}
}, [moveToFolder, t, handleUndo]);
const handleMoveToParent = useCallback(async (names: string[]) => {
try {
await moveToParent(names);
toast.success(t("move_success", { count: names.length }), {
action: { label: t("undo"), onClick: handleUndo },
});
} catch (err) {
console.error("Failed to move:", err);
toast.error(t("move_error"));
}
}, [moveToParent, t, handleUndo]);
const handlePaste = useCallback(async () => {
try {
await pasteResources();
@@ -290,13 +326,13 @@ export default function FilesPage() {
const handlePreviewImage = useCallback((name: string) => {
setPreviewImage(name);
addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name);
}, [addRecentFile, currentPath]);
addRecentFile(name, findResourceId(name));
}, [addRecentFile, findResourceId]);
const handlePreviewFile = useCallback((name: string) => {
setPreviewFile(name);
addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name);
}, [addRecentFile, currentPath]);
addRecentFile(name, findResourceId(name));
}, [addRecentFile, findResourceId]);
const handleShowDetails = useCallback((name: string) => {
setDetailName(name);
@@ -325,22 +361,24 @@ export default function FilesPage() {
<div className="flex flex-col flex-1 min-w-0">
<div className="flex flex-1 min-h-0">
<div className="flex-1 min-w-0 flex flex-col">
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/")}
className="justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("title")}
</Button>
{folderLayout !== "sidebar" && (
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/")}
className="justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("title")}
</Button>
</div>
</div>
</div>
)}
<div className="flex-1 min-h-0">
{supportsWebDAV === false ? (
{supportsFiles === false ? (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
</div>
@@ -373,6 +411,7 @@ export default function FilesPage() {
onCopy={copyResources}
onPaste={handlePaste}
onMoveToFolder={handleMoveToFolder}
onMoveToParent={handleMoveToParent}
onPreviewImage={handlePreviewImage}
onPreviewFile={handlePreviewFile}
onShowDetails={handleShowDetails}
@@ -380,6 +419,7 @@ export default function FilesPage() {
onDuplicate={handleDuplicate}
getImageUrl={getImageUrl}
listPath={listPath}
listByParentId={listByParentId}
favorites={favorites}
recentFiles={recentFiles}
onToggleFavorite={toggleFavorite}
+5 -1
View File
@@ -18,6 +18,7 @@ import { AdvancedSettings } from '@/components/settings/advanced-settings';
import { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useIsDesktop } from '@/hooks/use-media-query';
@@ -25,7 +26,7 @@ import { NavigationRail } from '@/components/layout/navigation-rail';
import { useConfig } from '@/hooks/use-config';
import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'folders' | 'keywords' | 'advanced';
type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'advanced';
export default function SettingsPage() {
const router = useRouter();
@@ -66,6 +67,7 @@ export default function SettingsPage() {
const supportsVacation = client?.supportsVacationResponse() ?? false;
const supportsCalendar = client?.supportsCalendars() ?? false;
const supportsSieve = client?.supportsSieve() ?? false;
const supportsFiles = client?.supportsFiles() ?? false;
const tabs: { id: Tab; label: string }[] = [
{ id: 'appearance', label: t('tabs.appearance') },
@@ -79,6 +81,7 @@ export default function SettingsPage() {
{ id: 'templates', label: t('tabs.templates') },
{ id: 'folders', label: t('tabs.folders') },
{ id: 'keywords', label: t('tabs.keywords') },
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files') }] : []),
{ id: 'advanced', label: t('tabs.advanced') },
];
@@ -105,6 +108,7 @@ export default function SettingsPage() {
{activeTab === 'templates' && <TemplateSettings />}
{activeTab === 'folders' && <FolderSettings />}
{activeTab === 'keywords' && <KeywordSettings />}
{activeTab === 'files' && <FilesSettingsComponent />}
{activeTab === 'advanced' && <AdvancedSettings />}
</>
);
+224 -78
View File
@@ -9,13 +9,19 @@ import {
Copy, Clipboard, Scissors, Info, Image as ImageIcon,
FilePlus, CopyPlus, FileText, FileAudio, FileVideo,
AlertCircle, Star, Clock, FolderUp,
FileArchive, FileSpreadsheet, Presentation, FileCode,
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn, formatFileSize } from "@/lib/utils";
import { NewFolderDialog } from "@/components/files/new-folder-dialog";
import { RenameDialog } from "@/components/files/rename-dialog";
import { FileUploadArea } from "@/components/files/file-upload-area";
import type { WebDAVResource } from "@/lib/webdav/client";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar";
import { ResizeHandle } from "@/components/layout/resize-handle";
import type { FileResource } from "@/stores/file-store";
type SortKey = "name" | "size" | "modified";
type SortDir = "asc" | "desc";
@@ -23,20 +29,20 @@ type ViewMode = "list" | "grid";
interface ClipboardState {
mode: "cut" | "copy";
paths: string[];
ids: string[];
names: string[];
sourcePath: string;
sourceParentId: string | null;
}
interface FileBrowserProps {
currentPath: string;
resources: WebDAVResource[];
resources: FileResource[];
isLoading: boolean;
error: string | null;
selectedResources: Set<string>;
uploadProgress: { name: string; loaded: number; total: number; current: number; totalFiles: number } | null;
clipboard: ClipboardState | null;
onNavigate: (path: string) => void;
onNavigate: (path: string, resourceId?: string | null) => void;
onCreateFolder: (name: string) => Promise<void>;
onUploadFiles: (files: File[]) => Promise<void>;
onUploadFolder: (files: File[]) => Promise<void>;
@@ -56,19 +62,21 @@ interface FileBrowserProps {
onCopy: (names: string[]) => void;
onPaste: () => Promise<void>;
onMoveToFolder: (names: string[], targetFolder: string) => Promise<void>;
onMoveToParent: (names: string[]) => Promise<void>;
onPreviewImage: (name: string) => void;
onPreviewFile: (name: string) => void;
onShowDetails: (name: string) => void;
onCreateTextFile: (name: string) => Promise<void>;
onDuplicate: (name: string) => Promise<void>;
getImageUrl: (name: string) => Promise<string>;
listPath: (path: string) => Promise<WebDAVResource[]>;
listPath: (path: string) => Promise<FileResource[]>;
listByParentId: (parentId: string | null) => Promise<FileResource[]>;
favorites: string[];
recentFiles: { name: string; path: string; timestamp: number }[];
recentFiles: { name: string; id: string; timestamp: number }[];
onToggleFavorite: (path: string) => void;
showDetails: boolean;
onToggleDetails: () => void;
detailResource: WebDAVResource | null;
detailResource: FileResource | null;
}
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
@@ -110,48 +118,100 @@ function isPdfFile(name: string): boolean {
return PDF_EXTENSIONS.has(ext);
}
const VECTOR_EXTENSIONS = new Set(["svg", "ai", "eps", "ps", "sketch", "fig", "xd", "gvdesign"]);
function isVectorFile(name: string): boolean {
const ext = name.split(".").pop()?.toLowerCase() || "";
return VECTOR_EXTENSIONS.has(ext);
}
const THREE_D_EXTENSIONS = new Set([
"obj", "fbx", "gltf", "glb", "stl", "3mf", "step", "stp", "iges", "igs",
"blend", "3ds", "dae", "usdz", "usd", "usda", "usdc", "ply", "wrl",
"c4d", "max", "ma", "mb", "dwg", "dxf",
]);
function is3DFile(name: string): boolean {
const ext = name.split(".").pop()?.toLowerCase() || "";
return THREE_D_EXTENSIONS.has(ext);
}
const EXECUTABLE_EXTENSIONS = new Set([
"exe", "msi", "dmg", "app", "appimage", "deb", "rpm", "snap", "flatpak",
"bat", "cmd", "com", "scr", "ps1", "apk", "ipa", "jar", "run",
]);
function isExecutableFile(name: string): boolean {
const ext = name.split(".").pop()?.toLowerCase() || "";
return EXECUTABLE_EXTENSIONS.has(ext);
}
const ARCHIVE_EXTENSIONS = new Set([
"zip", "rar", "7z", "tar", "gz", "bz2", "xz", "zst", "lz", "lzma",
"tgz", "tbz2", "txz", "cab", "iso", "img",
]);
function isArchiveFile(name: string): boolean {
const ext = name.split(".").pop()?.toLowerCase() || "";
return ARCHIVE_EXTENSIONS.has(ext);
}
const SPREADSHEET_EXTENSIONS = new Set(["xls", "xlsx", "ods", "numbers", "tsv"]);
function isSpreadsheetFile(name: string): boolean {
const ext = name.split(".").pop()?.toLowerCase() || "";
return SPREADSHEET_EXTENSIONS.has(ext);
}
const PRESENTATION_EXTENSIONS = new Set(["ppt", "pptx", "odp", "key"]);
function isPresentationFile(name: string): boolean {
const ext = name.split(".").pop()?.toLowerCase() || "";
return PRESENTATION_EXTENSIONS.has(ext);
}
const FONT_EXTENSIONS = new Set(["ttf", "otf", "woff", "woff2", "eot"]);
function isFontFile(name: string): boolean {
const ext = name.split(".").pop()?.toLowerCase() || "";
return FONT_EXTENSIONS.has(ext);
}
const DATABASE_EXTENSIONS = new Set(["db", "sqlite", "sqlite3", "mdb", "accdb"]);
function isDatabaseFile(name: string): boolean {
const ext = name.split(".").pop()?.toLowerCase() || "";
return DATABASE_EXTENSIONS.has(ext);
}
function isPreviewable(name: string): boolean {
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
}
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
function getFileIcon(resource: WebDAVResource) {
function getFileIconByName(name: string, size: "sm" | "lg") {
const cls = size === "sm" ? "w-5 h-5" : "w-10 h-10";
if (isVectorFile(name)) return <PenTool className={`${cls} text-orange-500`} />;
if (is3DFile(name)) return <Box className={`${cls} text-cyan-500`} />;
if (isImageFile(name)) return <ImageIcon className={`${cls} text-emerald-500`} />;
if (isAudioFile(name)) return <FileAudio className={`${cls} text-purple-500`} />;
if (isVideoFile(name)) return <FileVideo className={`${cls} text-pink-500`} />;
if (isArchiveFile(name)) return <FileArchive className={`${cls} text-amber-600`} />;
if (isExecutableFile(name)) return <TerminalIcon className={`${cls} text-red-500`} />;
if (isSpreadsheetFile(name)) return <FileSpreadsheet className={`${cls} text-green-600`} />;
if (isPresentationFile(name)) return <Presentation className={`${cls} text-orange-600`} />;
if (isFontFile(name)) return <TypeIcon className={`${cls} text-indigo-500`} />;
if (isDatabaseFile(name)) return <Database className={`${cls} text-slate-500`} />;
if (isPdfFile(name)) return <FileText className={`${cls} text-red-600`} />;
if (isTextFile(name)) return <FileCode className={`${cls} text-yellow-600`} />;
return <File className={`${cls} text-muted-foreground`} />;
}
function getFileIcon(resource: FileResource) {
if (resource.isDirectory) {
return <Folder className="w-5 h-5 text-blue-500" />;
}
if (isImageFile(resource.name)) {
return <ImageIcon className="w-5 h-5 text-emerald-500" />;
}
if (isAudioFile(resource.name)) {
return <FileAudio className="w-5 h-5 text-purple-500" />;
}
if (isVideoFile(resource.name)) {
return <FileVideo className="w-5 h-5 text-pink-500" />;
}
if (isTextFile(resource.name)) {
return <FileText className="w-5 h-5 text-yellow-600" />;
}
return <File className="w-5 h-5 text-muted-foreground" />;
return getFileIconByName(resource.name, "sm");
}
function getGridIcon(resource: WebDAVResource) {
function getGridIcon(resource: FileResource) {
if (resource.isDirectory) {
return <Folder className="w-10 h-10 text-blue-500" />;
}
if (isImageFile(resource.name)) {
return <ImageIcon className="w-10 h-10 text-emerald-500" />;
}
if (isAudioFile(resource.name)) {
return <FileAudio className="w-10 h-10 text-purple-500" />;
}
if (isVideoFile(resource.name)) {
return <FileVideo className="w-10 h-10 text-pink-500" />;
}
if (isTextFile(resource.name)) {
return <FileText className="w-10 h-10 text-yellow-600" />;
}
return <File className="w-10 h-10 text-muted-foreground" />;
return getFileIconByName(resource.name, "lg");
}
function Thumbnail({ name, getImageUrl: fetchUrl, size = "sm" }: {
@@ -246,6 +306,7 @@ export function FileBrowser({
onCopy,
onPaste,
onMoveToFolder,
onMoveToParent,
onPreviewImage,
onPreviewFile,
onShowDetails,
@@ -253,6 +314,7 @@ export function FileBrowser({
onDuplicate,
getImageUrl,
listPath,
listByParentId,
favorites,
recentFiles,
onToggleFavorite,
@@ -275,14 +337,44 @@ export function FileBrowser({
const [sortDir, setSortDir] = useState<SortDir>("asc");
const [viewMode, setViewMode] = useState<ViewMode>(() => {
if (typeof window !== "undefined") {
return (localStorage.getItem("webdav-view-mode") as ViewMode) || "list";
return (localStorage.getItem("files-view-mode") as ViewMode) || "list";
}
return "list";
});
const [showThumbnails, setShowThumbnails] = useState(() => loadFilesSettings().showThumbnails);
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
const [sidebarWidth, setSidebarWidth] = useState(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("files-sidebar-width");
if (saved) return Math.max(180, Math.min(400, Number(saved)));
}
return 220;
});
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(220);
const [dragTarget, setDragTarget] = useState<string | null>(null);
// Sync showThumbnails and folderLayout when settings change
useEffect(() => {
const reloadSettings = () => {
const s = loadFilesSettings();
setShowThumbnails(s.showThumbnails);
setFolderLayout(s.folderLayout);
};
const handleStorage = (e: StorageEvent) => {
if (e.key === "files-settings") reloadSettings();
};
// StorageEvent fires cross-tab, custom event fires same-tab
window.addEventListener("storage", handleStorage);
window.addEventListener("files-settings-changed", reloadSettings);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener("files-settings-changed", reloadSettings);
};
}, []);
const [breadcrumbDropdown, setBreadcrumbDropdown] = useState<{
path: string;
folders: WebDAVResource[];
folders: FileResource[];
x: number;
y: number;
} | null>(null);
@@ -315,15 +407,19 @@ export function FileBrowser({
// Persist view mode
const handleViewModeChange = useCallback((mode: ViewMode) => {
setViewMode(mode);
localStorage.setItem("webdav-view-mode", mode);
localStorage.setItem("files-view-mode", mode);
}, []);
// Filter and sort resources
const displayResources = useMemo(() => {
let filtered = resources;
// In sidebar mode, folders are shown in the sidebar tree — hide them from the main list
if (folderLayout === "sidebar") {
filtered = filtered.filter(r => !r.isDirectory);
}
if (searchQuery) {
const q = searchQuery.toLowerCase();
filtered = resources.filter(r => r.name.toLowerCase().includes(q));
filtered = filtered.filter(r => r.name.toLowerCase().includes(q));
}
const sorted = [...filtered].sort((a, b) => {
@@ -345,7 +441,7 @@ export function FileBrowser({
return sortDir === "asc" ? cmp : -cmp;
});
return sorted;
}, [resources, searchQuery, sortKey, sortDir]);
}, [resources, searchQuery, sortKey, sortDir, folderLayout]);
// Build breadcrumb segments
const breadcrumbs = currentPath === '/'
@@ -363,10 +459,10 @@ export function FileBrowser({
const segments = currentPath.split('/').filter(Boolean);
segments.pop();
const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/');
onNavigate(parentPath);
onNavigate(parentPath, null);
};
const handleResourceClick = (resource: WebDAVResource, e: React.MouseEvent) => {
const handleResourceClick = (resource: FileResource, e: React.MouseEvent) => {
if (resource.isDirectory) {
// Ctrl/Cmd+click on directories also toggles selection
if (e.ctrlKey || e.metaKey) {
@@ -376,7 +472,7 @@ export function FileBrowser({
const newPath = currentPath === '/'
? `/${resource.name}`
: `${currentPath}/${resource.name}`;
onNavigate(newPath);
onNavigate(newPath, resource.id);
} else {
if (e.ctrlKey || e.metaKey) {
onToggleSelect(resource.name);
@@ -491,12 +587,12 @@ export function FileBrowser({
};
}, [marquee, onSetSelection]);
const handleResourceDoubleClick = (resource: WebDAVResource) => {
const handleResourceDoubleClick = (resource: FileResource) => {
if (resource.isDirectory) {
const newPath = currentPath === '/'
? `/${resource.name}`
: `${currentPath}/${resource.name}`;
onNavigate(newPath);
onNavigate(newPath, resource.id);
} else if (isPreviewable(resource.name)) {
if (isImageFile(resource.name)) {
onPreviewImage(resource.name);
@@ -511,7 +607,10 @@ export function FileBrowser({
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(true);
// Only show upload overlay for external file drags, not internal resource drags
if (e.dataTransfer.types.includes("Files")) {
setIsDraggingOver(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
@@ -711,7 +810,7 @@ export function FileBrowser({
const newPath = currentPath === '/'
? `/${resource.name}`
: `${currentPath}/${resource.name}`;
onNavigate(newPath);
onNavigate(newPath, resource.id);
} else if (resource) {
onDownload(resource.name);
}
@@ -988,8 +1087,29 @@ export function FileBrowser({
{/* File list */}
<div className="flex-1 min-h-0 flex relative">
{/* Favorites & Recent sidebar */}
{(favorites.length > 0 || recentFiles.length > 0) && (
{/* Folder tree sidebar (when layout is sidebar) */}
{folderLayout === "sidebar" && (
<>
<FolderTreeSidebar
currentPath={currentPath}
onNavigate={onNavigate}
listByParentId={listByParentId}
width={sidebarWidth}
isResizing={isResizing}
/>
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem("files-sidebar-width", String(sidebarWidth));
}}
onDoubleClick={() => { setSidebarWidth(220); localStorage.setItem("files-sidebar-width", "220"); }}
/>
</>
)}
{/* Favorites & Recent sidebar (when layout is inline) */}
{folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && (
<div className="w-48 border-r border-border bg-background overflow-y-auto shrink-0 hidden lg:block">
{favorites.length > 0 && (
<div className="p-3">
@@ -1023,13 +1143,9 @@ export function FileBrowser({
<div className="space-y-0.5">
{recentFiles.slice(0, 10).map((recent) => (
<button
key={recent.path}
onClick={() => {
const dir = recent.path.substring(0, recent.path.lastIndexOf('/')) || '/';
onNavigate(dir);
}}
key={recent.id}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-left"
title={recent.path}
title={recent.name}
>
<File className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="truncate">{recent.name}</span>
@@ -1069,7 +1185,7 @@ export function FileBrowser({
<SkeletonRow />
</tbody>
</table>
) : resources.length === 0 && !searchQuery ? (
) : resources.length === 0 && !searchQuery && currentPath === '/' ? (
<FileUploadArea
onUpload={async (files: File[]) => {
setIsUploading(true);
@@ -1087,8 +1203,27 @@ export function FileBrowser({
<div className="p-4" role="grid" aria-label={t("file_list")}>
{currentPath !== '/' && !searchQuery && (
<div
className="inline-flex flex-col items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-muted/50 transition-colors w-28"
className={cn(
"inline-flex flex-col items-center gap-2 p-3 rounded-lg cursor-pointer transition-colors w-28",
dragTarget === '..' ? "bg-primary/5 ring-1 ring-primary/40" : "hover:bg-muted/50"
)}
onClick={handleNavigateUp}
onDragOver={(e) => {
if (e.dataTransfer.types.includes("application/x-file-names")) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragTarget('..');
}
}}
onDragLeave={() => setDragTarget(null)}
onDrop={async (e) => {
e.preventDefault();
setDragTarget(null);
const raw = e.dataTransfer.getData("application/x-file-names");
if (!raw) return;
const names: string[] = JSON.parse(raw);
await onMoveToParent(names);
}}
>
<Folder className="w-10 h-10 text-muted-foreground" />
<span className="text-xs text-muted-foreground truncate w-full text-center">..</span>
@@ -1107,12 +1242,12 @@ export function FileBrowser({
>
{displayResources.map((resource) => (
<div
key={resource.href}
key={resource.id}
data-resource={resource.name}
draggable
onDragStart={(e) => {
const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name];
e.dataTransfer.setData("application/x-webdav-names", JSON.stringify(names));
e.dataTransfer.setData("application/x-file-names", JSON.stringify(names));
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
@@ -1127,7 +1262,7 @@ export function FileBrowser({
e.preventDefault();
setDragTarget(null);
if (!resource.isDirectory) return;
const raw = e.dataTransfer.getData("application/x-webdav-names");
const raw = e.dataTransfer.getData("application/x-file-names");
if (!raw) return;
const names: string[] = JSON.parse(raw);
if (names.includes(resource.name)) return;
@@ -1154,7 +1289,7 @@ export function FileBrowser({
data-checked={selectedResources.has(resource.name)}
onClick={(e) => e.stopPropagation()}
/>
{isImageFile(resource.name)
{showThumbnails && isImageFile(resource.name)
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="lg" />
: getGridIcon(resource)}
<span className="text-xs truncate w-full text-center" title={resource.name}>
@@ -1212,8 +1347,27 @@ export function FileBrowser({
<tbody>
{currentPath !== '/' && !searchQuery && (
<tr
className="border-b border-border hover:bg-muted/50 cursor-pointer transition-colors"
className={cn(
"border-b border-border cursor-pointer transition-colors",
dragTarget === '..' ? "bg-primary/5 ring-1 ring-primary/40" : "hover:bg-muted/50"
)}
onClick={handleNavigateUp}
onDragOver={(e) => {
if (e.dataTransfer.types.includes("application/x-file-names")) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragTarget('..');
}
}}
onDragLeave={() => setDragTarget(null)}
onDrop={async (e) => {
e.preventDefault();
setDragTarget(null);
const raw = e.dataTransfer.getData("application/x-file-names");
if (!raw) return;
const names: string[] = JSON.parse(raw);
await onMoveToParent(names);
}}
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-3">
@@ -1235,13 +1389,13 @@ export function FileBrowser({
</tr>
) : displayResources.map((resource) => (
<tr
key={resource.href}
key={resource.id}
data-resource={resource.name}
aria-selected={selectedResources.has(resource.name)}
draggable
onDragStart={(e) => {
const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name];
e.dataTransfer.setData("application/x-webdav-names", JSON.stringify(names));
e.dataTransfer.setData("application/x-file-names", JSON.stringify(names));
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
@@ -1256,7 +1410,7 @@ export function FileBrowser({
e.preventDefault();
setDragTarget(null);
if (!resource.isDirectory) return;
const raw = e.dataTransfer.getData("application/x-webdav-names");
const raw = e.dataTransfer.getData("application/x-file-names");
if (!raw) return;
const names: string[] = JSON.parse(raw);
if (names.includes(resource.name)) return;
@@ -1284,7 +1438,7 @@ export function FileBrowser({
className="w-4 h-4 rounded border-border accent-primary cursor-pointer shrink-0"
onClick={(e) => e.stopPropagation()}
/>
{isImageFile(resource.name)
{showThumbnails && isImageFile(resource.name)
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="sm" />
: getFileIcon(resource)}
<span className="truncate">{resource.name}</span>
@@ -1525,10 +1679,10 @@ export function FileBrowser({
: `${breadcrumbDropdown.path}/${folder.name}`;
return (
<button
key={folder.href}
key={folder.id}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
onClick={() => {
onNavigate(folderPath);
onNavigate(folderPath, folder.id);
setBreadcrumbDropdown(null);
}}
>
@@ -1567,9 +1721,7 @@ export function FileBrowser({
<div className="flex flex-col items-center gap-3 mb-4">
{detailResource.isDirectory
? <Folder className="w-12 h-12 text-blue-500" />
: isImageFile(detailResource.name)
? <ImageIcon className="w-12 h-12 text-emerald-500" />
: <File className="w-12 h-12 text-muted-foreground" />}
: getFileIconByName(detailResource.name, "lg")}
<p className="text-sm font-medium text-center break-all">{detailResource.name}</p>
</div>
<dl className="space-y-3 text-sm">
@@ -1589,12 +1741,6 @@ export function FileBrowser({
<dd className="tabular-nums">{formatDate(detailResource.lastModified)}</dd>
</div>
)}
{detailResource.etag && (
<div>
<dt className="text-muted-foreground text-xs">ETag</dt>
<dd className="text-xs break-all font-mono">{detailResource.etag}</dd>
</div>
)}
<div>
<dt className="text-muted-foreground text-xs">{t("path")}</dt>
<dd className="text-xs break-all font-mono">
+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 { 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';
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'));
}
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
.readdirSync(localesDir)
.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', () => {
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([]);
});
});
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 { toWildcardQuery } from "./search-utils";
@@ -2350,6 +2350,269 @@ export class JMAPClient {
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> {
const url = this.getBlobDownloadUrl(blobId, name, type);
const response = await this.authenticatedFetch(url, {});
+19
View File
@@ -618,4 +618,23 @@ export interface AccountStates {
Mailbox?: 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",
"star": "Markieren (s)",
"unstar": "Markierung entfernen (s)",
"compose": "Verfassen (c)"
"compose": "Verfassen (c)",
"previous": "Vorherige E-Mail",
"next": "Nächste E-Mail"
},
"spam": {
"button_title": "Spam melden",
@@ -300,7 +302,10 @@
"no_calendar": "Kalender nicht verfügbar",
"select_calendar": "Kalender auswählen",
"already_in_calendar": "Bereits in deinem Kalender"
}
},
"previous": "Zurück",
"next": "Weiter",
"send": "Senden"
},
"email_composer": {
"new_message": "Neue Nachricht",
@@ -362,7 +367,9 @@
"continue_draft": "Entwurf fortsetzen",
"close_draft_title": "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": "Bestätigen",
@@ -385,7 +392,8 @@
"yes": "Ja",
"no": "Nein",
"unknown": "Unbekannt",
"app_title": "Webmail"
"app_title": "Webmail",
"reconnecting": "Verbindung verloren. Verbindung wird wiederhergestellt…"
},
"notifications": {
"email_sent": "E-Mail erfolgreich gesendet",
@@ -491,7 +499,8 @@
"templates": "Vorlagen",
"folders": "Ordner",
"keywords": "Schlüsselwörter",
"security": "Sicherheit"
"security": "Sicherheit",
"files": "Dateien"
},
"tab_groups": {
"general": "Allgemein",
@@ -1055,6 +1064,55 @@
"empty": "Vorlagenname ist erforderlich",
"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": {
@@ -1208,7 +1266,9 @@
"identity_name": "Gesendet mit Identität: {name}",
"identity_short": "über {name}",
"subaddress_tag": "+{tag}"
}
},
"delete_button": "Löschen",
"delete_confirm_title": "Identität löschen"
},
"templates": {
"picker_title": "Vorlage wählen",
@@ -1385,7 +1445,8 @@
"name_required": "Mindestens ein Vor- oder Nachname ist erforderlich",
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"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": {
"create": "Neue Gruppe",
@@ -1663,7 +1724,40 @@
"error_delete": "Kalender konnte nicht gelöscht werden",
"caldav_url": "CalDAV-URL",
"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": {
@@ -1741,7 +1835,7 @@
"rename_success": "Erfolgreich umbenannt",
"rename_error": "Umbenennen 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",
"create": "Erstellen",
"save": "Speichern",
@@ -1782,6 +1876,26 @@
"undo_error": "Rückgängig machen fehlgeschlagen",
"toolbar": "Dateiaktionen",
"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",
"signing_in": "Signing in...",
"loading": "Loading...",
"reconnecting": "Connection lost. Attempting to reconnect\u2026",
"reconnecting": "Connection lost. Attempting to reconnect",
"error": {
"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.",
@@ -304,7 +304,8 @@
"no_calendar": "Calendar not available",
"select_calendar": "Select calendar",
"already_in_calendar": "Already in your calendar"
}
},
"send": "Send"
},
"email_composer": {
"new_message": "New Message",
@@ -391,7 +392,8 @@
"yes": "Yes",
"no": "No",
"unknown": "Unknown",
"app_title": "Webmail"
"app_title": "Webmail",
"reconnecting": "Connection lost. Attempting to reconnect…"
},
"notifications": {
"email_sent": "Email sent successfully",
@@ -497,7 +499,8 @@
"templates": "Templates",
"folders": "Folders",
"keywords": "Keywords",
"security": "Security"
"security": "Security",
"files": "Files"
},
"tab_groups": {
"general": "General",
@@ -617,11 +620,11 @@
"description": "Display email preview in the list"
},
"emails_per_page": {
"label": "Emails Per Page",
"description": "Number of emails to load at once",
"25": "25 emails",
"50": "50 emails",
"100": "100 emails"
"100": "100 emails",
"label": "Emails Per Page",
"description": "Number of emails to load at once"
},
"external_content": {
"label": "External Content",
@@ -1061,6 +1064,61 @@
"empty": "Template name is required",
"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": {
@@ -1214,7 +1272,9 @@
"identity_name": "Sent using identity: {name}",
"identity_short": "via {name}",
"subaddress_tag": "+{tag}"
}
},
"delete_button": "Delete",
"delete_confirm_title": "Delete Identity"
},
"templates": {
"picker_title": "Choose a Template",
@@ -1391,7 +1451,8 @@
"name_required": "At least a first name or last name is required",
"email_invalid": "Please enter a valid email address",
"email_error_inline": "Invalid email format",
"save_failed": "Failed to save contact"
"save_failed": "Failed to save contact",
"delete": "Delete"
},
"groups": {
"create": "New Group",
@@ -1780,7 +1841,7 @@
"rename_success": "Renamed successfully",
"rename_error": "Failed to rename",
"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",
"create": "Create",
"save": "Save",
@@ -1821,6 +1882,30 @@
"undo_error": "Failed to undo",
"toolbar": "File actions",
"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",
"star": "Destacar (s)",
"unstar": "Quitar estrella (s)",
"compose": "Redactar (c)"
"compose": "Redactar (c)",
"previous": "Correo anterior",
"next": "Correo siguiente"
},
"spam": {
"button_title": "Reportar spam",
@@ -300,7 +302,10 @@
"no_calendar": "Calendario no disponible",
"select_calendar": "Seleccionar calendario",
"already_in_calendar": "Ya está en tu calendario"
}
},
"previous": "Anterior",
"next": "Siguiente",
"send": "Enviar"
},
"email_composer": {
"new_message": "Nuevo Mensaje",
@@ -362,7 +367,9 @@
"continue_draft": "Continuar borrador",
"close_draft_title": "¿Guardar o descartar borrador?",
"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": "Confirmar",
@@ -385,7 +392,8 @@
"yes": "Sí",
"no": "No",
"unknown": "Desconocido",
"app_title": "Correo Web"
"app_title": "Correo Web",
"reconnecting": "Conexión perdida. Intentando reconectar…"
},
"notifications": {
"email_sent": "Correo enviado exitosamente",
@@ -491,7 +499,8 @@
"templates": "Plantillas",
"folders": "Carpetas",
"keywords": "Palabras clave",
"security": "Seguridad"
"security": "Seguridad",
"files": "Archivos"
},
"tab_groups": {
"general": "General",
@@ -1055,6 +1064,55 @@
"empty": "El nombre de la plantilla es obligatorio",
"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": {
@@ -1208,7 +1266,9 @@
"identity_name": "Enviado usando identidad: {name}",
"identity_short": "vía {name}",
"subaddress_tag": "+{tag}"
}
},
"delete_button": "Eliminar",
"delete_confirm_title": "Eliminar identidad"
},
"templates": {
"picker_title": "Elegir una plantilla",
@@ -1385,7 +1445,8 @@
"name_required": "Se requiere al menos un nombre o apellido",
"email_invalid": "Introduce una dirección de correo válida",
"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": {
"create": "Nuevo grupo",
@@ -1663,7 +1724,40 @@
"error_delete": "Error al eliminar el calendario",
"caldav_url": "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": {
@@ -1741,7 +1835,7 @@
"rename_success": "Renombrado correctamente",
"rename_error": "Error al renombrar",
"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",
"create": "Crear",
"save": "Guardar",
@@ -1782,6 +1876,26 @@
"undo_error": "Error al deshacer",
"toolbar": "Acciones de archivo",
"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",
"star": "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": {
"button_title": "Signaler comme spam",
@@ -300,7 +302,10 @@
"no_calendar": "Calendrier non disponible",
"select_calendar": "Choisir un calendrier",
"already_in_calendar": "Déjà dans votre calendrier"
}
},
"previous": "Précédent",
"next": "Suivant",
"send": "Envoyer"
},
"email_composer": {
"new_message": "Nouveau message",
@@ -362,7 +367,9 @@
"continue_draft": "Continuer 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 ?",
"save_draft": "Enregistrer le brouillon"
"save_draft": "Enregistrer le brouillon",
"drop_files": "Déposez les fichiers à joindre",
"show_less": "Afficher moins"
},
"confirm_dialog": {
"confirm": "Confirmer",
@@ -385,7 +392,8 @@
"yes": "Oui",
"no": "Non",
"unknown": "Inconnu",
"app_title": "Webmail"
"app_title": "Webmail",
"reconnecting": "Connexion perdue. Tentative de reconnexion…"
},
"notifications": {
"email_sent": "Email envoyé avec succès",
@@ -491,7 +499,8 @@
"templates": "Modèles",
"folders": "Dossiers",
"keywords": "Mots-clés",
"security": "Sécurité"
"security": "Sécurité",
"files": "Fichiers"
},
"tab_groups": {
"general": "Général",
@@ -1055,6 +1064,55 @@
"empty": "Le nom du modèle est requis",
"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": {
@@ -1208,7 +1266,9 @@
"identity_name": "Envoyé avec l'identité: {name}",
"identity_short": "via {name}",
"subaddress_tag": "+{tag}"
}
},
"delete_button": "Supprimer",
"delete_confirm_title": "Supprimer l'identité"
},
"templates": {
"picker_title": "Choisir un modèle",
@@ -1385,7 +1445,8 @@
"name_required": "Un prénom ou un nom est requis",
"email_invalid": "Veuillez saisir une adresse e-mail valide",
"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": {
"create": "Nouveau groupe",
@@ -1663,7 +1724,40 @@
"error_delete": "Échec de la suppression du calendrier",
"caldav_url": "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": {
@@ -1741,7 +1835,7 @@
"rename_success": "Renommé avec succès",
"rename_error": "Échec du renommage",
"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",
"create": "Créer",
"save": "Enregistrer",
@@ -1782,6 +1876,26 @@
"undo_error": "Échec de l'annulation",
"toolbar": "Actions sur les fichiers",
"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",
"star": "Aggiungi stella (s)",
"unstar": "Rimuovi stella (s)",
"compose": "Scrivi (c)"
"compose": "Scrivi (c)",
"previous": "Email precedente",
"next": "Email successiva"
},
"spam": {
"button_title": "Segnala come spam",
@@ -300,7 +302,10 @@
"no_calendar": "Calendario non disponibile",
"select_calendar": "Seleziona calendario",
"already_in_calendar": "Già nel tuo calendario"
}
},
"previous": "Precedente",
"next": "Successivo",
"send": "Invia"
},
"email_composer": {
"new_message": "Nuovo messaggio",
@@ -362,7 +367,9 @@
"continue_draft": "Continua bozza",
"close_draft_title": "Salvare o eliminare la bozza?",
"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": "Conferma",
@@ -385,7 +392,8 @@
"yes": "Sì",
"no": "No",
"unknown": "Sconosciuto",
"app_title": "Webmail"
"app_title": "Webmail",
"reconnecting": "Connessione persa. Tentativo di riconnessione…"
},
"notifications": {
"email_sent": "Messaggio inviato con successo",
@@ -491,7 +499,8 @@
"templates": "Modelli",
"folders": "Cartelle",
"keywords": "Parole chiave",
"security": "Sicurezza"
"security": "Sicurezza",
"files": "File"
},
"tab_groups": {
"general": "Generale",
@@ -1055,6 +1064,55 @@
"empty": "Il nome del modello è obbligatorio",
"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": {
@@ -1208,7 +1266,9 @@
"identity_name": "Inviato usando identità: {name}",
"identity_short": "tramite {name}",
"subaddress_tag": "+{tag}"
}
},
"delete_button": "Elimina",
"delete_confirm_title": "Elimina identità"
},
"templates": {
"picker_title": "Scegli un modello",
@@ -1385,7 +1445,8 @@
"name_required": "È richiesto almeno un nome o cognome",
"email_invalid": "Inserisci un indirizzo email valido",
"email_error_inline": "Formato email non valido",
"save_failed": "Impossibile salvare il contatto"
"save_failed": "Impossibile salvare il contatto",
"delete": "Elimina"
},
"groups": {
"create": "Nuovo gruppo",
@@ -1663,7 +1724,40 @@
"error_delete": "Impossibile eliminare il calendario",
"caldav_url": "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": {
@@ -1741,7 +1835,7 @@
"rename_success": "Rinominato con successo",
"rename_error": "Rinominazione non riuscita",
"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",
"create": "Crea",
"save": "Salva",
@@ -1782,6 +1876,26 @@
"undo_error": "Annullamento non riuscito",
"toolbar": "Azioni file",
"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": "削除",
"star": "スター (s)",
"unstar": "スター解除 (s)",
"compose": "新規作成 (c)"
"compose": "新規作成 (c)",
"previous": "前のメール",
"next": "次のメール"
},
"spam": {
"button_title": "迷惑メールを報告",
@@ -300,7 +302,10 @@
"no_calendar": "カレンダーが利用できません",
"select_calendar": "カレンダーを選択",
"already_in_calendar": "カレンダーに登録済み"
}
},
"previous": "前へ",
"next": "次へ",
"send": "送信"
},
"email_composer": {
"new_message": "新規メッセージ",
@@ -362,7 +367,9 @@
"continue_draft": "下書きを続ける",
"close_draft_title": "下書きを保存または破棄しますか?",
"close_draft_message": "未保存の変更があります。下書きとして保存しますか、それとも破棄しますか?",
"save_draft": "下書きを保存"
"save_draft": "下書きを保存",
"drop_files": "ファイルをドロップして添付",
"show_less": "折りたたむ"
},
"confirm_dialog": {
"confirm": "確認",
@@ -385,7 +392,8 @@
"yes": "はい",
"no": "いいえ",
"unknown": "不明",
"app_title": "ウェブメール"
"app_title": "ウェブメール",
"reconnecting": "接続が切れました。再接続を試みています…"
},
"notifications": {
"email_sent": "メールを送信しました",
@@ -491,7 +499,8 @@
"templates": "テンプレート",
"folders": "フォルダー",
"keywords": "キーワード",
"security": "セキュリティ"
"security": "セキュリティ",
"files": "ファイル"
},
"tab_groups": {
"general": "一般",
@@ -1055,6 +1064,55 @@
"empty": "テンプレート名は必須です",
"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": {
@@ -1208,7 +1266,9 @@
"identity_name": "送信者情報を使用して送信: {name}",
"identity_short": "{name}経由",
"subaddress_tag": "+{tag}"
}
},
"delete_button": "削除",
"delete_confirm_title": "IDの削除"
},
"templates": {
"picker_title": "テンプレートを選択",
@@ -1385,7 +1445,8 @@
"name_required": "名前は必須です",
"email_invalid": "有効なメールアドレスを入力してください",
"email_error_inline": "メールアドレスの形式が正しくありません",
"save_failed": "連絡先の保存に失敗しました"
"save_failed": "連絡先の保存に失敗しました",
"delete": "削除"
},
"groups": {
"create": "新しいグループ",
@@ -1663,7 +1724,40 @@
"error_delete": "カレンダーの削除に失敗しました",
"caldav_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": {
@@ -1741,7 +1835,7 @@
"rename_success": "正常に名前を変更しました",
"rename_error": "名前の変更に失敗しました",
"download_error": "ダウンロードに失敗しました",
"not_available": "WebDAVファイルストレージはこのサーバーで利用できません",
"not_available": "ファイルストレージはこのサーバーで利用できません",
"cancel": "キャンセル",
"create": "作成",
"save": "保存",
@@ -1782,6 +1876,26 @@
"undo_error": "元に戻すのに失敗しました",
"toolbar": "ファイル操作",
"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",
"star": "Ster toevoegen (s)",
"unstar": "Ster verwijderen (s)",
"compose": "Opstellen (c)"
"compose": "Opstellen (c)",
"previous": "Vorige e-mail",
"next": "Volgende e-mail"
},
"spam": {
"button_title": "Spam melden",
@@ -300,7 +302,10 @@
"no_calendar": "Agenda niet beschikbaar",
"select_calendar": "Agenda selecteren",
"already_in_calendar": "Staat al in je agenda"
}
},
"previous": "Vorige",
"next": "Volgende",
"send": "Verzenden"
},
"email_composer": {
"new_message": "Nieuw bericht",
@@ -362,7 +367,9 @@
"continue_draft": "Concept voortzetten",
"close_draft_title": "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": "Bevestigen",
@@ -385,7 +392,8 @@
"yes": "Ja",
"no": "Nee",
"unknown": "Onbekend",
"app_title": "Webmail"
"app_title": "Webmail",
"reconnecting": "Verbinding verloren. Opnieuw verbinden…"
},
"notifications": {
"email_sent": "E-mail succesvol verzonden",
@@ -491,7 +499,8 @@
"templates": "Sjablonen",
"folders": "Mappen",
"keywords": "Sleutelwoorden",
"security": "Beveiliging"
"security": "Beveiliging",
"files": "Bestanden"
},
"tab_groups": {
"general": "Algemeen",
@@ -1055,6 +1064,55 @@
"empty": "Sjabloonnaam is verplicht",
"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": {
@@ -1208,7 +1266,9 @@
"identity_name": "Verzonden met identiteit: {name}",
"identity_short": "via {name}",
"subaddress_tag": "+{tag}"
}
},
"delete_button": "Verwijderen",
"delete_confirm_title": "Identiteit verwijderen"
},
"templates": {
"picker_title": "Kies een sjabloon",
@@ -1385,7 +1445,8 @@
"name_required": "Ten minste een voor- of achternaam is vereist",
"email_invalid": "Voer een geldig e-mailadres in",
"email_error_inline": "Ongeldig e-mailformaat",
"save_failed": "Kon contact niet opslaan"
"save_failed": "Kon contact niet opslaan",
"delete": "Verwijderen"
},
"groups": {
"create": "Nieuwe groep",
@@ -1663,7 +1724,40 @@
"error_delete": "Agenda verwijderen mislukt",
"caldav_url": "CalDAV-URL",
"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": {
@@ -1741,7 +1835,7 @@
"rename_success": "Succesvol hernoemd",
"rename_error": "Hernoemen 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",
"create": "Aanmaken",
"save": "Opslaan",
@@ -1782,6 +1876,26 @@
"undo_error": "Ongedaan maken mislukt",
"toolbar": "Bestandsacties",
"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",
"star": "Favoritar (s)",
"unstar": "Remover favorito (s)",
"compose": "Compor (c)"
"compose": "Compor (c)",
"previous": "E-mail anterior",
"next": "Próximo e-mail"
},
"spam": {
"button_title": "Reportar spam",
@@ -300,7 +302,10 @@
"no_calendar": "Calendário não disponível",
"select_calendar": "Selecionar calendário",
"already_in_calendar": "Já está no seu calendário"
}
},
"previous": "Anterior",
"next": "Próximo",
"send": "Enviar"
},
"email_composer": {
"new_message": "Nova Mensagem",
@@ -362,7 +367,9 @@
"continue_draft": "Continuar rascunho",
"close_draft_title": "Salvar ou descartar rascunho?",
"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": "Confirmar",
@@ -385,7 +392,8 @@
"yes": "Sim",
"no": "Não",
"unknown": "Desconhecido",
"app_title": "Webmail"
"app_title": "Webmail",
"reconnecting": "Conexão perdida. Tentando reconectar…"
},
"notifications": {
"email_sent": "E-mail enviado com sucesso",
@@ -491,7 +499,8 @@
"templates": "Modelos",
"folders": "Pastas",
"keywords": "Palavras-chave",
"security": "Segurança"
"security": "Segurança",
"files": "Arquivos"
},
"tab_groups": {
"general": "Geral",
@@ -1055,6 +1064,55 @@
"empty": "O nome do modelo é obrigatório",
"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": {
@@ -1208,7 +1266,9 @@
"identity_name": "Enviado usando identidade: {name}",
"identity_short": "via {name}",
"subaddress_tag": "+{tag}"
}
},
"delete_button": "Excluir",
"delete_confirm_title": "Excluir identidade"
},
"templates": {
"picker_title": "Escolher um modelo",
@@ -1385,7 +1445,8 @@
"name_required": "É necessário pelo menos um nome ou sobrenome",
"email_invalid": "Por favor, insira um endereço de e-mail vá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": {
"create": "Novo grupo",
@@ -1663,7 +1724,40 @@
"error_delete": "Falha ao excluir calendário",
"caldav_url": "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": {
@@ -1741,7 +1835,7 @@
"rename_success": "Renomeado com sucesso",
"rename_error": "Falha ao renomear",
"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",
"create": "Criar",
"save": "Guardar",
@@ -1782,6 +1876,26 @@
"undo_error": "Falha ao desfazer",
"toolbar": "Ações de arquivo",
"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();
},
}));