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:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "操作"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user