feat: add WebDAV file browser with auth improvements

Add a new Files section powered by WebDAV for browsing, uploading,
downloading, renaming, and deleting files and folders.

New features:
- WebDAV file browser with grid/list views and breadcrumb navigation
- File upload (drag-and-drop and button), folder creation, rename, delete
- File preview modals for images and other file types
- WebDAV proxy API route to handle authentication
- Navigation rail entry for Files (auto-hidden when WebDAV is unsupported)

Auth improvements:
- Fix premature redirects on calendar, contacts, and settings pages by
  adding explicit auth check on mount before redirecting to login
- Persist active settings tab in localStorage

Other:
- Expose getAuthHeader() and getServerUrl() on JMAPClient
- Add WebDAV store with connection testing and capability detection
- Add i18n translations for file browser in all 8 locales (de, en, es,
  fr, it, ja, nl, pt)
This commit is contained in:
Linus Rath
2026-03-15 05:24:27 +01:00
parent cb74e3bf73
commit 1f1db8fac8
23 changed files with 4179 additions and 12 deletions
+12 -4
View File
@@ -45,7 +45,8 @@ export default function CalendarPage() {
const router = useRouter();
const t = useTranslations("calendar");
const isMobile = useIsMobile();
const { client, isAuthenticated, logout } = useAuthStore();
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const {
calendars, events, selectedDate, viewMode, selectedCalendarIds,
@@ -76,14 +77,21 @@ export default function CalendarPage() {
// Swipe navigation ref (handlers defined after navigatePrev/navigateNext)
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
// Check auth on mount
useEffect(() => {
if (!isAuthenticated) {
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
router.push("/login");
} else if (!supportsCalendar) {
} else if (client && !supportsCalendar) {
router.push("/");
}
}, [isAuthenticated, supportsCalendar, router]);
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, router]);
useEffect(() => {
if (error) {
+11 -3
View File
@@ -38,7 +38,8 @@ type View =
export default function ContactsPage() {
const router = useRouter();
const t = useTranslations("contacts");
const { client, isAuthenticated, logout } = useAuthStore();
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const {
contacts,
@@ -77,12 +78,19 @@ export default function ContactsPage() {
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const isMobile = useIsMobile();
// Check auth on mount
useEffect(() => {
if (!isAuthenticated) {
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
router.push("/login");
}
}, [isAuthenticated, router]);
}, [initialCheckDone, isAuthenticated, authLoading, router]);
useEffect(() => {
if (client && supportsSync && !hasFetched.current) {
+423
View File
@@ -0,0 +1,423 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
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 { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail";
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";
export default function FilesPage() {
const router = useRouter();
const t = useTranslations("files");
const { isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const {
currentPath,
resources,
isLoading,
error,
supportsWebDAV,
selectedResources,
uploadProgress,
clipboard,
initClient,
checkSupport,
navigate,
refresh,
createDirectory,
uploadFile,
uploadFiles,
uploadFolder,
deleteResource,
deleteResources,
renameResource,
downloadResource,
getImageUrl,
getFileContent,
createTextFile,
duplicateResource,
downloadResources,
moveToFolder,
cutResources,
copyResources,
pasteResources,
selectResource,
toggleSelect,
selectAll,
clearSelection,
setSelection,
listPath,
favorites,
recentFiles,
toggleFavorite,
addRecentFile,
cancelUpload,
undoLastAction,
lastAction,
} = useWebDAVStore();
const isMobile = useIsMobile();
const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewFile, setPreviewFile] = useState<string | null>(null);
const [showDetails, setShowDetails] = useState(false);
const [detailName, setDetailName] = useState<string | null>(null);
const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null;
// Check auth on mount
useEffect(() => {
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
// Redirect if not authenticated
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
router.push("/login");
}
}, [initialCheckDone, isAuthenticated, authLoading, router]);
// Initialize WebDAV client
useEffect(() => {
if (isAuthenticated && !hasFetched.current) {
hasFetched.current = true;
initClient();
}
}, [isAuthenticated, initClient]);
// Check support and load root after client is initialized
const { webdavClient } = useWebDAVStore();
useEffect(() => {
if (webdavClient && supportsWebDAV === null) {
checkSupport().then((supported) => {
if (supported) {
let initialPath = '/';
try {
const saved = localStorage.getItem('webdav-last-path');
if (saved) initialPath = saved;
} catch { /* ignore */ }
navigate(initialPath);
}
});
}
}, [webdavClient, supportsWebDAV, checkSupport, navigate]);
const handleNavigate = useCallback((path: string) => {
navigate(path);
}, [navigate]);
const handleCreateFolder = useCallback(async (name: string) => {
try {
await createDirectory(name);
toast.success(t("create_folder_success"));
} catch (err) {
console.error("Failed to create folder:", err);
toast.error(t("create_folder_error"));
}
}, [createDirectory, t]);
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
const handleUploadFiles = useCallback(async (files: File[]) => {
const oversized = files.filter(f => f.size > MAX_FILE_SIZE);
const valid = files.filter(f => f.size <= MAX_FILE_SIZE);
if (oversized.length > 0) {
toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" }));
}
if (valid.length === 0) return;
try {
await uploadFiles(valid);
toast.success(t("upload_success", { count: valid.length }));
} catch (err) {
console.error("Failed to upload files:", err);
toast.error(t("upload_error"));
}
}, [uploadFiles, t]);
const handleUploadFolder = useCallback(async (files: File[]) => {
const oversized = files.filter(f => f.size > MAX_FILE_SIZE);
const valid = files.filter(f => f.size <= MAX_FILE_SIZE);
if (oversized.length > 0) {
toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" }));
}
if (valid.length === 0) return;
try {
await uploadFolder(valid);
toast.success(t("upload_success", { count: valid.length }));
} catch (err) {
console.error("Failed to upload folder:", err);
toast.error(t("upload_error"));
}
}, [uploadFolder, t]);
const handleDelete = useCallback(async (name: string) => {
const confirmed = await confirmDialog({
title: t("delete_confirm_title"),
message: t("delete_confirm_message", { name }),
confirmText: t("delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
await deleteResource(name);
toast.success(t("delete_success"));
} catch (err) {
console.error("Failed to delete:", err);
toast.error(t("delete_error"));
}
}, [deleteResource, confirmDialog, t]);
const handleBatchDelete = useCallback(async (names: string[]) => {
const confirmed = await confirmDialog({
title: t("delete_confirm_title"),
message: t("batch_delete_confirm_message", { count: names.length }),
confirmText: t("delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
await deleteResources(names);
toast.success(t("batch_delete_success", { count: names.length }));
} catch (err) {
console.error("Failed to batch delete:", err);
toast.error(t("delete_error"));
}
}, [deleteResources, confirmDialog, t]);
const handleUndo = useCallback(async () => {
try {
await undoLastAction();
toast.success(t("undo_success"));
} catch (err) {
console.error("Failed to undo:", err);
toast.error(t("undo_error"));
}
}, [undoLastAction, t]);
const handleRename = useCallback(async (oldName: string, newName: string) => {
try {
await renameResource(oldName, newName);
toast.success(t("rename_success"), {
action: { label: t("undo"), onClick: handleUndo },
});
} catch (err) {
console.error("Failed to rename:", err);
toast.error(t("rename_error"));
}
}, [renameResource, t, handleUndo]);
const handleDownload = useCallback(async (name: string) => {
try {
await downloadResource(name);
addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name);
} catch (err) {
console.error("Failed to download:", err);
toast.error(t("download_error"));
}
}, [downloadResource, addRecentFile, currentPath, t]);
const handleBatchDownload = useCallback(async (names: string[]) => {
try {
await downloadResources(names);
} catch (err) {
console.error("Failed to batch download:", err);
toast.error(t("download_error"));
}
}, [downloadResources, t]);
const handleCreateTextFile = useCallback(async (name: string) => {
try {
await createTextFile(name);
toast.success(t("create_file_success"));
} catch (err) {
console.error("Failed to create file:", err);
toast.error(t("create_file_error"));
}
}, [createTextFile, t]);
const handleDuplicate = useCallback(async (name: string) => {
try {
await duplicateResource(name);
toast.success(t("duplicate_success"));
} catch (err) {
console.error("Failed to duplicate:", err);
toast.error(t("duplicate_error"));
}
}, [duplicateResource, t]);
const handleMoveToFolder = useCallback(async (names: string[], targetFolder: string) => {
try {
await moveToFolder(names, targetFolder);
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"));
}
}, [moveToFolder, t, handleUndo]);
const handlePaste = useCallback(async () => {
try {
await pasteResources();
toast.success(t("paste_success"), {
action: lastAction ? { label: t("undo"), onClick: handleUndo } : undefined,
});
} catch (err) {
console.error("Failed to paste:", err);
toast.error(t("paste_error"));
}
}, [pasteResources, t, lastAction, handleUndo]);
const handlePreviewImage = useCallback((name: string) => {
setPreviewImage(name);
addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name);
}, [addRecentFile, currentPath]);
const handlePreviewFile = useCallback((name: string) => {
setPreviewFile(name);
addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name);
}, [addRecentFile, currentPath]);
const handleShowDetails = useCallback((name: string) => {
setDetailName(name);
setShowDetails(true);
}, []);
const handleToggleDetails = useCallback(() => {
setShowDetails(v => !v);
}, []);
if (!isAuthenticated) return null;
return (
<div className="flex h-dvh bg-background overflow-hidden">
{!isMobile && (
<div className="w-14 border-r border-border bg-secondary flex flex-col flex-shrink-0">
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={() => { logout(); router.push('/login'); }}
/>
</div>
)}
<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>
</div>
</div>
<div className="flex-1 min-h-0">
{supportsWebDAV === false ? (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
</div>
) : (
<FileBrowser
currentPath={currentPath}
resources={resources}
isLoading={isLoading}
error={error}
selectedResources={selectedResources}
uploadProgress={uploadProgress}
clipboard={clipboard}
onNavigate={handleNavigate}
onCreateFolder={handleCreateFolder}
onUploadFiles={handleUploadFiles}
onUploadFolder={handleUploadFolder}
onCancelUpload={cancelUpload}
onDelete={handleDelete}
onBatchDelete={handleBatchDelete}
onRename={handleRename}
onDownload={handleDownload}
onBatchDownload={handleBatchDownload}
onRefresh={refresh}
onSelectResource={selectResource}
onToggleSelect={toggleSelect}
onSelectAll={selectAll}
onClearSelection={clearSelection}
onSetSelection={setSelection}
onCut={cutResources}
onCopy={copyResources}
onPaste={handlePaste}
onMoveToFolder={handleMoveToFolder}
onPreviewImage={handlePreviewImage}
onPreviewFile={handlePreviewFile}
onShowDetails={handleShowDetails}
onCreateTextFile={handleCreateTextFile}
onDuplicate={handleDuplicate}
getImageUrl={getImageUrl}
listPath={listPath}
favorites={favorites}
recentFiles={recentFiles}
onToggleFavorite={toggleFavorite}
showDetails={showDetails}
onToggleDetails={handleToggleDetails}
detailResource={detailResource}
/>
)}
</div>
</div>
</div>
{isMobile && (
<NavigationRail orientation="horizontal" />
)}
</div>
{/* Image preview modal */}
{previewImage && (
<ImagePreviewModal
name={previewImage}
onClose={() => setPreviewImage(null)}
onDownload={handleDownload}
getImageUrl={getImageUrl}
/>
)}
{/* File preview modal (text, PDF, audio, video, markdown) */}
{previewFile && (
<FilePreviewModal
name={previewFile}
onClose={() => setPreviewFile(null)}
onDownload={handleDownload}
getFileContent={getFileContent}
/>
)}
<ConfirmDialog {...confirmDialogProps} />
</div>
);
}
+19 -4
View File
@@ -31,19 +31,33 @@ export default function SettingsPage() {
const router = useRouter();
const t = useTranslations('settings');
const tSidebar = useTranslations('sidebar');
const { client, isAuthenticated, logout } = useAuthStore();
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig();
const [activeTab, setActiveTab] = useState<Tab>('appearance');
const [activeTab, setActiveTab] = useState<Tab>(() => {
try {
const saved = localStorage.getItem('settings-active-tab');
if (saved) return saved as Tab;
} catch { /* ignore */ }
return 'appearance';
});
const [mobileShowContent, setMobileShowContent] = useState(false);
const isDesktop = useIsDesktop();
// Check auth on mount
useEffect(() => {
if (!isAuthenticated) {
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
router.push('/login');
}
}, [isAuthenticated, router]);
}, [initialCheckDone, isAuthenticated, authLoading, router]);
if (!isAuthenticated) {
return null;
@@ -70,6 +84,7 @@ export default function SettingsPage() {
const handleTabSelect = (tabId: Tab) => {
setActiveTab(tabId);
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
if (!isDesktop) {
setMobileShowContent(true);
}
+110
View File
@@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
/**
* POST /api/webdav
* Proxies WebDAV requests to the Stalwart server.
*
* Headers:
* X-WebDAV-Method: The actual WebDAV method (PROPFIND, MKCOL, GET, PUT, DELETE, MOVE, COPY)
* X-WebDAV-Path: Resource path relative to the user's DAV root (default: /)
* X-WebDAV-Destination: Destination path for MOVE/COPY (relative to user's DAV root)
* Depth: WebDAV Depth header (forwarded as-is)
* Content-Type: Forwarded for PROPFIND (XML) and PUT (file upload)
* Overwrite: WebDAV Overwrite header for MOVE/COPY
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const method = request.headers.get('X-WebDAV-Method')?.toUpperCase();
if (!method || !ALLOWED_METHODS.has(method)) {
return NextResponse.json({ error: 'Invalid WebDAV method' }, { status: 400 });
}
const davPath = request.headers.get('X-WebDAV-Path') || '/';
const cleanPath = davPath.replace(/^\/+/, '');
const baseUrl = creds.apiUrl.replace(/\/$/, '');
const targetUrl = cleanPath
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
// Build headers for the upstream request
const upstreamHeaders: Record<string, string> = {
'Authorization': creds.authHeader,
};
// Forward relevant WebDAV headers
const depth = request.headers.get('Depth');
if (depth) upstreamHeaders['Depth'] = depth;
const contentType = request.headers.get('Content-Type');
if (contentType) upstreamHeaders['Content-Type'] = contentType;
// For MOVE/COPY, construct the full Destination URL from the relative path
const destination = request.headers.get('X-WebDAV-Destination');
if (destination) {
const cleanDest = destination.replace(/^\/+/, '');
upstreamHeaders['Destination'] = cleanDest
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
}
const overwrite = request.headers.get('Overwrite');
if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
// Forward request body for methods that need it
let body: ArrayBuffer | null = null;
if (method === 'PROPFIND' || method === 'PUT') {
body = await request.arrayBuffer();
}
const response = await fetch(targetUrl, {
method,
headers: upstreamHeaders,
body,
redirect: 'follow',
});
// For file downloads (GET), stream the response back
if (method === 'GET') {
const headers = new Headers();
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/octet-stream');
const contentLength = response.headers.get('Content-Length');
if (contentLength) headers.set('Content-Length', contentLength);
headers.set('X-WebDAV-Request-URI', targetUrl);
return new NextResponse(response.body, {
status: response.status,
headers,
});
}
// For PROPFIND, return XML with the actual request URI for href comparison
if (method === 'PROPFIND') {
const text = await response.text();
const headers = new Headers();
headers.set('Content-Type', 'application/xml; charset=utf-8');
headers.set('X-WebDAV-Request-URI', targetUrl);
return new NextResponse(text, {
status: response.status,
headers,
});
}
// For other methods (MKCOL, DELETE, MOVE, COPY, PUT), return the status
return new NextResponse(null, {
status: response.status,
});
} catch (error) {
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
File diff suppressed because it is too large Load Diff
+232
View File
@@ -0,0 +1,232 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { X, Download, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
interface FilePreviewModalProps {
name: string;
onClose: () => void;
onDownload: (name: string) => Promise<void>;
getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>;
}
const TEXT_EXTENSIONS = new Set([
"txt", "md", "markdown", "json", "xml", "html", "htm", "css", "js", "ts",
"jsx", "tsx", "py", "rb", "java", "c", "cpp", "h", "hpp", "go", "rs",
"sh", "bash", "zsh", "yaml", "yml", "toml", "ini", "cfg", "conf", "env",
"log", "csv", "sql", "graphql", "vue", "svelte", "astro", "php", "pl",
"swift", "kt", "scala", "r", "lua", "vim",
]);
function getFileType(name: string): "text" | "pdf" | "audio" | "video" | "markdown" | "unknown" {
const ext = name.split(".").pop()?.toLowerCase() || "";
const baseName = name.toLowerCase();
if (ext === "md" || ext === "markdown") return "markdown";
if (ext === "pdf") return "pdf";
if (["mp3", "wav", "ogg", "flac", "aac", "m4a", "wma", "opus"].includes(ext)) return "audio";
if (["mp4", "webm", "ogv", "mov", "avi", "mkv", "m4v"].includes(ext)) return "video";
if (TEXT_EXTENSIONS.has(ext) || ["dockerfile", "makefile", "readme", "license", "changelog"].includes(baseName)) return "text";
return "unknown";
}
function SimpleMarkdown({ content }: { content: string }) {
const lines = content.split("\n");
const elements: React.ReactNode[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Headers
if (line.startsWith("### ")) {
elements.push(<h3 key={i} className="text-lg font-semibold mt-4 mb-2">{processInline(line.slice(4))}</h3>);
} else if (line.startsWith("## ")) {
elements.push(<h2 key={i} className="text-xl font-semibold mt-5 mb-2">{processInline(line.slice(3))}</h2>);
} else if (line.startsWith("# ")) {
elements.push(<h1 key={i} className="text-2xl font-bold mt-6 mb-3">{processInline(line.slice(2))}</h1>);
} else if (line.startsWith("---") || line.startsWith("***")) {
elements.push(<hr key={i} className="my-4 border-border" />);
} else if (line.startsWith("- ") || line.startsWith("* ")) {
elements.push(<li key={i} className="ml-4 list-disc">{processInline(line.slice(2))}</li>);
} else if (/^\d+\. /.test(line)) {
elements.push(<li key={i} className="ml-4 list-decimal">{processInline(line.replace(/^\d+\. /, ""))}</li>);
} else if (line.startsWith("> ")) {
elements.push(<blockquote key={i} className="border-l-4 border-border pl-4 italic text-muted-foreground my-2">{processInline(line.slice(2))}</blockquote>);
} else if (line.startsWith("```")) {
// Code block - collect until closing ```
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
elements.push(
<pre key={i} className="bg-muted rounded p-3 my-2 overflow-x-auto text-sm font-mono">
<code>{codeLines.join("\n")}</code>
</pre>
);
} else if (line.trim() === "") {
elements.push(<div key={i} className="h-2" />);
} else {
elements.push(<p key={i} className="my-1">{processInline(line)}</p>);
}
}
return <div className="prose prose-sm dark:prose-invert max-w-none">{elements}</div>;
}
function processInline(text: string): React.ReactNode {
// Process bold, italic, code inline
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
while (remaining.length > 0) {
// Bold
const boldMatch = remaining.match(/\*\*(.+?)\*\*/);
// Inline code
const codeMatch = remaining.match(/`([^`]+)`/);
// Italic
const italicMatch = remaining.match(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/);
const matches = [
boldMatch && { type: "bold", match: boldMatch },
codeMatch && { type: "code", match: codeMatch },
italicMatch && { type: "italic", match: italicMatch },
].filter(Boolean).sort((a, b) => (a!.match.index ?? 0) - (b!.match.index ?? 0));
if (matches.length === 0) {
parts.push(remaining);
break;
}
const first = matches[0]!;
const idx = first.match.index ?? 0;
if (idx > 0) {
parts.push(remaining.slice(0, idx));
}
if (first.type === "bold") {
parts.push(<strong key={key++}>{first.match[1]}</strong>);
} else if (first.type === "code") {
parts.push(<code key={key++} className="bg-muted px-1 py-0.5 rounded text-sm font-mono">{first.match[1]}</code>);
} else {
parts.push(<em key={key++}>{first.match[1]}</em>);
}
remaining = remaining.slice(idx + first.match[0].length);
}
return parts.length === 1 ? parts[0] : <>{parts}</>;
}
export function FilePreviewModal({ name, onClose, onDownload, getFileContent }: FilePreviewModalProps) {
const t = useTranslations("files");
const [content, setContent] = useState<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const fileType = getFileType(name);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const { blob, contentType } = await getFileContent(name);
if (cancelled) return;
if (fileType === "text" || fileType === "markdown") {
const text = await blob.text();
if (!cancelled) setContent(text);
} else {
const url = URL.createObjectURL(blob);
if (!cancelled) setObjectUrl(url);
}
} catch {
if (!cancelled) setError(true);
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [name]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return (
<div role="dialog" aria-label={name} className="fixed inset-0 z-50 flex flex-col bg-black/80" onClick={onClose}>
<div className="flex items-center justify-between px-4 py-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-medium truncate">{name}</h3>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onDownload(name)}>
<Download className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
</div>
<div className="flex-1 flex items-center justify-center overflow-auto p-4" onClick={(e) => e.stopPropagation()}>
{loading && (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
)}
{error && (
<p className="text-sm text-destructive">{t("preview_error")}</p>
)}
{!loading && !error && (fileType === "text") && content !== null && (
<pre className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm font-mono whitespace-pre-wrap break-words">
{content}
</pre>
)}
{!loading && !error && fileType === "markdown" && content !== null && (
<div className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm">
<SimpleMarkdown content={content} />
</div>
)}
{!loading && !error && fileType === "pdf" && objectUrl && (
<iframe
src={objectUrl}
className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name}
/>
)}
{!loading && !error && fileType === "audio" && objectUrl && (
<div className="bg-background rounded-lg p-8 max-w-lg w-full">
<p className="text-sm font-medium mb-4 text-center">{name}</p>
<audio controls className="w-full" src={objectUrl} />
</div>
)}
{!loading && !error && fileType === "video" && objectUrl && (
<video controls className="max-w-4xl max-h-full rounded-lg" src={objectUrl} />
)}
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import { Upload, FolderPlus, FilePlus } from "lucide-react";
import { Button } from "@/components/ui/button";
interface FileUploadAreaProps {
onUpload: (files: File[]) => Promise<void>;
onCreateFolder: () => void;
onCreateTextFile?: () => void;
}
export function FileUploadArea({ onUpload, onCreateFolder, onCreateTextFile }: FileUploadAreaProps) {
const t = useTranslations("files");
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
await onUpload(files);
}
}, [onUpload]);
return (
<div className="flex items-center justify-center h-full p-8">
<div
className={`flex flex-col items-center gap-4 p-12 rounded-xl border-2 border-dashed transition-colors max-w-md w-full ${
isDragging ? "border-primary bg-primary/5" : "border-border"
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center">
<Upload className="w-8 h-8 text-muted-foreground" />
</div>
<div className="text-center">
<h3 className="text-base font-medium">{t("empty_state_title")}</h3>
<p className="text-sm text-muted-foreground mt-1">{t("empty_state_description")}</p>
<p className="text-xs text-muted-foreground mt-2">{t("drop_files_here")}</p>
</div>
<div className="flex gap-2 flex-wrap justify-center">
<Button
variant="outline"
size="sm"
onClick={onCreateFolder}
>
<FolderPlus className="w-4 h-4 mr-2" />
{t("new_folder")}
</Button>
{onCreateTextFile && (
<Button
variant="outline"
size="sm"
onClick={onCreateTextFile}
>
<FilePlus className="w-4 h-4 mr-2" />
{t("new_text_file")}
</Button>
)}
</div>
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { X, Download, ZoomIn, ZoomOut, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "next-intl";
interface ImagePreviewModalProps {
name: string;
onClose: () => void;
onDownload: (name: string) => Promise<void>;
getImageUrl: (name: string) => Promise<string>;
}
export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: ImagePreviewModalProps) {
const t = useTranslations("files");
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
useEffect(() => {
let revoke: string | null = null;
setLoading(true);
setError(false);
getImageUrl(name)
.then((url) => {
revoke = url;
setImageUrl(url);
setLoading(false);
})
.catch(() => {
setError(true);
setLoading(false);
});
return () => {
if (revoke) URL.revokeObjectURL(revoke);
};
}, [name, getImageUrl]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "+" || e.key === "=") setZoom((z) => Math.min(z + 0.25, 5));
if (e.key === "-") setZoom((z) => Math.max(z - 0.25, 0.25));
if (e.key === "r") setRotation((r) => r + 90);
}, [onClose]);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
return (
<div
role="dialog"
aria-label={name}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
onClick={onClose}
>
{/* Header */}
<div className="absolute top-0 left-0 right-0 flex items-center justify-between px-4 py-3 bg-gradient-to-b from-black/60 to-transparent z-10">
<span className="text-white text-sm font-medium truncate max-w-[50%]">{name}</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setZoom((z) => Math.min(z + 0.25, 5)); }}>
<ZoomIn className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setZoom((z) => Math.max(z - 0.25, 0.25)); }}>
<ZoomOut className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setRotation((r) => r + 90); }}>
<RotateCw className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); onDownload(name); }}>
<Download className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
</div>
{/* Image */}
<div className="flex items-center justify-center w-full h-full p-16" onClick={(e) => e.stopPropagation()}>
{loading && (
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin" />
)}
{error && (
<p className="text-white/70 text-sm">{t("preview_error")}</p>
)}
{imageUrl && !error && (
<img
src={imageUrl}
alt={name}
className="max-w-full max-h-full object-contain transition-transform duration-200"
style={{ transform: `scale(${zoom}) rotate(${rotation}deg)` }}
onLoad={() => setLoading(false)}
draggable={false}
/>
)}
</div>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface NewFolderDialogProps {
onConfirm: (name: string) => Promise<void>;
onCancel: () => void;
}
export function NewFolderDialog({ onConfirm, onCancel }: NewFolderDialogProps) {
const t = useTranslations("files");
const [name, setName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
setIsSubmitting(true);
try {
await onConfirm(trimmed);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div
className="bg-background border border-border rounded-lg shadow-lg p-6 w-full max-w-sm mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold mb-4">{t("new_folder")}</h2>
<form onSubmit={handleSubmit}>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("new_folder_name")}
className="mb-4"
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button type="submit" disabled={!name.trim() || isSubmitting}>
{t("create")}
</Button>
</div>
</form>
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface RenameDialogProps {
currentName: string;
title?: string;
label?: string;
onConfirm: (newName: string) => Promise<void>;
onCancel: () => void;
}
export function RenameDialog({ currentName, title, label, onConfirm, onCancel }: RenameDialogProps) {
const t = useTranslations("files");
const [name, setName] = useState(currentName);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
setIsSubmitting(true);
try {
await onConfirm(trimmed);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div
className="bg-background border border-border rounded-lg shadow-lg p-6 w-full max-w-sm mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold mb-4">{title || t("rename_title")}</h2>
<form onSubmit={handleSubmit}>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={label || t("new_name")}
className="mb-4"
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button type="submit" disabled={!name.trim() || isSubmitting}>
{t("save")}
</Button>
</div>
</form>
</div>
</div>
);
}
+4 -1
View File
@@ -2,11 +2,12 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { Mail, Calendar, BookUser, Settings, LogOut } from "lucide-react";
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut } from "lucide-react";
import { usePathname, Link } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store";
import { useEmailStore } from "@/stores/email-store";
import { useWebDAVStore } from "@/stores/webdav-store";
import { cn, formatFileSize } from "@/lib/utils";
interface NavItem {
@@ -140,12 +141,14 @@ export function NavigationRail({
const pathname = usePathname();
const { supportsCalendar } = useCalendarStore();
const { mailboxes } = useEmailStore();
const { supportsWebDAV } = useWebDAVStore();
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const navItems: NavItem[] = [
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false },
{ id: "settings", icon: Settings, labelKey: "settings", href: "/settings" },
];
+8
View File
@@ -144,6 +144,14 @@ export class JMAPClient {
this.authHeader = `Bearer ${token}`;
}
getAuthHeader(): string {
return this.authHeader;
}
getServerUrl(): string {
return this.serverUrl;
}
private async authenticatedFetch(url: string, init?: Parameters<typeof fetch>[1]): Promise<Response> {
const headers = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
let response: Response;
+294
View File
@@ -0,0 +1,294 @@
/**
* WebDAV client that proxies through /api/webdav to avoid CORS issues.
* The server-side proxy handles auth and forwards requests to Stalwart's /dav/file/ endpoint.
*/
export interface WebDAVResource {
href: string;
name: string;
isDirectory: boolean;
contentType: string;
contentLength: number;
lastModified: string;
etag: string;
}
export class WebDAVClient {
private proxyUrl = '/api/webdav';
/**
* Send a WebDAV request through the proxy.
*/
private async request(method: string, path: string, options?: {
headers?: Record<string, string>;
body?: string | ArrayBuffer | Blob;
}): Promise<Response> {
const headers: Record<string, string> = {
'X-WebDAV-Method': method,
'X-WebDAV-Path': path,
...options?.headers,
};
return fetch(this.proxyUrl, {
method: 'POST',
headers,
body: options?.body,
});
}
/**
* Check if WebDAV is available by sending a PROPFIND to the root.
*/
async checkSupport(): Promise<boolean> {
try {
const response = await this.request('PROPFIND', '/', {
headers: {
'Depth': '0',
'Content-Type': 'application/xml; charset=utf-8',
},
body: `<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:resourcetype/>
</D:prop>
</D:propfind>`,
});
return response.status === 207;
} catch {
return false;
}
}
/**
* List contents of a directory via PROPFIND with Depth: 1
*/
async list(path: string = '/'): Promise<WebDAVResource[]> {
const response = await this.request('PROPFIND', path, {
headers: {
'Depth': '1',
'Content-Type': 'application/xml; charset=utf-8',
},
body: `<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:resourcetype/>
<D:getcontenttype/>
<D:getcontentlength/>
<D:getlastmodified/>
<D:getetag/>
<D:displayname/>
</D:prop>
</D:propfind>`,
});
if (response.status !== 207) {
throw new Error(`PROPFIND failed: ${response.status} ${response.statusText}`);
}
const text = await response.text();
const requestUri = response.headers.get('X-WebDAV-Request-URI') || '';
return this.parseMultistatus(text, requestUri);
}
/**
* Create a new directory
*/
async createDirectory(path: string): Promise<void> {
const response = await this.request('MKCOL', path);
if (response.status !== 201 && response.status !== 204) {
throw new Error(`MKCOL failed: ${response.status} ${response.statusText}`);
}
}
/**
* Upload a file with optional progress tracking
*/
async uploadFile(
path: string,
file: File | Blob,
contentType?: string,
onProgress?: (loaded: number, total: number) => void,
signal?: AbortSignal,
): Promise<void> {
if (onProgress) {
// Use XMLHttpRequest for progress tracking
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', this.proxyUrl);
xhr.setRequestHeader('X-WebDAV-Method', 'PUT');
xhr.setRequestHeader('X-WebDAV-Path', path);
xhr.setRequestHeader('Content-Type',
contentType || (file instanceof File ? file.type : 'application/octet-stream'));
if (signal) {
signal.addEventListener('abort', () => {
xhr.abort();
reject(new DOMException('Upload aborted', 'AbortError'));
});
}
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(e.loaded, e.total);
};
xhr.onload = () => {
if (xhr.status === 200 || xhr.status === 201 || xhr.status === 204) {
resolve();
} else {
reject(new Error(`PUT failed: ${xhr.status} ${xhr.statusText}`));
}
};
xhr.onerror = () => reject(new Error('Upload failed'));
xhr.send(file);
});
}
const response = await this.request('PUT', path, {
headers: {
'Content-Type': contentType || (file instanceof File ? file.type : 'application/octet-stream'),
},
body: file,
});
if (response.status !== 201 && response.status !== 204 && response.status !== 200) {
throw new Error(`PUT failed: ${response.status} ${response.statusText}`);
}
}
/**
* Download a file
*/
async downloadFile(path: string): Promise<{ blob: Blob; contentType: string; filename: string }> {
const response = await this.request('GET', path);
if (!response.ok) {
throw new Error(`GET failed: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
const contentType = response.headers.get('Content-Type') || 'application/octet-stream';
const filename = path.split('/').pop() || 'download';
return { blob, contentType, filename };
}
/**
* Delete a file or directory
*/
async delete(path: string): Promise<void> {
const response = await this.request('DELETE', path);
if (response.status !== 204 && response.status !== 200) {
throw new Error(`DELETE failed: ${response.status} ${response.statusText}`);
}
}
/**
* Move/rename a resource
*/
async move(fromPath: string, toPath: string): Promise<void> {
const response = await this.request('MOVE', fromPath, {
headers: {
'X-WebDAV-Destination': toPath,
'Overwrite': 'F',
},
});
if (response.status !== 201 && response.status !== 204) {
throw new Error(`MOVE failed: ${response.status} ${response.statusText}`);
}
}
/**
* Copy a resource
*/
async copy(fromPath: string, toPath: string): Promise<void> {
const response = await this.request('COPY', fromPath, {
headers: {
'X-WebDAV-Destination': toPath,
'Overwrite': 'F',
},
});
if (response.status !== 201 && response.status !== 204) {
throw new Error(`COPY failed: ${response.status} ${response.statusText}`);
}
}
/**
* Parse a WebDAV multistatus XML response into WebDAVResource[]
*/
private parseMultistatus(xml: string, requestUrl: string): WebDAVResource[] {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'application/xml');
const responses = doc.getElementsByTagNameNS('DAV:', 'response');
const resources: WebDAVResource[] = [];
// Normalize the request URL for comparison (skip the "self" entry)
const normalizedRequestUrl = requestUrl.replace(/\/+$/, '');
for (let i = 0; i < responses.length; i++) {
const resp = responses[i];
const hrefEl = resp.getElementsByTagNameNS('DAV:', 'href')[0];
if (!hrefEl?.textContent) continue;
const href = decodeURIComponent(hrefEl.textContent);
// Skip the directory itself (the parent being listed)
const normalizedHref = href.replace(/\/+$/, '');
if (this.isSameResource(normalizedHref, normalizedRequestUrl)) continue;
const propstat = resp.getElementsByTagNameNS('DAV:', 'propstat')[0];
if (!propstat) continue;
const statusEl = propstat.getElementsByTagNameNS('DAV:', 'status')[0];
if (statusEl?.textContent && !statusEl.textContent.includes('200')) continue;
const prop = propstat.getElementsByTagNameNS('DAV:', 'prop')[0];
if (!prop) continue;
const resourceType = prop.getElementsByTagNameNS('DAV:', 'resourcetype')[0];
const isDirectory = !!resourceType?.getElementsByTagNameNS('DAV:', 'collection')[0];
const displayName = prop.getElementsByTagNameNS('DAV:', 'displayname')[0]?.textContent || '';
const contentType = prop.getElementsByTagNameNS('DAV:', 'getcontenttype')[0]?.textContent || '';
const contentLengthStr = prop.getElementsByTagNameNS('DAV:', 'getcontentlength')[0]?.textContent || '0';
const lastModified = prop.getElementsByTagNameNS('DAV:', 'getlastmodified')[0]?.textContent || '';
const etag = prop.getElementsByTagNameNS('DAV:', 'getetag')[0]?.textContent || '';
// Extract the name from the href path
const segments = href.replace(/\/+$/, '').split('/');
const name = displayName || segments[segments.length - 1] || '';
resources.push({
href,
name,
isDirectory,
contentType: isDirectory ? '' : contentType,
contentLength: parseInt(contentLengthStr, 10) || 0,
lastModified,
etag,
});
}
// 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);
});
return resources;
}
private isSameResource(href1: string, href2: string): boolean {
// Compare by path only (ignore origin differences)
try {
const path1 = new URL(href1, 'http://dummy').pathname.replace(/\/+$/, '');
const path2 = new URL(href2, 'http://dummy').pathname.replace(/\/+$/, '');
return path1 === path2;
} catch {
return href1 === href2;
}
}
}
+79
View File
@@ -61,6 +61,7 @@
"contacts": "Kontakte",
"calendar": "Kalender",
"settings": "Einstellungen",
"files": "Dateien",
"loading_mailboxes": "Postfächer werden geladen...",
"push_connected": "Echtzeit-Updates aktiv",
"push_disconnected": "Echtzeit-Updates inaktiv",
@@ -1692,5 +1693,83 @@
"got_it": "Verstanden",
"settings": "Einstellungen",
"dismiss": "Schließen"
},
"files": {
"title": "Dateien",
"search_placeholder": "Dateien suchen...",
"empty_state_title": "Noch keine Dateien",
"empty_state_description": "Laden Sie Dateien hoch oder erstellen Sie Ordner, um loszulegen",
"upload": "Hochladen",
"upload_files": "Dateien hochladen",
"new_folder": "Neuer Ordner",
"new_folder_name": "Ordnername",
"rename": "Umbenennen",
"rename_title": "Umbenennen",
"new_name": "Neuer Name",
"delete": "Löschen",
"delete_confirm_title": "Ressource löschen",
"delete_confirm_message": "Möchten Sie \"{name}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.",
"download": "Herunterladen",
"name": "Name",
"size": "Größe",
"modified": "Geändert",
"type": "Typ",
"folder": "Ordner",
"file": "Datei",
"parent_directory": "Übergeordnetes Verzeichnis",
"breadcrumb_root": "Startseite",
"drop_files_here": "Dateien hier ablegen zum Hochladen",
"uploading": "Wird hochgeladen...",
"upload_success": "{count, plural, one {1 Datei hochgeladen} other {# Dateien hochgeladen}}",
"upload_error": "Datei konnte nicht hochgeladen werden",
"create_folder_success": "Ordner erstellt",
"create_folder_error": "Ordner konnte nicht erstellt werden",
"delete_success": "Erfolgreich gelöscht",
"delete_error": "Löschen fehlgeschlagen",
"rename_success": "Erfolgreich umbenannt",
"rename_error": "Umbenennen fehlgeschlagen",
"download_error": "Herunterladen fehlgeschlagen",
"not_available": "WebDAV-Dateispeicher ist auf diesem Server nicht verfügbar",
"cancel": "Abbrechen",
"create": "Erstellen",
"save": "Speichern",
"no_results": "Keine Dateien entsprechen Ihrer Suche",
"batch_delete_confirm_message": "Möchten Sie wirklich {count, plural, one {1 Element} other {# Elemente}} löschen? Dies kann nicht rückgängig gemacht werden.",
"batch_delete_success": "{count, plural, one {1 Element gelöscht} other {# Elemente gelöscht}}",
"grid_view": "Rasteransicht",
"list_view": "Listenansicht",
"details": "Details",
"path": "Pfad",
"preview": "Vorschau",
"preview_error": "Vorschau konnte nicht geladen werden",
"cut": "Ausschneiden",
"copy": "Kopieren",
"paste": "Einfügen",
"move_success": "{count, plural, one {1 Element verschoben} other {# Elemente verschoben}}",
"move_error": "Verschieben fehlgeschlagen",
"paste_success": "Erfolgreich eingefügt",
"paste_error": "Einfügen fehlgeschlagen",
"new_text_file": "Neue Textdatei",
"file_name": "Dateiname",
"retry": "Wiederholen",
"refresh": "Aktualisieren",
"toggle_favorite": "Favorit umschalten",
"duplicate": "Duplizieren",
"duplicate_success": "Erfolgreich dupliziert",
"duplicate_error": "Duplizieren fehlgeschlagen",
"create_file_success": "Datei erstellt",
"create_file_error": "Datei konnte nicht erstellt werden",
"favorites": "Favoriten",
"recent": "Zuletzt verwendet",
"properties": "Eigenschaften",
"open_folder": "Ordner öffnen",
"upload_folder": "Ordner hochladen",
"file_too_large": "\"{name}\" überschreitet die maximale Dateigröße ({max})",
"undo": "Rückgängig",
"undo_success": "Aktion rückgängig gemacht",
"undo_error": "Rückgängig machen fehlgeschlagen",
"toolbar": "Dateiaktionen",
"file_list": "Dateien und Ordner",
"context_menu": "Aktionen"
}
}
+79
View File
@@ -61,6 +61,7 @@
"contacts": "Contacts",
"calendar": "Calendar",
"settings": "Settings",
"files": "Files",
"loading_mailboxes": "Loading mailboxes...",
"push_connected": "Real-time updates active",
"push_disconnected": "Real-time updates inactive",
@@ -1729,5 +1730,83 @@
"got_it": "Got it",
"settings": "Settings",
"dismiss": "Dismiss"
},
"files": {
"title": "Files",
"search_placeholder": "Search files...",
"empty_state_title": "No files yet",
"empty_state_description": "Upload files or create folders to get started",
"upload": "Upload",
"upload_files": "Upload Files",
"new_folder": "New Folder",
"new_folder_name": "Folder name",
"rename": "Rename",
"rename_title": "Rename",
"new_name": "New name",
"delete": "Delete",
"delete_confirm_title": "Delete resource",
"delete_confirm_message": "Are you sure you want to delete \"{name}\"? This cannot be undone.",
"download": "Download",
"name": "Name",
"size": "Size",
"modified": "Modified",
"type": "Type",
"folder": "Folder",
"file": "File",
"parent_directory": "Parent directory",
"breadcrumb_root": "Home",
"drop_files_here": "Drop files here to upload",
"uploading": "Uploading...",
"upload_success": "{count, plural, one {1 file uploaded} other {# files uploaded}}",
"upload_error": "Failed to upload file",
"create_folder_success": "Folder created",
"create_folder_error": "Failed to create folder",
"delete_success": "Deleted successfully",
"delete_error": "Failed to delete",
"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",
"cancel": "Cancel",
"create": "Create",
"save": "Save",
"no_results": "No files match your search",
"batch_delete_confirm_message": "Are you sure you want to delete {count, plural, one {1 item} other {# items}}? This cannot be undone.",
"batch_delete_success": "{count, plural, one {1 item deleted} other {# items deleted}}",
"grid_view": "Grid view",
"list_view": "List view",
"details": "Details",
"path": "Path",
"preview": "Preview",
"preview_error": "Failed to load preview",
"cut": "Cut",
"copy": "Copy",
"paste": "Paste",
"move_success": "{count, plural, one {1 item moved} other {# items moved}}",
"move_error": "Failed to move",
"paste_success": "Pasted successfully",
"paste_error": "Failed to paste",
"new_text_file": "New Text File",
"file_name": "File name",
"retry": "Retry",
"refresh": "Refresh",
"toggle_favorite": "Toggle favorite",
"duplicate": "Duplicate",
"duplicate_success": "Duplicated successfully",
"duplicate_error": "Failed to duplicate",
"create_file_success": "File created",
"create_file_error": "Failed to create file",
"favorites": "Favorites",
"recent": "Recent",
"properties": "Properties",
"open_folder": "Open folder",
"upload_folder": "Upload Folder",
"file_too_large": "\"{name}\" exceeds the maximum file size ({max})",
"undo": "Undo",
"undo_success": "Action undone",
"undo_error": "Failed to undo",
"toolbar": "File actions",
"file_list": "Files and folders",
"context_menu": "Actions"
}
}
+79
View File
@@ -61,6 +61,7 @@
"contacts": "Contactos",
"calendar": "Calendario",
"settings": "Configuración",
"files": "Archivos",
"loading_mailboxes": "Cargando buzones...",
"push_connected": "Actualizaciones en tiempo real activas",
"push_disconnected": "Actualizaciones en tiempo real inactivas",
@@ -1692,5 +1693,83 @@
"got_it": "Entendido",
"settings": "Ajustes",
"dismiss": "Cerrar"
},
"files": {
"title": "Archivos",
"search_placeholder": "Buscar archivos...",
"empty_state_title": "Aún no hay archivos",
"empty_state_description": "Suba archivos o cree carpetas para comenzar",
"upload": "Subir",
"upload_files": "Subir archivos",
"new_folder": "Nueva carpeta",
"new_folder_name": "Nombre de la carpeta",
"rename": "Renombrar",
"rename_title": "Renombrar",
"new_name": "Nuevo nombre",
"delete": "Eliminar",
"delete_confirm_title": "Eliminar recurso",
"delete_confirm_message": "¿Está seguro de que desea eliminar \"{name}\"? Esta acción no se puede deshacer.",
"download": "Descargar",
"name": "Nombre",
"size": "Tamaño",
"modified": "Modificado",
"type": "Tipo",
"folder": "Carpeta",
"file": "Archivo",
"parent_directory": "Directorio superior",
"breadcrumb_root": "Inicio",
"drop_files_here": "Suelte los archivos aquí para subirlos",
"uploading": "Subiendo...",
"upload_success": "{count, plural, one {1 archivo subido} other {# archivos subidos}}",
"upload_error": "Error al subir el archivo",
"create_folder_success": "Carpeta creada",
"create_folder_error": "Error al crear la carpeta",
"delete_success": "Eliminado correctamente",
"delete_error": "Error al eliminar",
"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",
"cancel": "Cancelar",
"create": "Crear",
"save": "Guardar",
"no_results": "Ningún archivo coincide con su búsqueda",
"batch_delete_confirm_message": "¿Está seguro de que desea eliminar {count, plural, one {1 elemento} other {# elementos}}? Esta acción no se puede deshacer.",
"batch_delete_success": "{count, plural, one {1 elemento eliminado} other {# elementos eliminados}}",
"grid_view": "Vista de cuadrícula",
"list_view": "Vista de lista",
"details": "Detalles",
"path": "Ruta",
"preview": "Vista previa",
"preview_error": "Error al cargar la vista previa",
"cut": "Cortar",
"copy": "Copiar",
"paste": "Pegar",
"move_success": "{count, plural, one {1 elemento movido} other {# elementos movidos}}",
"move_error": "Error al mover",
"paste_success": "Pegado correctamente",
"paste_error": "Error al pegar",
"new_text_file": "Nuevo archivo de texto",
"file_name": "Nombre del archivo",
"retry": "Reintentar",
"refresh": "Actualizar",
"toggle_favorite": "Alternar favorito",
"duplicate": "Duplicar",
"duplicate_success": "Duplicado correctamente",
"duplicate_error": "Error al duplicar",
"create_file_success": "Archivo creado",
"create_file_error": "Error al crear el archivo",
"favorites": "Favoritos",
"recent": "Recientes",
"properties": "Propiedades",
"open_folder": "Abrir carpeta",
"upload_folder": "Subir carpeta",
"file_too_large": "\"{name}\" excede el tamaño máximo de archivo ({max})",
"undo": "Deshacer",
"undo_success": "Acción deshecha",
"undo_error": "Error al deshacer",
"toolbar": "Acciones de archivo",
"file_list": "Archivos y carpetas",
"context_menu": "Acciones"
}
}
+79
View File
@@ -61,6 +61,7 @@
"contacts": "Contacts",
"calendar": "Calendrier",
"settings": "Paramètres",
"files": "Fichiers",
"loading_mailboxes": "Chargement des boîtes mail...",
"push_connected": "Mises à jour en temps réel actives",
"push_disconnected": "Mises à jour en temps réel inactives",
@@ -1692,5 +1693,83 @@
"got_it": "Compris",
"settings": "Paramètres",
"dismiss": "Fermer"
},
"files": {
"title": "Fichiers",
"search_placeholder": "Rechercher des fichiers...",
"empty_state_title": "Aucun fichier pour le moment",
"empty_state_description": "Téléversez des fichiers ou créez des dossiers pour commencer",
"upload": "Téléverser",
"upload_files": "Téléverser des fichiers",
"new_folder": "Nouveau dossier",
"new_folder_name": "Nom du dossier",
"rename": "Renommer",
"rename_title": "Renommer",
"new_name": "Nouveau nom",
"delete": "Supprimer",
"delete_confirm_title": "Supprimer la ressource",
"delete_confirm_message": "Êtes-vous sûr de vouloir supprimer \"{name}\" ? Cette action est irréversible.",
"download": "Télécharger",
"name": "Nom",
"size": "Taille",
"modified": "Modifié",
"type": "Type",
"folder": "Dossier",
"file": "Fichier",
"parent_directory": "Répertoire parent",
"breadcrumb_root": "Accueil",
"drop_files_here": "Déposez les fichiers ici pour les téléverser",
"uploading": "Téléversement en cours...",
"upload_success": "{count, plural, one {1 fichier téléversé} other {# fichiers téléversés}}",
"upload_error": "Échec du téléversement du fichier",
"create_folder_success": "Dossier créé",
"create_folder_error": "Échec de la création du dossier",
"delete_success": "Supprimé avec succès",
"delete_error": "Échec de la suppression",
"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",
"cancel": "Annuler",
"create": "Créer",
"save": "Enregistrer",
"no_results": "Aucun fichier ne correspond à votre recherche",
"batch_delete_confirm_message": "Êtes-vous sûr de vouloir supprimer {count, plural, one {1 élément} other {# éléments}} ? Cette action est irréversible.",
"batch_delete_success": "{count, plural, one {1 élément supprimé} other {# éléments supprimés}}",
"grid_view": "Vue en grille",
"list_view": "Vue en liste",
"details": "Détails",
"path": "Chemin",
"preview": "Aperçu",
"preview_error": "Échec du chargement de l'aperçu",
"cut": "Couper",
"copy": "Copier",
"paste": "Coller",
"move_success": "{count, plural, one {1 élément déplacé} other {# éléments déplacés}}",
"move_error": "Échec du déplacement",
"paste_success": "Collé avec succès",
"paste_error": "Échec du collage",
"new_text_file": "Nouveau fichier texte",
"file_name": "Nom du fichier",
"retry": "Réessayer",
"refresh": "Actualiser",
"toggle_favorite": "Basculer le favori",
"duplicate": "Dupliquer",
"duplicate_success": "Dupliqué avec succès",
"duplicate_error": "Échec de la duplication",
"create_file_success": "Fichier créé",
"create_file_error": "Échec de la création du fichier",
"favorites": "Favoris",
"recent": "Récents",
"properties": "Propriétés",
"open_folder": "Ouvrir le dossier",
"upload_folder": "Téléverser un dossier",
"file_too_large": "\"{name}\" dépasse la taille maximale du fichier ({max})",
"undo": "Annuler",
"undo_success": "Action annulée",
"undo_error": "Échec de l'annulation",
"toolbar": "Actions sur les fichiers",
"file_list": "Fichiers et dossiers",
"context_menu": "Actions"
}
}
+79
View File
@@ -61,6 +61,7 @@
"contacts": "Contatti",
"calendar": "Calendario",
"settings": "Impostazioni",
"files": "File",
"loading_mailboxes": "Caricamento caselle di posta...",
"push_connected": "Aggiornamenti in tempo reale attivi",
"push_disconnected": "Aggiornamenti in tempo reale non attivi",
@@ -1692,5 +1693,83 @@
"got_it": "Ho capito",
"settings": "Impostazioni",
"dismiss": "Chiudi"
},
"files": {
"title": "File",
"search_placeholder": "Cerca file...",
"empty_state_title": "Nessun file ancora",
"empty_state_description": "Carica file o crea cartelle per iniziare",
"upload": "Carica",
"upload_files": "Carica file",
"new_folder": "Nuova cartella",
"new_folder_name": "Nome cartella",
"rename": "Rinomina",
"rename_title": "Rinomina",
"new_name": "Nuovo nome",
"delete": "Elimina",
"delete_confirm_title": "Elimina risorsa",
"delete_confirm_message": "Sei sicuro di voler eliminare \"{name}\"? Questa azione non può essere annullata.",
"download": "Scarica",
"name": "Nome",
"size": "Dimensione",
"modified": "Modificato",
"type": "Tipo",
"folder": "Cartella",
"file": "File",
"parent_directory": "Directory superiore",
"breadcrumb_root": "Home",
"drop_files_here": "Trascina i file qui per caricarli",
"uploading": "Caricamento in corso...",
"upload_success": "{count, plural, one {1 file caricato} other {# file caricati}}",
"upload_error": "Caricamento del file non riuscito",
"create_folder_success": "Cartella creata",
"create_folder_error": "Creazione della cartella non riuscita",
"delete_success": "Eliminato con successo",
"delete_error": "Eliminazione non riuscita",
"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",
"cancel": "Annulla",
"create": "Crea",
"save": "Salva",
"no_results": "Nessun file corrisponde alla ricerca",
"batch_delete_confirm_message": "Sei sicuro di voler eliminare {count, plural, one {1 elemento} other {# elementi}}? Questa azione non può essere annullata.",
"batch_delete_success": "{count, plural, one {1 elemento eliminato} other {# elementi eliminati}}",
"grid_view": "Vista a griglia",
"list_view": "Vista a elenco",
"details": "Dettagli",
"path": "Percorso",
"preview": "Anteprima",
"preview_error": "Caricamento dell'anteprima non riuscito",
"cut": "Taglia",
"copy": "Copia",
"paste": "Incolla",
"move_success": "{count, plural, one {1 elemento spostato} other {# elementi spostati}}",
"move_error": "Spostamento non riuscito",
"paste_success": "Incollato con successo",
"paste_error": "Incollaggio non riuscito",
"new_text_file": "Nuovo file di testo",
"file_name": "Nome del file",
"retry": "Riprova",
"refresh": "Aggiorna",
"toggle_favorite": "Attiva/disattiva preferito",
"duplicate": "Duplica",
"duplicate_success": "Duplicato con successo",
"duplicate_error": "Duplicazione non riuscita",
"create_file_success": "File creato",
"create_file_error": "Creazione del file non riuscita",
"favorites": "Preferiti",
"recent": "Recenti",
"properties": "Proprietà",
"open_folder": "Apri cartella",
"upload_folder": "Carica cartella",
"file_too_large": "\"{name}\" supera la dimensione massima del file ({max})",
"undo": "Annulla",
"undo_success": "Azione annullata",
"undo_error": "Annullamento non riuscito",
"toolbar": "Azioni file",
"file_list": "File e cartelle",
"context_menu": "Azioni"
}
}
+79
View File
@@ -61,6 +61,7 @@
"contacts": "連絡先",
"calendar": "カレンダー",
"settings": "設定",
"files": "ファイル",
"loading_mailboxes": "メールボックスを読み込み中...",
"push_connected": "リアルタイム更新が有効",
"push_disconnected": "リアルタイム更新が無効",
@@ -1692,5 +1693,83 @@
"got_it": "了解",
"settings": "設定",
"dismiss": "閉じる"
},
"files": {
"title": "ファイル",
"search_placeholder": "ファイルを検索...",
"empty_state_title": "ファイルがありません",
"empty_state_description": "ファイルをアップロードするかフォルダーを作成して始めましょう",
"upload": "アップロード",
"upload_files": "ファイルをアップロード",
"new_folder": "新しいフォルダー",
"new_folder_name": "フォルダー名",
"rename": "名前を変更",
"rename_title": "名前を変更",
"new_name": "新しい名前",
"delete": "削除",
"delete_confirm_title": "リソースを削除",
"delete_confirm_message": "\"{name}\"を削除してもよろしいですか?この操作は元に戻せません。",
"download": "ダウンロード",
"name": "名前",
"size": "サイズ",
"modified": "更新日時",
"type": "種類",
"folder": "フォルダー",
"file": "ファイル",
"parent_directory": "親ディレクトリ",
"breadcrumb_root": "ホーム",
"drop_files_here": "ここにファイルをドロップしてアップロード",
"uploading": "アップロード中...",
"upload_success": "{count, plural, other {#件のファイルをアップロードしました}}",
"upload_error": "ファイルのアップロードに失敗しました",
"create_folder_success": "フォルダーを作成しました",
"create_folder_error": "フォルダーの作成に失敗しました",
"delete_success": "正常に削除しました",
"delete_error": "削除に失敗しました",
"rename_success": "正常に名前を変更しました",
"rename_error": "名前の変更に失敗しました",
"download_error": "ダウンロードに失敗しました",
"not_available": "WebDAVファイルストレージはこのサーバーで利用できません",
"cancel": "キャンセル",
"create": "作成",
"save": "保存",
"no_results": "検索に一致するファイルがありません",
"batch_delete_confirm_message": "{count, plural, other {#件のアイテム}}を削除してもよろしいですか?この操作は元に戻せません。",
"batch_delete_success": "{count, plural, other {#件のアイテムを削除しました}}",
"grid_view": "グリッド表示",
"list_view": "リスト表示",
"details": "詳細",
"path": "パス",
"preview": "プレビュー",
"preview_error": "プレビューの読み込みに失敗しました",
"cut": "切り取り",
"copy": "コピー",
"paste": "貼り付け",
"move_success": "{count, plural, other {#件のアイテムを移動しました}}",
"move_error": "移動に失敗しました",
"paste_success": "正常に貼り付けました",
"paste_error": "貼り付けに失敗しました",
"new_text_file": "新規テキストファイル",
"file_name": "ファイル名",
"retry": "再試行",
"refresh": "更新",
"toggle_favorite": "お気に入り切替",
"duplicate": "複製",
"duplicate_success": "正常に複製しました",
"duplicate_error": "複製に失敗しました",
"create_file_success": "ファイルを作成しました",
"create_file_error": "ファイルの作成に失敗しました",
"favorites": "お気に入り",
"recent": "最近のファイル",
"properties": "プロパティ",
"open_folder": "フォルダを開く",
"upload_folder": "フォルダをアップロード",
"file_too_large": "\"{name}\" がファイルサイズの上限を超えています({max})",
"undo": "元に戻す",
"undo_success": "操作を元に戻しました",
"undo_error": "元に戻すのに失敗しました",
"toolbar": "ファイル操作",
"file_list": "ファイルとフォルダ",
"context_menu": "操作"
}
}
+79
View File
@@ -61,6 +61,7 @@
"contacts": "Contacten",
"calendar": "Agenda",
"settings": "Instellingen",
"files": "Bestanden",
"loading_mailboxes": "Mappen laden...",
"push_connected": "Real-time updates actief",
"push_disconnected": "Real-time updates inactief",
@@ -1692,5 +1693,83 @@
"got_it": "Begrepen",
"settings": "Instellingen",
"dismiss": "Sluiten"
},
"files": {
"title": "Bestanden",
"search_placeholder": "Bestanden zoeken...",
"empty_state_title": "Nog geen bestanden",
"empty_state_description": "Upload bestanden of maak mappen aan om te beginnen",
"upload": "Uploaden",
"upload_files": "Bestanden uploaden",
"new_folder": "Nieuwe map",
"new_folder_name": "Mapnaam",
"rename": "Hernoemen",
"rename_title": "Hernoemen",
"new_name": "Nieuwe naam",
"delete": "Verwijderen",
"delete_confirm_title": "Bron verwijderen",
"delete_confirm_message": "Weet u zeker dat u \"{name}\" wilt verwijderen? Dit kan niet ongedaan worden gemaakt.",
"download": "Downloaden",
"name": "Naam",
"size": "Grootte",
"modified": "Gewijzigd",
"type": "Type",
"folder": "Map",
"file": "Bestand",
"parent_directory": "Bovenliggende map",
"breadcrumb_root": "Start",
"drop_files_here": "Sleep bestanden hierheen om te uploaden",
"uploading": "Uploaden...",
"upload_success": "{count, plural, one {1 bestand geüpload} other {# bestanden geüpload}}",
"upload_error": "Bestand uploaden mislukt",
"create_folder_success": "Map aangemaakt",
"create_folder_error": "Map aanmaken mislukt",
"delete_success": "Succesvol verwijderd",
"delete_error": "Verwijderen mislukt",
"rename_success": "Succesvol hernoemd",
"rename_error": "Hernoemen mislukt",
"download_error": "Downloaden mislukt",
"not_available": "WebDAV-bestandsopslag is niet beschikbaar op deze server",
"cancel": "Annuleren",
"create": "Aanmaken",
"save": "Opslaan",
"no_results": "Geen bestanden komen overeen met uw zoekopdracht",
"batch_delete_confirm_message": "Weet u zeker dat u {count, plural, one {1 item} other {# items}} wilt verwijderen? Dit kan niet ongedaan worden gemaakt.",
"batch_delete_success": "{count, plural, one {1 item verwijderd} other {# items verwijderd}}",
"grid_view": "Rasterweergave",
"list_view": "Lijstweergave",
"details": "Details",
"path": "Pad",
"preview": "Voorbeeld",
"preview_error": "Voorbeeld laden mislukt",
"cut": "Knippen",
"copy": "Kopiëren",
"paste": "Plakken",
"move_success": "{count, plural, one {1 item verplaatst} other {# items verplaatst}}",
"move_error": "Verplaatsen mislukt",
"paste_success": "Succesvol geplakt",
"paste_error": "Plakken mislukt",
"new_text_file": "Nieuw tekstbestand",
"file_name": "Bestandsnaam",
"retry": "Opnieuw proberen",
"refresh": "Vernieuwen",
"toggle_favorite": "Favoriet aan/uit",
"duplicate": "Dupliceren",
"duplicate_success": "Succesvol gedupliceerd",
"duplicate_error": "Dupliceren mislukt",
"create_file_success": "Bestand aangemaakt",
"create_file_error": "Bestand aanmaken mislukt",
"favorites": "Favorieten",
"recent": "Recent",
"properties": "Eigenschappen",
"open_folder": "Map openen",
"upload_folder": "Map uploaden",
"file_too_large": "\"{name}\" overschrijdt de maximale bestandsgrootte ({max})",
"undo": "Ongedaan maken",
"undo_success": "Actie ongedaan gemaakt",
"undo_error": "Ongedaan maken mislukt",
"toolbar": "Bestandsacties",
"file_list": "Bestanden en mappen",
"context_menu": "Acties"
}
}
+79
View File
@@ -61,6 +61,7 @@
"contacts": "Contatos",
"calendar": "Calendário",
"settings": "Configurações",
"files": "Ficheiros",
"loading_mailboxes": "Carregando caixas de entrada...",
"push_connected": "Atualizações em tempo real ativas",
"push_disconnected": "Atualizações em tempo real inativas",
@@ -1692,5 +1693,83 @@
"got_it": "Entendi",
"settings": "Configurações",
"dismiss": "Fechar"
},
"files": {
"title": "Ficheiros",
"search_placeholder": "Pesquisar ficheiros...",
"empty_state_title": "Ainda não há ficheiros",
"empty_state_description": "Carregue ficheiros ou crie pastas para começar",
"upload": "Carregar",
"upload_files": "Carregar ficheiros",
"new_folder": "Nova pasta",
"new_folder_name": "Nome da pasta",
"rename": "Renomear",
"rename_title": "Renomear",
"new_name": "Novo nome",
"delete": "Eliminar",
"delete_confirm_title": "Eliminar recurso",
"delete_confirm_message": "Tem a certeza de que deseja eliminar \"{name}\"? Esta ação não pode ser desfeita.",
"download": "Transferir",
"name": "Nome",
"size": "Tamanho",
"modified": "Modificado",
"type": "Tipo",
"folder": "Pasta",
"file": "Ficheiro",
"parent_directory": "Diretório superior",
"breadcrumb_root": "Início",
"drop_files_here": "Largue os ficheiros aqui para carregar",
"uploading": "A carregar...",
"upload_success": "{count, plural, one {1 ficheiro carregado} other {# ficheiros carregados}}",
"upload_error": "Falha ao carregar o ficheiro",
"create_folder_success": "Pasta criada",
"create_folder_error": "Falha ao criar a pasta",
"delete_success": "Eliminado com sucesso",
"delete_error": "Falha ao eliminar",
"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",
"cancel": "Cancelar",
"create": "Criar",
"save": "Guardar",
"no_results": "Nenhum ficheiro corresponde à sua pesquisa",
"batch_delete_confirm_message": "Tem a certeza de que deseja eliminar {count, plural, one {1 item} other {# itens}}? Esta ação não pode ser desfeita.",
"batch_delete_success": "{count, plural, one {1 item eliminado} other {# itens eliminados}}",
"grid_view": "Vista em grelha",
"list_view": "Vista em lista",
"details": "Detalhes",
"path": "Caminho",
"preview": "Pré-visualização",
"preview_error": "Falha ao carregar a pré-visualização",
"cut": "Cortar",
"copy": "Copiar",
"paste": "Colar",
"move_success": "{count, plural, one {1 item movido} other {# itens movidos}}",
"move_error": "Falha ao mover",
"paste_success": "Colado com sucesso",
"paste_error": "Falha ao colar",
"new_text_file": "Novo ficheiro de texto",
"file_name": "Nome do ficheiro",
"retry": "Tentar novamente",
"refresh": "Atualizar",
"toggle_favorite": "Alternar favorito",
"duplicate": "Duplicar",
"duplicate_success": "Duplicado com sucesso",
"duplicate_error": "Falha ao duplicar",
"create_file_success": "Ficheiro criado",
"create_file_error": "Falha ao criar ficheiro",
"favorites": "Favoritos",
"recent": "Recentes",
"properties": "Propriedades",
"open_folder": "Abrir pasta",
"upload_folder": "Carregar pasta",
"file_too_large": "\"{name}\" excede o tamanho máximo do ficheiro ({max})",
"undo": "Desfazer",
"undo_success": "Ação desfeita",
"undo_error": "Falha ao desfazer",
"toolbar": "Ações de arquivo",
"file_list": "Ficheiros e pastas",
"context_menu": "Ações"
}
}
+480
View File
@@ -0,0 +1,480 @@
import { create } from 'zustand';
import { WebDAVClient, type WebDAVResource } from '@/lib/webdav/client';
interface UploadProgress {
name: string;
loaded: number;
total: number;
current: number;
totalFiles: number;
}
interface ClipboardState {
mode: 'cut' | 'copy';
paths: string[];
names: string[];
sourcePath: string;
}
interface UndoAction {
type: 'rename' | 'move';
// For rename: from/to paths
// For move: array of {from, to} pairs
entries: { from: string; to: string }[];
sourcePath: string;
}
interface WebDAVState {
currentPath: string;
resources: WebDAVResource[];
isLoading: boolean;
error: string | null;
supportsWebDAV: boolean | null;
selectedResources: Set<string>;
uploadProgress: UploadProgress | null;
webdavClient: WebDAVClient | null;
clipboard: ClipboardState | null;
uploadAbortController: AbortController | null;
favorites: string[];
recentFiles: { name: string; path: string; timestamp: number }[];
lastAction: UndoAction | null;
// Actions
initClient: () => void;
checkSupport: () => Promise<boolean>;
navigate: (path: string) => 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>;
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<WebDAVResource[]>;
toggleFavorite: (path: string) => void;
addRecentFile: (name: string, path: string) => void;
undoLastAction: () => Promise<void>;
}
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}`;
}
export const useWebDAVStore = create<WebDAVState>((set, get) => ({
currentPath: '/',
resources: [],
isLoading: false,
error: null,
supportsWebDAV: null,
selectedResources: new Set<string>(),
uploadProgress: null,
webdavClient: null,
clipboard: null,
uploadAbortController: null,
lastAction: null,
favorites: (() => {
try { return JSON.parse(localStorage.getItem('webdav-favorites') || '[]'); } catch { return []; }
})(),
recentFiles: (() => {
try { return JSON.parse(localStorage.getItem('webdav-recent-files') || '[]'); } catch { return []; }
})(),
initClient: () => {
const client = new WebDAVClient();
set({ webdavClient: client });
},
checkSupport: async () => {
const { webdavClient } = get();
if (!webdavClient) return false;
try {
const supported = await webdavClient.checkSupport();
set({ supportsWebDAV: supported });
return supported;
} catch {
set({ supportsWebDAV: false });
return false;
}
},
navigate: async (path: string) => {
const { webdavClient } = get();
if (!webdavClient) return;
set({ isLoading: true, error: null, currentPath: path, selectedResources: new Set() });
// Remember last directory
try { localStorage.setItem('webdav-last-path', path); } catch { /* ignore */ }
try {
const resources = await webdavClient.list(path);
set({ resources, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to list directory',
isLoading: false,
resources: [],
});
}
},
refresh: async () => {
const { currentPath, navigate } = get();
await navigate(currentPath);
},
createDirectory: async (name: string) => {
const { webdavClient, currentPath, refresh } = get();
if (!webdavClient) return;
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
await webdavClient.createDirectory(fullPath);
await refresh();
},
uploadFile: async (file: File) => {
const { webdavClient, currentPath } = get();
if (!webdavClient) return;
const abortController = new AbortController();
set({ uploadAbortController: abortController });
const fullPath = currentPath === '/' ? `/${file.name}` : `${currentPath}/${file.name}`;
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: 1, totalFiles: 1 } });
try {
await webdavClient.uploadFile(fullPath, file, undefined, (loaded: number, total: number) => {
set({ uploadProgress: { name: file.name, loaded, total, current: 1, totalFiles: 1 } });
}, abortController.signal);
} finally {
set({ uploadProgress: null, uploadAbortController: null });
}
},
uploadFiles: async (files: File[]) => {
const { webdavClient, currentPath, resources } = get();
if (!webdavClient) return;
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 fullPath = currentPath === '/' ? `/${uniqueName}` : `${currentPath}/${uniqueName}`;
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: i + 1, totalFiles } });
try {
await webdavClient.uploadFile(fullPath, file, undefined, (loaded: number, total: number) => {
set({ uploadProgress: { name: file.name, loaded, total, current: i + 1, totalFiles } });
}, abortController.signal);
} 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 { webdavClient, currentPath } = get();
if (!webdavClient || files.length === 0) return;
const abortController = new AbortController();
set({ uploadAbortController: abortController });
const totalFiles = files.length;
// Collect all unique directory paths to create
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 first (sorted by depth)
const sortedDirs = [...dirs].sort((a, b) => a.split('/').length - b.split('/').length);
for (const dir of sortedDirs) {
if (abortController.signal.aborted) break;
const fullPath = currentPath === '/' ? `/${dir}` : `${currentPath}/${dir}`;
try {
await webdavClient.createDirectory(fullPath);
} catch {
// Directory may already exist
}
}
// Upload files
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 fullPath = currentPath === '/' ? `/${relativePath}` : `${currentPath}/${relativePath}`;
set({ uploadProgress: { name: relativePath, loaded: 0, total: file.size, current: i + 1, totalFiles } });
try {
await webdavClient.uploadFile(fullPath, file, undefined, (loaded: number, total: number) => {
set({ uploadProgress: { name: relativePath, loaded, total, current: i + 1, totalFiles } });
}, abortController.signal);
} 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 { webdavClient, currentPath, refresh } = get();
if (!webdavClient) return;
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
await webdavClient.delete(fullPath);
await refresh();
},
deleteResources: async (names: string[]) => {
const { webdavClient, currentPath, refresh } = get();
if (!webdavClient) return;
for (const name of names) {
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
await webdavClient.delete(fullPath);
}
set({ selectedResources: new Set() });
await refresh();
},
renameResource: async (oldName: string, newName: string) => {
const { webdavClient, currentPath, refresh } = get();
if (!webdavClient) return;
const oldPath = currentPath === '/' ? `/${oldName}` : `${currentPath}/${oldName}`;
const newPath = currentPath === '/' ? `/${newName}` : `${currentPath}/${newName}`;
await webdavClient.move(oldPath, newPath);
set({ lastAction: { type: 'rename', entries: [{ from: oldPath, to: newPath }], sourcePath: currentPath } });
await refresh();
},
downloadResource: async (name: string) => {
const { webdavClient, currentPath } = get();
if (!webdavClient) return;
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
const { blob, filename } = await webdavClient.downloadFile(fullPath);
// Trigger browser download
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
downloadResources: async (names: string[]) => {
const { downloadResource } = get();
for (const name of names) {
await downloadResource(name);
}
},
getImageUrl: async (name: string) => {
const { webdavClient, currentPath } = get();
if (!webdavClient) throw new Error('No client');
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
const { blob } = await webdavClient.downloadFile(fullPath);
return URL.createObjectURL(blob);
},
getFileContent: async (name: string) => {
const { webdavClient, currentPath } = get();
if (!webdavClient) throw new Error('No client');
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
const { blob, contentType } = await webdavClient.downloadFile(fullPath);
return { blob, contentType };
},
createTextFile: async (name: string) => {
const { webdavClient, currentPath, refresh } = get();
if (!webdavClient) return;
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
await webdavClient.uploadFile(fullPath, new Blob([''], { type: 'text/plain' }), 'text/plain');
await refresh();
},
duplicateResource: async (name: string) => {
const { webdavClient, currentPath, refresh } = get();
if (!webdavClient) return;
const srcPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
const dotIdx = name.lastIndexOf('.');
const copyName = dotIdx > 0
? `${name.substring(0, dotIdx)} (copy)${name.substring(dotIdx)}`
: `${name} (copy)`;
const destPath = currentPath === '/' ? `/${copyName}` : `${currentPath}/${copyName}`;
await webdavClient.copy(srcPath, destPath);
await refresh();
},
moveToFolder: async (names: string[], targetFolder: string) => {
const { webdavClient, currentPath, refresh } = get();
if (!webdavClient) return;
const targetBase = currentPath === '/' ? `/${targetFolder}` : `${currentPath}/${targetFolder}`;
const entries: { from: string; to: string }[] = [];
for (const name of names) {
const oldPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
const newPath = `${targetBase}/${name}`;
await webdavClient.move(oldPath, newPath);
entries.push({ from: oldPath, to: newPath });
}
set({ selectedResources: new Set(), lastAction: { type: 'move', entries, sourcePath: currentPath } });
await refresh();
},
cutResources: (names: string[]) => {
const { currentPath } = get();
const paths = names.map(n => currentPath === '/' ? `/${n}` : `${currentPath}/${n}`);
set({ clipboard: { mode: 'cut', paths, names, sourcePath: currentPath } });
},
copyResources: (names: string[]) => {
const { currentPath } = get();
const paths = names.map(n => currentPath === '/' ? `/${n}` : `${currentPath}/${n}`);
set({ clipboard: { mode: 'copy', paths, names, sourcePath: currentPath } });
},
pasteResources: async () => {
const { webdavClient, currentPath, clipboard, refresh } = get();
if (!webdavClient || !clipboard) return;
const entries: { from: string; to: string }[] = [];
for (let i = 0; i < clipboard.paths.length; i++) {
const srcPath = clipboard.paths[i];
const destPath = currentPath === '/' ? `/${clipboard.names[i]}` : `${currentPath}/${clipboard.names[i]}`;
if (clipboard.mode === 'cut') {
await webdavClient.move(srcPath, destPath);
entries.push({ from: srcPath, to: destPath });
} else {
await webdavClient.copy(srcPath, destPath);
}
}
if (clipboard.mode === 'cut') {
set({ clipboard: null, lastAction: { type: 'move', entries, sourcePath: currentPath } });
}
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 { webdavClient } = get();
if (!webdavClient) return [];
return webdavClient.list(path);
},
toggleFavorite: (path: string) => {
const { favorites } = get();
const next = favorites.includes(path)
? favorites.filter(f => f !== path)
: [...favorites, path];
set({ favorites: next });
try { localStorage.setItem('webdav-favorites', JSON.stringify(next)); } catch { /* ignore */ }
},
addRecentFile: (name: string, path: string) => {
const { recentFiles } = get();
const entry = { name, path, timestamp: Date.now() };
const filtered = recentFiles.filter(r => r.path !== path);
const next = [entry, ...filtered].slice(0, 20);
set({ recentFiles: next });
try { localStorage.setItem('webdav-recent-files', JSON.stringify(next)); } catch { /* ignore */ }
},
undoLastAction: async () => {
const { webdavClient, lastAction, refresh } = get();
if (!webdavClient || !lastAction) return;
// Reverse all entries
for (const entry of lastAction.entries) {
await webdavClient.move(entry.to, entry.from);
}
set({ lastAction: null });
await refresh();
},
}));