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:
+79
-39
@@ -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}
|
||||
|
||||
@@ -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 />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,13 +9,19 @@ import {
|
||||
Copy, Clipboard, Scissors, Info, Image as ImageIcon,
|
||||
FilePlus, CopyPlus, FileText, FileAudio, FileVideo,
|
||||
AlertCircle, Star, Clock, FolderUp,
|
||||
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
||||
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { NewFolderDialog } from "@/components/files/new-folder-dialog";
|
||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||
import { FileUploadArea } from "@/components/files/file-upload-area";
|
||||
import type { WebDAVResource } from "@/lib/webdav/client";
|
||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
|
||||
type SortKey = "name" | "size" | "modified";
|
||||
type SortDir = "asc" | "desc";
|
||||
@@ -23,20 +29,20 @@ type ViewMode = "list" | "grid";
|
||||
|
||||
interface ClipboardState {
|
||||
mode: "cut" | "copy";
|
||||
paths: string[];
|
||||
ids: string[];
|
||||
names: string[];
|
||||
sourcePath: string;
|
||||
sourceParentId: string | null;
|
||||
}
|
||||
|
||||
interface FileBrowserProps {
|
||||
currentPath: string;
|
||||
resources: WebDAVResource[];
|
||||
resources: FileResource[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
selectedResources: Set<string>;
|
||||
uploadProgress: { name: string; loaded: number; total: number; current: number; totalFiles: number } | null;
|
||||
clipboard: ClipboardState | null;
|
||||
onNavigate: (path: string) => void;
|
||||
onNavigate: (path: string, resourceId?: string | null) => void;
|
||||
onCreateFolder: (name: string) => Promise<void>;
|
||||
onUploadFiles: (files: File[]) => Promise<void>;
|
||||
onUploadFolder: (files: File[]) => Promise<void>;
|
||||
@@ -56,19 +62,21 @@ interface FileBrowserProps {
|
||||
onCopy: (names: string[]) => void;
|
||||
onPaste: () => Promise<void>;
|
||||
onMoveToFolder: (names: string[], targetFolder: string) => Promise<void>;
|
||||
onMoveToParent: (names: string[]) => Promise<void>;
|
||||
onPreviewImage: (name: string) => void;
|
||||
onPreviewFile: (name: string) => void;
|
||||
onShowDetails: (name: string) => void;
|
||||
onCreateTextFile: (name: string) => Promise<void>;
|
||||
onDuplicate: (name: string) => Promise<void>;
|
||||
getImageUrl: (name: string) => Promise<string>;
|
||||
listPath: (path: string) => Promise<WebDAVResource[]>;
|
||||
listPath: (path: string) => Promise<FileResource[]>;
|
||||
listByParentId: (parentId: string | null) => Promise<FileResource[]>;
|
||||
favorites: string[];
|
||||
recentFiles: { name: string; path: string; timestamp: number }[];
|
||||
recentFiles: { name: string; id: string; timestamp: number }[];
|
||||
onToggleFavorite: (path: string) => void;
|
||||
showDetails: boolean;
|
||||
onToggleDetails: () => void;
|
||||
detailResource: WebDAVResource | null;
|
||||
detailResource: FileResource | null;
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
|
||||
@@ -110,48 +118,100 @@ function isPdfFile(name: string): boolean {
|
||||
return PDF_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const VECTOR_EXTENSIONS = new Set(["svg", "ai", "eps", "ps", "sketch", "fig", "xd", "gvdesign"]);
|
||||
function isVectorFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return VECTOR_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const THREE_D_EXTENSIONS = new Set([
|
||||
"obj", "fbx", "gltf", "glb", "stl", "3mf", "step", "stp", "iges", "igs",
|
||||
"blend", "3ds", "dae", "usdz", "usd", "usda", "usdc", "ply", "wrl",
|
||||
"c4d", "max", "ma", "mb", "dwg", "dxf",
|
||||
]);
|
||||
function is3DFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return THREE_D_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const EXECUTABLE_EXTENSIONS = new Set([
|
||||
"exe", "msi", "dmg", "app", "appimage", "deb", "rpm", "snap", "flatpak",
|
||||
"bat", "cmd", "com", "scr", "ps1", "apk", "ipa", "jar", "run",
|
||||
]);
|
||||
function isExecutableFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return EXECUTABLE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const ARCHIVE_EXTENSIONS = new Set([
|
||||
"zip", "rar", "7z", "tar", "gz", "bz2", "xz", "zst", "lz", "lzma",
|
||||
"tgz", "tbz2", "txz", "cab", "iso", "img",
|
||||
]);
|
||||
function isArchiveFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return ARCHIVE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const SPREADSHEET_EXTENSIONS = new Set(["xls", "xlsx", "ods", "numbers", "tsv"]);
|
||||
function isSpreadsheetFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return SPREADSHEET_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const PRESENTATION_EXTENSIONS = new Set(["ppt", "pptx", "odp", "key"]);
|
||||
function isPresentationFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return PRESENTATION_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const FONT_EXTENSIONS = new Set(["ttf", "otf", "woff", "woff2", "eot"]);
|
||||
function isFontFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return FONT_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const DATABASE_EXTENSIONS = new Set(["db", "sqlite", "sqlite3", "mdb", "accdb"]);
|
||||
function isDatabaseFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return DATABASE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
function isPreviewable(name: string): boolean {
|
||||
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
|
||||
|
||||
function getFileIcon(resource: WebDAVResource) {
|
||||
function getFileIconByName(name: string, size: "sm" | "lg") {
|
||||
const cls = size === "sm" ? "w-5 h-5" : "w-10 h-10";
|
||||
if (isVectorFile(name)) return <PenTool className={`${cls} text-orange-500`} />;
|
||||
if (is3DFile(name)) return <Box className={`${cls} text-cyan-500`} />;
|
||||
if (isImageFile(name)) return <ImageIcon className={`${cls} text-emerald-500`} />;
|
||||
if (isAudioFile(name)) return <FileAudio className={`${cls} text-purple-500`} />;
|
||||
if (isVideoFile(name)) return <FileVideo className={`${cls} text-pink-500`} />;
|
||||
if (isArchiveFile(name)) return <FileArchive className={`${cls} text-amber-600`} />;
|
||||
if (isExecutableFile(name)) return <TerminalIcon className={`${cls} text-red-500`} />;
|
||||
if (isSpreadsheetFile(name)) return <FileSpreadsheet className={`${cls} text-green-600`} />;
|
||||
if (isPresentationFile(name)) return <Presentation className={`${cls} text-orange-600`} />;
|
||||
if (isFontFile(name)) return <TypeIcon className={`${cls} text-indigo-500`} />;
|
||||
if (isDatabaseFile(name)) return <Database className={`${cls} text-slate-500`} />;
|
||||
if (isPdfFile(name)) return <FileText className={`${cls} text-red-600`} />;
|
||||
if (isTextFile(name)) return <FileCode className={`${cls} text-yellow-600`} />;
|
||||
return <File className={`${cls} text-muted-foreground`} />;
|
||||
}
|
||||
|
||||
function getFileIcon(resource: FileResource) {
|
||||
if (resource.isDirectory) {
|
||||
return <Folder className="w-5 h-5 text-blue-500" />;
|
||||
}
|
||||
if (isImageFile(resource.name)) {
|
||||
return <ImageIcon className="w-5 h-5 text-emerald-500" />;
|
||||
}
|
||||
if (isAudioFile(resource.name)) {
|
||||
return <FileAudio className="w-5 h-5 text-purple-500" />;
|
||||
}
|
||||
if (isVideoFile(resource.name)) {
|
||||
return <FileVideo className="w-5 h-5 text-pink-500" />;
|
||||
}
|
||||
if (isTextFile(resource.name)) {
|
||||
return <FileText className="w-5 h-5 text-yellow-600" />;
|
||||
}
|
||||
return <File className="w-5 h-5 text-muted-foreground" />;
|
||||
return getFileIconByName(resource.name, "sm");
|
||||
}
|
||||
|
||||
function getGridIcon(resource: WebDAVResource) {
|
||||
function getGridIcon(resource: FileResource) {
|
||||
if (resource.isDirectory) {
|
||||
return <Folder className="w-10 h-10 text-blue-500" />;
|
||||
}
|
||||
if (isImageFile(resource.name)) {
|
||||
return <ImageIcon className="w-10 h-10 text-emerald-500" />;
|
||||
}
|
||||
if (isAudioFile(resource.name)) {
|
||||
return <FileAudio className="w-10 h-10 text-purple-500" />;
|
||||
}
|
||||
if (isVideoFile(resource.name)) {
|
||||
return <FileVideo className="w-10 h-10 text-pink-500" />;
|
||||
}
|
||||
if (isTextFile(resource.name)) {
|
||||
return <FileText className="w-10 h-10 text-yellow-600" />;
|
||||
}
|
||||
return <File className="w-10 h-10 text-muted-foreground" />;
|
||||
return getFileIconByName(resource.name, "lg");
|
||||
}
|
||||
|
||||
function Thumbnail({ name, getImageUrl: fetchUrl, size = "sm" }: {
|
||||
@@ -246,6 +306,7 @@ export function FileBrowser({
|
||||
onCopy,
|
||||
onPaste,
|
||||
onMoveToFolder,
|
||||
onMoveToParent,
|
||||
onPreviewImage,
|
||||
onPreviewFile,
|
||||
onShowDetails,
|
||||
@@ -253,6 +314,7 @@ export function FileBrowser({
|
||||
onDuplicate,
|
||||
getImageUrl,
|
||||
listPath,
|
||||
listByParentId,
|
||||
favorites,
|
||||
recentFiles,
|
||||
onToggleFavorite,
|
||||
@@ -275,14 +337,44 @@ export function FileBrowser({
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return (localStorage.getItem("webdav-view-mode") as ViewMode) || "list";
|
||||
return (localStorage.getItem("files-view-mode") as ViewMode) || "list";
|
||||
}
|
||||
return "list";
|
||||
});
|
||||
const [showThumbnails, setShowThumbnails] = useState(() => loadFilesSettings().showThumbnails);
|
||||
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("files-sidebar-width");
|
||||
if (saved) return Math.max(180, Math.min(400, Number(saved)));
|
||||
}
|
||||
return 220;
|
||||
});
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const dragStartWidth = useRef(220);
|
||||
const [dragTarget, setDragTarget] = useState<string | null>(null);
|
||||
|
||||
// Sync showThumbnails and folderLayout when settings change
|
||||
useEffect(() => {
|
||||
const reloadSettings = () => {
|
||||
const s = loadFilesSettings();
|
||||
setShowThumbnails(s.showThumbnails);
|
||||
setFolderLayout(s.folderLayout);
|
||||
};
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === "files-settings") reloadSettings();
|
||||
};
|
||||
// StorageEvent fires cross-tab, custom event fires same-tab
|
||||
window.addEventListener("storage", handleStorage);
|
||||
window.addEventListener("files-settings-changed", reloadSettings);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener("files-settings-changed", reloadSettings);
|
||||
};
|
||||
}, []);
|
||||
const [breadcrumbDropdown, setBreadcrumbDropdown] = useState<{
|
||||
path: string;
|
||||
folders: WebDAVResource[];
|
||||
folders: FileResource[];
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
@@ -315,15 +407,19 @@ export function FileBrowser({
|
||||
// Persist view mode
|
||||
const handleViewModeChange = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
localStorage.setItem("webdav-view-mode", mode);
|
||||
localStorage.setItem("files-view-mode", mode);
|
||||
}, []);
|
||||
|
||||
// Filter and sort resources
|
||||
const displayResources = useMemo(() => {
|
||||
let filtered = resources;
|
||||
// In sidebar mode, folders are shown in the sidebar tree — hide them from the main list
|
||||
if (folderLayout === "sidebar") {
|
||||
filtered = filtered.filter(r => !r.isDirectory);
|
||||
}
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
filtered = resources.filter(r => r.name.toLowerCase().includes(q));
|
||||
filtered = filtered.filter(r => r.name.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
@@ -345,7 +441,7 @@ export function FileBrowser({
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
return sorted;
|
||||
}, [resources, searchQuery, sortKey, sortDir]);
|
||||
}, [resources, searchQuery, sortKey, sortDir, folderLayout]);
|
||||
|
||||
// Build breadcrumb segments
|
||||
const breadcrumbs = currentPath === '/'
|
||||
@@ -363,10 +459,10 @@ export function FileBrowser({
|
||||
const segments = currentPath.split('/').filter(Boolean);
|
||||
segments.pop();
|
||||
const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/');
|
||||
onNavigate(parentPath);
|
||||
onNavigate(parentPath, null);
|
||||
};
|
||||
|
||||
const handleResourceClick = (resource: WebDAVResource, e: React.MouseEvent) => {
|
||||
const handleResourceClick = (resource: FileResource, e: React.MouseEvent) => {
|
||||
if (resource.isDirectory) {
|
||||
// Ctrl/Cmd+click on directories also toggles selection
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
@@ -376,7 +472,7 @@ export function FileBrowser({
|
||||
const newPath = currentPath === '/'
|
||||
? `/${resource.name}`
|
||||
: `${currentPath}/${resource.name}`;
|
||||
onNavigate(newPath);
|
||||
onNavigate(newPath, resource.id);
|
||||
} else {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
onToggleSelect(resource.name);
|
||||
@@ -491,12 +587,12 @@ export function FileBrowser({
|
||||
};
|
||||
}, [marquee, onSetSelection]);
|
||||
|
||||
const handleResourceDoubleClick = (resource: WebDAVResource) => {
|
||||
const handleResourceDoubleClick = (resource: FileResource) => {
|
||||
if (resource.isDirectory) {
|
||||
const newPath = currentPath === '/'
|
||||
? `/${resource.name}`
|
||||
: `${currentPath}/${resource.name}`;
|
||||
onNavigate(newPath);
|
||||
onNavigate(newPath, resource.id);
|
||||
} else if (isPreviewable(resource.name)) {
|
||||
if (isImageFile(resource.name)) {
|
||||
onPreviewImage(resource.name);
|
||||
@@ -511,7 +607,10 @@ export function FileBrowser({
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingOver(true);
|
||||
// Only show upload overlay for external file drags, not internal resource drags
|
||||
if (e.dataTransfer.types.includes("Files")) {
|
||||
setIsDraggingOver(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
@@ -711,7 +810,7 @@ export function FileBrowser({
|
||||
const newPath = currentPath === '/'
|
||||
? `/${resource.name}`
|
||||
: `${currentPath}/${resource.name}`;
|
||||
onNavigate(newPath);
|
||||
onNavigate(newPath, resource.id);
|
||||
} else if (resource) {
|
||||
onDownload(resource.name);
|
||||
}
|
||||
@@ -988,8 +1087,29 @@ export function FileBrowser({
|
||||
|
||||
{/* File list */}
|
||||
<div className="flex-1 min-h-0 flex relative">
|
||||
{/* Favorites & Recent sidebar */}
|
||||
{(favorites.length > 0 || recentFiles.length > 0) && (
|
||||
{/* Folder tree sidebar (when layout is sidebar) */}
|
||||
{folderLayout === "sidebar" && (
|
||||
<>
|
||||
<FolderTreeSidebar
|
||||
currentPath={currentPath}
|
||||
onNavigate={onNavigate}
|
||||
listByParentId={listByParentId}
|
||||
width={sidebarWidth}
|
||||
isResizing={isResizing}
|
||||
/>
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
||||
onResizeEnd={() => {
|
||||
setIsResizing(false);
|
||||
localStorage.setItem("files-sidebar-width", String(sidebarWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setSidebarWidth(220); localStorage.setItem("files-sidebar-width", "220"); }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Favorites & Recent sidebar (when layout is inline) */}
|
||||
{folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && (
|
||||
<div className="w-48 border-r border-border bg-background overflow-y-auto shrink-0 hidden lg:block">
|
||||
{favorites.length > 0 && (
|
||||
<div className="p-3">
|
||||
@@ -1023,13 +1143,9 @@ export function FileBrowser({
|
||||
<div className="space-y-0.5">
|
||||
{recentFiles.slice(0, 10).map((recent) => (
|
||||
<button
|
||||
key={recent.path}
|
||||
onClick={() => {
|
||||
const dir = recent.path.substring(0, recent.path.lastIndexOf('/')) || '/';
|
||||
onNavigate(dir);
|
||||
}}
|
||||
key={recent.id}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-left"
|
||||
title={recent.path}
|
||||
title={recent.name}
|
||||
>
|
||||
<File className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{recent.name}</span>
|
||||
@@ -1069,7 +1185,7 @@ export function FileBrowser({
|
||||
<SkeletonRow />
|
||||
</tbody>
|
||||
</table>
|
||||
) : resources.length === 0 && !searchQuery ? (
|
||||
) : resources.length === 0 && !searchQuery && currentPath === '/' ? (
|
||||
<FileUploadArea
|
||||
onUpload={async (files: File[]) => {
|
||||
setIsUploading(true);
|
||||
@@ -1087,8 +1203,27 @@ export function FileBrowser({
|
||||
<div className="p-4" role="grid" aria-label={t("file_list")}>
|
||||
{currentPath !== '/' && !searchQuery && (
|
||||
<div
|
||||
className="inline-flex flex-col items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-muted/50 transition-colors w-28"
|
||||
className={cn(
|
||||
"inline-flex flex-col items-center gap-2 p-3 rounded-lg cursor-pointer transition-colors w-28",
|
||||
dragTarget === '..' ? "bg-primary/5 ring-1 ring-primary/40" : "hover:bg-muted/50"
|
||||
)}
|
||||
onClick={handleNavigateUp}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes("application/x-file-names")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragTarget('..');
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => setDragTarget(null)}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
setDragTarget(null);
|
||||
const raw = e.dataTransfer.getData("application/x-file-names");
|
||||
if (!raw) return;
|
||||
const names: string[] = JSON.parse(raw);
|
||||
await onMoveToParent(names);
|
||||
}}
|
||||
>
|
||||
<Folder className="w-10 h-10 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground truncate w-full text-center">..</span>
|
||||
@@ -1107,12 +1242,12 @@ export function FileBrowser({
|
||||
>
|
||||
{displayResources.map((resource) => (
|
||||
<div
|
||||
key={resource.href}
|
||||
key={resource.id}
|
||||
data-resource={resource.name}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name];
|
||||
e.dataTransfer.setData("application/x-webdav-names", JSON.stringify(names));
|
||||
e.dataTransfer.setData("application/x-file-names", JSON.stringify(names));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
@@ -1127,7 +1262,7 @@ export function FileBrowser({
|
||||
e.preventDefault();
|
||||
setDragTarget(null);
|
||||
if (!resource.isDirectory) return;
|
||||
const raw = e.dataTransfer.getData("application/x-webdav-names");
|
||||
const raw = e.dataTransfer.getData("application/x-file-names");
|
||||
if (!raw) return;
|
||||
const names: string[] = JSON.parse(raw);
|
||||
if (names.includes(resource.name)) return;
|
||||
@@ -1154,7 +1289,7 @@ export function FileBrowser({
|
||||
data-checked={selectedResources.has(resource.name)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{isImageFile(resource.name)
|
||||
{showThumbnails && isImageFile(resource.name)
|
||||
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="lg" />
|
||||
: getGridIcon(resource)}
|
||||
<span className="text-xs truncate w-full text-center" title={resource.name}>
|
||||
@@ -1212,8 +1347,27 @@ export function FileBrowser({
|
||||
<tbody>
|
||||
{currentPath !== '/' && !searchQuery && (
|
||||
<tr
|
||||
className="border-b border-border hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
className={cn(
|
||||
"border-b border-border cursor-pointer transition-colors",
|
||||
dragTarget === '..' ? "bg-primary/5 ring-1 ring-primary/40" : "hover:bg-muted/50"
|
||||
)}
|
||||
onClick={handleNavigateUp}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes("application/x-file-names")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragTarget('..');
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => setDragTarget(null)}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
setDragTarget(null);
|
||||
const raw = e.dataTransfer.getData("application/x-file-names");
|
||||
if (!raw) return;
|
||||
const names: string[] = JSON.parse(raw);
|
||||
await onMoveToParent(names);
|
||||
}}
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1235,13 +1389,13 @@ export function FileBrowser({
|
||||
</tr>
|
||||
) : displayResources.map((resource) => (
|
||||
<tr
|
||||
key={resource.href}
|
||||
key={resource.id}
|
||||
data-resource={resource.name}
|
||||
aria-selected={selectedResources.has(resource.name)}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name];
|
||||
e.dataTransfer.setData("application/x-webdav-names", JSON.stringify(names));
|
||||
e.dataTransfer.setData("application/x-file-names", JSON.stringify(names));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
@@ -1256,7 +1410,7 @@ export function FileBrowser({
|
||||
e.preventDefault();
|
||||
setDragTarget(null);
|
||||
if (!resource.isDirectory) return;
|
||||
const raw = e.dataTransfer.getData("application/x-webdav-names");
|
||||
const raw = e.dataTransfer.getData("application/x-file-names");
|
||||
if (!raw) return;
|
||||
const names: string[] = JSON.parse(raw);
|
||||
if (names.includes(resource.name)) return;
|
||||
@@ -1284,7 +1438,7 @@ export function FileBrowser({
|
||||
className="w-4 h-4 rounded border-border accent-primary cursor-pointer shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{isImageFile(resource.name)
|
||||
{showThumbnails && isImageFile(resource.name)
|
||||
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="sm" />
|
||||
: getFileIcon(resource)}
|
||||
<span className="truncate">{resource.name}</span>
|
||||
@@ -1525,10 +1679,10 @@ export function FileBrowser({
|
||||
: `${breadcrumbDropdown.path}/${folder.name}`;
|
||||
return (
|
||||
<button
|
||||
key={folder.href}
|
||||
key={folder.id}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
onClick={() => {
|
||||
onNavigate(folderPath);
|
||||
onNavigate(folderPath, folder.id);
|
||||
setBreadcrumbDropdown(null);
|
||||
}}
|
||||
>
|
||||
@@ -1567,9 +1721,7 @@ export function FileBrowser({
|
||||
<div className="flex flex-col items-center gap-3 mb-4">
|
||||
{detailResource.isDirectory
|
||||
? <Folder className="w-12 h-12 text-blue-500" />
|
||||
: isImageFile(detailResource.name)
|
||||
? <ImageIcon className="w-12 h-12 text-emerald-500" />
|
||||
: <File className="w-12 h-12 text-muted-foreground" />}
|
||||
: getFileIconByName(detailResource.name, "lg")}
|
||||
<p className="text-sm font-medium text-center break-all">{detailResource.name}</p>
|
||||
</div>
|
||||
<dl className="space-y-3 text-sm">
|
||||
@@ -1589,12 +1741,6 @@ export function FileBrowser({
|
||||
<dd className="tabular-nums">{formatDate(detailResource.lastModified)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{detailResource.etag && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-xs">ETag</dt>
|
||||
<dd className="text-xs break-all font-mono">{detailResource.etag}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-xs">{t("path")}</dt>
|
||||
<dd className="text-xs break-all font-mono">
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "@/components/settings/settings-section";
|
||||
|
||||
export type FolderLayout = "inline" | "sidebar";
|
||||
|
||||
export interface FilesSettings {
|
||||
defaultViewMode: "list" | "grid";
|
||||
showIcons: boolean;
|
||||
coloredIcons: boolean;
|
||||
defaultSortKey: "name" | "size" | "modified";
|
||||
defaultSortDir: "asc" | "desc";
|
||||
showHiddenFiles: boolean;
|
||||
showThumbnails: boolean;
|
||||
folderLayout: FolderLayout;
|
||||
}
|
||||
|
||||
export const DEFAULT_FILES_SETTINGS: FilesSettings = {
|
||||
defaultViewMode: "list",
|
||||
showIcons: true,
|
||||
coloredIcons: true,
|
||||
defaultSortKey: "name",
|
||||
defaultSortDir: "asc",
|
||||
showHiddenFiles: false,
|
||||
showThumbnails: true,
|
||||
folderLayout: "inline",
|
||||
};
|
||||
|
||||
export function loadFilesSettings(): FilesSettings {
|
||||
if (typeof window === "undefined") return DEFAULT_FILES_SETTINGS;
|
||||
try {
|
||||
const raw = localStorage.getItem("files-settings");
|
||||
if (raw) return { ...DEFAULT_FILES_SETTINGS, ...JSON.parse(raw) };
|
||||
} catch { /* ignore */ }
|
||||
return DEFAULT_FILES_SETTINGS;
|
||||
}
|
||||
|
||||
export function saveFilesSettings(settings: FilesSettings) {
|
||||
localStorage.setItem("files-settings", JSON.stringify(settings));
|
||||
// Dispatch custom event for same-tab listeners (StorageEvent only fires cross-tab)
|
||||
window.dispatchEvent(new CustomEvent("files-settings-changed"));
|
||||
}
|
||||
|
||||
interface FilesSettingsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
settings: FilesSettings;
|
||||
onSettingsChange: (settings: FilesSettings) => void;
|
||||
}
|
||||
|
||||
export function FilesSettingsDialog({ isOpen, onClose, settings, onSettingsChange }: FilesSettingsDialogProps) {
|
||||
const t = useTranslations("files");
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
if (isOpen) window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
if (isOpen) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const update = (patch: Partial<FilesSettings>) => {
|
||||
const next = { ...settings, ...patch };
|
||||
onSettingsChange(next);
|
||||
saveFilesSettings(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("settings_title")}
|
||||
className="bg-background border border-border rounded-lg shadow-lg w-full max-w-md mx-4 max-h-[80vh] flex flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">{t("settings_title")}</h2>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4 space-y-6">
|
||||
<SettingsSection title={t("settings_display")}>
|
||||
<SettingItem label={t("settings_folder_layout")} description={t("settings_folder_layout_desc")}>
|
||||
<RadioGroup
|
||||
value={settings.folderLayout}
|
||||
onChange={(v) => update({ folderLayout: v as FolderLayout })}
|
||||
options={[
|
||||
{ value: "inline", label: t("settings_folder_layout_inline") },
|
||||
{ value: "sidebar", label: t("settings_folder_layout_sidebar") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("settings_default_view")} description={t("settings_default_view_desc")}>
|
||||
<RadioGroup
|
||||
value={settings.defaultViewMode}
|
||||
onChange={(v) => update({ defaultViewMode: v as "list" | "grid" })}
|
||||
options={[
|
||||
{ value: "list", label: t("list_view") },
|
||||
{ value: "grid", label: t("grid_view") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("settings_default_sort")} description={t("settings_default_sort_desc")}>
|
||||
<RadioGroup
|
||||
value={settings.defaultSortKey}
|
||||
onChange={(v) => update({ defaultSortKey: v as "name" | "size" | "modified" })}
|
||||
options={[
|
||||
{ value: "name", label: t("name") },
|
||||
{ value: "size", label: t("size") },
|
||||
{ value: "modified", label: t("modified") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("settings_sort_direction")} description={t("settings_sort_direction_desc")}>
|
||||
<RadioGroup
|
||||
value={settings.defaultSortDir}
|
||||
onChange={(v) => update({ defaultSortDir: v as "asc" | "desc" })}
|
||||
options={[
|
||||
{ value: "asc", label: t("settings_ascending") },
|
||||
{ value: "desc", label: t("settings_descending") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings_icons")}>
|
||||
<SettingItem label={t("settings_show_icons")} description={t("settings_show_icons_desc")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.showIcons}
|
||||
onChange={(v) => update({ showIcons: v })}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("settings_colored_icons")} description={t("settings_colored_icons_desc")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.coloredIcons}
|
||||
onChange={(v) => update({ coloredIcons: v })}
|
||||
disabled={!settings.showIcons}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("settings_show_thumbnails")} description={t("settings_show_thumbnails_desc")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.showThumbnails}
|
||||
onChange={(v) => update({ showThumbnails: v })}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings_behavior")}>
|
||||
<SettingItem label={t("settings_show_hidden")} description={t("settings_show_hidden_desc")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.showHiddenFiles}
|
||||
onChange={(v) => update({ showHiddenFiles: v })}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Home,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFileStore, type FileResource } from "@/stores/file-store";
|
||||
|
||||
interface FolderNode {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface FolderTreeSidebarProps {
|
||||
currentPath: string;
|
||||
onNavigate: (path: string, resourceId?: string | null) => void;
|
||||
listByParentId: (parentId: string | null) => Promise<FileResource[]>;
|
||||
width?: number;
|
||||
isResizing?: boolean;
|
||||
}
|
||||
|
||||
export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 220, isResizing }: FolderTreeSidebarProps) {
|
||||
const t = useTranslations("files");
|
||||
const client = useFileStore(s => s.client);
|
||||
const [rootChildren, setRootChildren] = useState<FolderNode[] | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(["root"]));
|
||||
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
|
||||
// Cache: parentId (or "root") -> FolderNode[]
|
||||
const [childrenCache, setChildrenCache] = useState<Map<string, FolderNode[]>>(new Map());
|
||||
// Map folder path -> id for reverse lookup
|
||||
const pathToIdRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
const loadChildren = useCallback(async (parentId: string | null, parentPath: string) => {
|
||||
const cacheKey = parentId ?? "root";
|
||||
// Skip if already loading or cached
|
||||
if (childrenCache.has(cacheKey)) return;
|
||||
|
||||
setLoadingIds(prev => new Set(prev).add(cacheKey));
|
||||
try {
|
||||
const resources = await listByParentId(parentId);
|
||||
const folders = resources
|
||||
.filter(r => r.isDirectory)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(r => {
|
||||
const folderPath = parentPath === "/" ? `/${r.name}` : `${parentPath}/${r.name}`;
|
||||
pathToIdRef.current.set(folderPath, r.id);
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
path: folderPath,
|
||||
};
|
||||
});
|
||||
|
||||
// Don't cache empty root results — empty root likely means client wasn't ready yet
|
||||
if (folders.length > 0 || parentId !== null) {
|
||||
setChildrenCache(prev => new Map(prev).set(cacheKey, folders));
|
||||
}
|
||||
|
||||
if (parentId === null) {
|
||||
setRootChildren(folders);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
setLoadingIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(cacheKey);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [childrenCache, listByParentId]);
|
||||
|
||||
// Load root folders when client is available (handles page refresh timing)
|
||||
useEffect(() => {
|
||||
if (client) {
|
||||
loadChildren(null, "/");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client]);
|
||||
|
||||
// Auto-expand along the current path when navigating
|
||||
useEffect(() => {
|
||||
if (currentPath === "/") return;
|
||||
const segments = currentPath.split("/").filter(Boolean);
|
||||
|
||||
// Walk down the path and expand + load each ancestor
|
||||
let ancestorPath = "";
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
ancestorPath = "/" + segments.slice(0, i + 1).join("/");
|
||||
const folderId = pathToIdRef.current.get(ancestorPath);
|
||||
if (folderId) {
|
||||
setExpandedIds(prev => {
|
||||
if (prev.has(folderId)) return prev;
|
||||
return new Set(prev).add(folderId);
|
||||
});
|
||||
if (!childrenCache.has(folderId)) {
|
||||
loadChildren(folderId, ancestorPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [currentPath, childrenCache, loadChildren]);
|
||||
|
||||
const handleToggleExpand = useCallback(async (folderId: string, folderPath: string) => {
|
||||
setExpandedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folderId)) {
|
||||
next.delete(folderId);
|
||||
} else {
|
||||
next.add(folderId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!childrenCache.has(folderId)) {
|
||||
await loadChildren(folderId, folderPath);
|
||||
}
|
||||
}, [childrenCache, loadChildren]);
|
||||
|
||||
const handleFolderClick = useCallback((path: string, id: string | null) => {
|
||||
onNavigate(path, id);
|
||||
}, [onNavigate]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border bg-secondary overflow-hidden shrink-0 hidden lg:flex flex-col",
|
||||
!isResizing && "transition-[width] duration-300"
|
||||
)}
|
||||
style={{ width: `${width}px` }}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{/* Root / Home entry */}
|
||||
<div
|
||||
style={{ paddingBlock: "var(--density-sidebar-py)" }}
|
||||
className={cn(
|
||||
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 px-2",
|
||||
currentPath === "/"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted text-foreground",
|
||||
"font-medium"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleFolderClick("/", null)}
|
||||
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
|
||||
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
|
||||
>
|
||||
<Home className={cn("w-4 h-4 flex-shrink-0 mr-2 transition-colors")} />
|
||||
<span className="truncate">{t("breadcrumb_root")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Folder tree */}
|
||||
{rootChildren === null && loadingIds.has("root") ? (
|
||||
<div className="px-3 py-2 space-y-2">
|
||||
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 w-20 bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 w-28 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
) : (
|
||||
rootChildren?.map(folder => (
|
||||
<FolderTreeItem
|
||||
key={folder.id}
|
||||
node={folder}
|
||||
depth={0}
|
||||
currentPath={currentPath}
|
||||
expandedIds={expandedIds}
|
||||
loadingIds={loadingIds}
|
||||
childrenCache={childrenCache}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onFolderClick={handleFolderClick}
|
||||
onLoadChildren={loadChildren}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderTreeItem({
|
||||
node,
|
||||
depth,
|
||||
currentPath,
|
||||
expandedIds,
|
||||
loadingIds,
|
||||
childrenCache,
|
||||
onToggleExpand,
|
||||
onFolderClick,
|
||||
onLoadChildren,
|
||||
}: {
|
||||
node: FolderNode;
|
||||
depth: number;
|
||||
currentPath: string;
|
||||
expandedIds: Set<string>;
|
||||
loadingIds: Set<string>;
|
||||
childrenCache: Map<string, FolderNode[]>;
|
||||
onToggleExpand: (folderId: string, folderPath: string) => void;
|
||||
onFolderClick: (path: string, id: string | null) => void;
|
||||
onLoadChildren: (parentId: string, parentPath: string) => Promise<void>;
|
||||
}) {
|
||||
const isExpanded = expandedIds.has(node.id);
|
||||
const isSelected = currentPath === node.path;
|
||||
const isLoading = loadingIds.has(node.id);
|
||||
const children = childrenCache.get(node.id);
|
||||
const hasChildren = children !== undefined && children.length > 0;
|
||||
const indentPx = depth * 16;
|
||||
const Icon = isExpanded && hasChildren ? FolderOpen : Folder;
|
||||
|
||||
// Eagerly load children on mount to know if subfolders exist
|
||||
useEffect(() => {
|
||||
if (children === undefined && !loadingIds.has(node.id)) {
|
||||
onLoadChildren(node.id, node.path);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{ paddingBlock: "var(--density-sidebar-py)" }}
|
||||
className={cn(
|
||||
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 px-2",
|
||||
isSelected
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted text-foreground",
|
||||
depth === 0 && "font-medium"
|
||||
)}
|
||||
>
|
||||
{/* Expand/collapse chevron */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand(node.id, node.path);
|
||||
}}
|
||||
className={cn(
|
||||
"p-0.5 rounded mr-1 transition-all duration-200",
|
||||
"hover:bg-muted active:bg-accent"
|
||||
)}
|
||||
style={{ marginLeft: `${indentPx}px` }}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* Folder name */}
|
||||
<button
|
||||
onClick={() => onFolderClick(node.path, node.id)}
|
||||
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
|
||||
style={{
|
||||
paddingBlock: "var(--density-sidebar-py)",
|
||||
paddingLeft: hasChildren ? "4px" : `${indentPx + 24}px`,
|
||||
}}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"w-4 h-4 flex-shrink-0 mr-2 transition-colors",
|
||||
isExpanded && hasChildren && "text-primary",
|
||||
!hasChildren && depth > 0 && "text-muted-foreground"
|
||||
)} />
|
||||
<span className="truncate">{node.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{isExpanded && children && (
|
||||
<div className="relative">
|
||||
{children.map(child => (
|
||||
<FolderTreeItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
currentPath={currentPath}
|
||||
expandedIds={expandedIds}
|
||||
loadingIds={loadingIds}
|
||||
childrenCache={childrenCache}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onFolderClick={onFolderClick}
|
||||
onLoadChildren={onLoadChildren}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section";
|
||||
import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
|
||||
export function FilesSettingsComponent() {
|
||||
const t = useTranslations("settings.files");
|
||||
const [settings, setSettings] = useState<FilesSettings>(loadFilesSettings);
|
||||
|
||||
// Listen for external changes (e.g. if file-browser updates settings)
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === "files-settings") {
|
||||
setSettings(loadFilesSettings());
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
const update = useCallback((patch: Partial<FilesSettings>) => {
|
||||
setSettings(prev => {
|
||||
const next = { ...prev, ...patch };
|
||||
saveFilesSettings(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsSection title={t("display.title")} description={t("display.description")}>
|
||||
<SettingItem label={t("folder_layout.label")} description={t("folder_layout.description")}>
|
||||
<RadioGroup
|
||||
value={settings.folderLayout}
|
||||
onChange={(v) => update({ folderLayout: v as FolderLayout })}
|
||||
options={[
|
||||
{ value: "inline", label: t("folder_layout.inline") },
|
||||
{ value: "sidebar", label: t("folder_layout.sidebar") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("default_view.label")} description={t("default_view.description")}>
|
||||
<RadioGroup
|
||||
value={settings.defaultViewMode}
|
||||
onChange={(v) => update({ defaultViewMode: v as "list" | "grid" })}
|
||||
options={[
|
||||
{ value: "list", label: t("default_view.list") },
|
||||
{ value: "grid", label: t("default_view.grid") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("default_sort.label")} description={t("default_sort.description")}>
|
||||
<RadioGroup
|
||||
value={settings.defaultSortKey}
|
||||
onChange={(v) => update({ defaultSortKey: v as "name" | "size" | "modified" })}
|
||||
options={[
|
||||
{ value: "name", label: t("default_sort.name") },
|
||||
{ value: "size", label: t("default_sort.size") },
|
||||
{ value: "modified", label: t("default_sort.modified") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("sort_direction.label")} description={t("sort_direction.description")}>
|
||||
<RadioGroup
|
||||
value={settings.defaultSortDir}
|
||||
onChange={(v) => update({ defaultSortDir: v as "asc" | "desc" })}
|
||||
options={[
|
||||
{ value: "asc", label: t("sort_direction.ascending") },
|
||||
{ value: "desc", label: t("sort_direction.descending") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("icons.title")} description={t("icons.description")}>
|
||||
<SettingItem label={t("show_icons.label")} description={t("show_icons.description")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.showIcons}
|
||||
onChange={(v) => update({ showIcons: v })}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("colored_icons.label")} description={t("colored_icons.description")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.coloredIcons}
|
||||
onChange={(v) => update({ coloredIcons: v })}
|
||||
disabled={!settings.showIcons}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("show_thumbnails.label")} description={t("show_thumbnails.description")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.showThumbnails}
|
||||
onChange={(v) => update({ showThumbnails: v })}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("behavior.title")} description={t("behavior.description")}>
|
||||
<SettingItem label={t("show_hidden.label")} description={t("show_hidden.description")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.showHiddenFiles}
|
||||
onChange={(v) => update({ showHiddenFiles: v })}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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, {});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" -> "test∕sub∕"
|
||||
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. "folder∕sub∕" → "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();
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user