This commit is contained in:
Linus Rath
2026-03-16 09:54:03 +01:00
57 changed files with 11190 additions and 500 deletions
+79 -19
View File
@@ -27,8 +27,11 @@ import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-pan
import { EventModal } from "@/components/calendar/event-modal";
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { cn } from "@/lib/utils";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
import { getUserParticipantId } from "@/lib/calendar-participants";
import { debug } from "@/lib/debug";
@@ -45,13 +48,15 @@ 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,
isLoading, isLoadingEvents, supportsCalendar, error,
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
setSelectedDate, setViewMode, toggleCalendarVisibility,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
refreshAllSubscriptions,
} = useCalendarStore();
const { firstDayOfWeek, timeFormat } = useSettingsStore();
const { identities } = useIdentityStore();
@@ -63,6 +68,7 @@ export default function CalendarPage() {
const [showEventModal, setShowEventModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
@@ -72,17 +78,31 @@ export default function CalendarPage() {
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
const hasFetched = useRef(false);
// Sidebar resize state
const [calSidebarWidth, setCalSidebarWidth] = useState(() => {
try { const v = localStorage.getItem("calendar-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; }
});
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256);
// 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) {
@@ -97,6 +117,16 @@ export default function CalendarPage() {
}
}, [client, fetchCalendars]);
// Auto-refresh iCal subscriptions
useEffect(() => {
if (!client) return;
// Refresh on mount (respects per-subscription interval)
refreshAllSubscriptions(client);
// Check again every 5 minutes
const interval = setInterval(() => refreshAllSubscriptions(client), 5 * 60 * 1000);
return () => clearInterval(interval);
}, [client, refreshAllSubscriptions]);
const dateRange = useMemo(() => {
const d = selectedDate;
switch (viewMode) {
@@ -687,6 +717,7 @@ export default function CalendarPage() {
onViewModeChange={setViewMode}
onCreateEvent={() => openCreateModal()}
onImport={() => setShowImportModal(true)}
onSubscribe={() => setShowSubscriptionModal(true)}
isMobile={isMobile}
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
@@ -699,21 +730,43 @@ export default function CalendarPage() {
onTouchEnd={handleTouchEnd}
>
{!isMobile && (
<div className="w-60 border-r border-border p-3 overflow-y-auto flex-shrink-0">
<MiniCalendar
selectedDate={selectedDate}
displayMonth={miniMonth}
onSelectDate={handleSelectDate}
onChangeMonth={handleMiniMonthChange}
events={events}
firstDayOfWeek={firstDayOfWeek}
<>
<div
className={cn(
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${calSidebarWidth}px` }}
>
<MiniCalendar
selectedDate={selectedDate}
displayMonth={miniMonth}
onSelectDate={handleSelectDate}
onChangeMonth={handleMiniMonthChange}
events={events}
firstDayOfWeek={firstDayOfWeek}
/>
<CalendarSidebarPanel
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
onColorChange={client ? (calendarId, color) => {
updateCalendar(client, calendarId, { color });
} : undefined}
onSubscribe={() => setShowSubscriptionModal(true)}
client={client}
/>
</div>
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth));
}}
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
/>
<CalendarSidebarPanel
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
/>
</div>
</>
)}
{renderView()}
@@ -798,6 +851,13 @@ export default function CalendarPage() {
/>
)}
{showSubscriptionModal && client && (
<ICalSubscriptionModal
client={client}
onClose={() => setShowSubscriptionModal(false)}
/>
)}
<RecurrenceScopeDialog
isOpen={!!pendingScopeAction}
actionType={pendingScopeAction?.type || "edit"}
+46 -75
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { ArrowLeft, Upload, Download, Users, BookUser } from "lucide-react";
import { ArrowLeft, Users, BookUser } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
@@ -13,7 +13,6 @@ import { ContactForm } from "@/components/contacts/contact-form";
import { ContactGroupList } from "@/components/contacts/contact-group-list";
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
@@ -21,6 +20,7 @@ import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { useIsMobile } from "@/hooks/use-media-query";
import type { ContactCard } from "@/lib/jmap/types";
@@ -32,13 +32,13 @@ type View =
| "group-detail"
| "group-create"
| "group-edit"
| "import"
| "bulk-add-to-group";
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,
@@ -68,7 +68,6 @@ export default function ContactsPage() {
clearSelection,
bulkDeleteContacts,
bulkAddToGroup,
importContacts,
} = useContactStore();
const [view, setView] = useState<View>("list");
@@ -77,12 +76,26 @@ export default function ContactsPage() {
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const isMobile = useIsMobile();
// Sidebar resize state
const [contactsSidebarWidth, setContactsSidebarWidth] = useState(() => {
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; }
});
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256);
// 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) {
@@ -169,8 +182,6 @@ export default function ContactsPage() {
const handleCancel = () => {
if (view === "group-create" || view === "group-edit") {
setView(selectedGroup ? "group-detail" : "list");
} else if (view === "import") {
setView("list");
} else if (view === "bulk-add-to-group") {
setView("list");
} else {
@@ -218,11 +229,10 @@ export default function ContactsPage() {
const jmapClient = supportsSync && client ? client : null;
if (view === "group-edit" && selectedGroup) {
await updateGroup(jmapClient, selectedGroup.id, name);
const currentMemberIds = selectedGroup.members
? Object.keys(selectedGroup.members).filter(k => selectedGroup.members![k])
: [];
const toAdd = memberIds.filter(id => !currentMemberIds.includes(id));
const toRemove = currentMemberIds.filter(id => !memberIds.includes(id));
// Use resolved member contact IDs for diff, not raw urn:uuid: keys
const currentIds = selectedGroupMembers.map(m => m.id);
const toAdd = memberIds.filter(id => !currentIds.includes(id));
const toRemove = currentIds.filter(id => !memberIds.includes(id));
if (toAdd.length > 0) await addMembersToGroup(jmapClient, selectedGroup.id, toAdd);
if (toRemove.length > 0) await removeMembersFromGroup(jmapClient, selectedGroup.id, toRemove);
toast.success(t("toast.updated"));
@@ -232,7 +242,7 @@ export default function ContactsPage() {
toast.success(t("toast.created"));
setView("list");
}
}, [view, selectedGroup, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, t]);
}, [view, selectedGroup, selectedGroupMembers, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, t]);
const handleRemoveGroupMember = async (memberId: string) => {
if (!selectedGroup) return;
@@ -306,13 +316,6 @@ export default function ContactsPage() {
}
};
const handleImport = useCallback(async (importedContacts: ContactCard[]) => {
return importContacts(
supportsSync && client ? client : null,
importedContacts
);
}, [supportsSync, client, importContacts]);
if (!isAuthenticated) return null;
const renderRightPanel = () => {
@@ -369,15 +372,6 @@ export default function ContactsPage() {
/>
);
case "import":
return (
<ContactImportDialog
existingContacts={contacts}
onImport={handleImport}
onClose={handleCancel}
/>
);
case "bulk-add-to-group":
return (
<div className="flex flex-col h-full">
@@ -456,49 +450,15 @@ export default function ContactsPage() {
<div className="flex flex-col flex-1 min-w-0">
<div className="flex flex-1 min-h-0">
{showListPanel && (
<div className={cn(
"border-r border-border flex flex-col flex-shrink-0",
isMobile ? "w-full" : "w-80"
)}>
<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("back_to_mail")}
</Button>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setView("import")}
title={t("import.title")}
>
<Upload className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
if (contacts.length > 0) {
exportContacts(contacts.filter(c => c.kind !== "group"));
toast.success(t("export.success", { count: contacts.filter(c => c.kind !== "group").length }));
}
}}
title={t("export.title")}
>
<Download className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<>
<div
className={cn(
"border-r border-border bg-secondary flex flex-col flex-shrink-0",
isMobile ? "w-full" : "",
!isResizing && !isMobile && "transition-[width] duration-300"
)}
style={!isMobile ? { width: `${contactsSidebarWidth}px` } : undefined}
>
<div className="flex border-b border-border">
<button
onClick={() => setActiveTab("all")}
@@ -539,7 +499,6 @@ export default function ContactsPage() {
onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew}
onImport={() => setView("import")}
supportsSync={supportsSync}
className="flex-1"
selectedContactIds={selectedContactIds}
@@ -561,6 +520,18 @@ export default function ContactsPage() {
/>
)}
</div>
{!isMobile && (
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = contactsSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setContactsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem("contacts-sidebar-width", String(contactsSidebarWidth));
}}
onDoubleClick={() => { setContactsSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
/>
)}
</>
)}
{showRightPanel && (
+463
View File
@@ -0,0 +1,463 @@
"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 { useFileStore } from "@/stores/file-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";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
export default function FilesPage() {
const router = useRouter();
const t = useTranslations("files");
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const {
currentPath,
resources,
isLoading,
error,
supportsFiles,
selectedResources,
uploadProgress,
clipboard,
initClient,
checkSupport,
navigate,
navigateByPath,
refresh,
createDirectory,
uploadFile,
uploadFiles,
uploadFolder,
deleteResource,
deleteResources,
renameResource,
downloadResource,
getImageUrl,
getFileContent,
createTextFile,
duplicateResource,
downloadResources,
moveToFolder,
moveToParent,
cutResources,
copyResources,
pasteResources,
selectResource,
toggleSelect,
selectAll,
clearSelection,
setSelection,
listPath,
listByParentId,
favorites,
recentFiles,
toggleFavorite,
addRecentFile,
cancelUpload,
undoLastAction,
lastAction,
} = useFileStore();
const isMobile = useIsMobile();
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
const hasFetched = useRef(false);
// Sync folderLayout when settings change
useEffect(() => {
const reload = () => setFolderLayout(loadFilesSettings().folderLayout);
const handleStorage = (e: StorageEvent) => { if (e.key === "files-settings") reload(); };
window.addEventListener("storage", handleStorage);
window.addEventListener("files-settings-changed", reload);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener("files-settings-changed", reload);
};
}, []);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewFile, setPreviewFile] = useState<string | null>(null);
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 JMAP files client
useEffect(() => {
if (isAuthenticated && client && !hasFetched.current) {
hasFetched.current = true;
initClient(client);
}
}, [isAuthenticated, client, initClient]);
// Check support and load root after client is initialized
const storeClient = useFileStore(s => s.client);
useEffect(() => {
if (storeClient && supportsFiles === null) {
checkSupport().then((supported) => {
if (supported) {
navigate(null);
}
});
}
}, [storeClient, supportsFiles, checkSupport, navigate]);
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
if (resourceId !== undefined) {
// Direct ID-based navigation (directory click, breadcrumb dropdown folder)
navigate(resourceId, path.split('/').pop() || '');
} else {
// Path-based navigation (breadcrumbs, favorites, recent files)
navigateByPath(path);
}
}, [navigate, navigateByPath]);
const handleCreateFolder = useCallback(async (name: string) => {
try {
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 findResourceId = useCallback((name: string) => {
const r = resources.find(res => res.name === name);
return r?.id || name;
}, [resources]);
const handleDownload = useCallback(async (name: string) => {
try {
await downloadResource(name);
addRecentFile(name, findResourceId(name));
} catch (err) {
console.error("Failed to download:", err);
toast.error(t("download_error"));
}
}, [downloadResource, addRecentFile, findResourceId, 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 handleMoveToParent = useCallback(async (names: string[]) => {
try {
await moveToParent(names);
toast.success(t("move_success", { count: names.length }), {
action: { label: t("undo"), onClick: handleUndo },
});
} catch (err) {
console.error("Failed to move:", err);
toast.error(t("move_error"));
}
}, [moveToParent, t, handleUndo]);
const handlePaste = useCallback(async () => {
try {
await pasteResources();
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, findResourceId(name));
}, [addRecentFile, findResourceId]);
const handlePreviewFile = useCallback((name: string) => {
setPreviewFile(name);
addRecentFile(name, findResourceId(name));
}, [addRecentFile, findResourceId]);
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">
{folderLayout !== "sidebar" && (
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/")}
className="justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("title")}
</Button>
</div>
</div>
)}
<div className="flex-1 min-h-0">
{supportsFiles === 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}
onMoveToParent={handleMoveToParent}
onPreviewImage={handlePreviewImage}
onPreviewFile={handlePreviewFile}
onShowDetails={handleShowDetails}
onCreateTextFile={handleCreateTextFile}
onDuplicate={handleDuplicate}
getImageUrl={getImageUrl}
listPath={listPath}
listByParentId={listByParentId}
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>
);
}
+86 -5
View File
@@ -109,6 +109,7 @@ export default function Home() {
selectKeyword,
hasMoreEmails,
fetchTagCounts,
fetchEmailContent,
} = useEmailStore();
// Keyboard shortcuts handlers
@@ -336,6 +337,19 @@ export default function Home() {
};
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]);
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
useEffect(() => {
if (!selectedEmail || !client) return;
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
if (!selectedEmail.bodyValues) {
setLoadingEmail(true);
fetchEmailContent(client, selectedEmail.id).finally(() => {
setLoadingEmail(false);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedEmail?.id]);
// Handle mark-as-read with delay based on settings
useEffect(() => {
// Clear any existing timeout when email changes
@@ -409,6 +423,7 @@ export default function Home() {
bcc: string[];
subject: string;
body: string;
htmlBody?: string;
draftId?: string;
fromEmail?: string;
fromName?: string;
@@ -417,7 +432,7 @@ export default function Home() {
if (!client) return;
try {
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName);
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody);
setShowComposer(false);
// Refresh the current mailbox to update the UI
@@ -459,12 +474,14 @@ export default function Home() {
const handleDelete = async () => {
if (!client || !selectedEmail) return;
// Check if we're currently in the trash folder
// Check if we're currently in the trash or junk folder
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInTrash = currentMailbox?.role === 'trash';
const isInJunk = currentMailbox?.role === 'junk';
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
if (isInTrash) {
// In trash: confirm before permanently deleting
if (isInTrash || (isInJunk && permanentlyDeleteJunk)) {
// In trash or junk with permanent delete enabled: confirm before permanently deleting
const confirmed = await confirmDialog({
title: t('email_list.permanent_delete_confirm_title'),
message: t('email_list.permanent_delete_confirm_message'),
@@ -643,6 +660,42 @@ export default function Home() {
}
};
const handleUnreadFilterClick = async (mailboxId: string) => {
const isTogglingOff = selectedMailbox === mailboxId && searchFilters.isUnread === true;
// Select the mailbox if not already selected
if (selectedMailbox !== mailboxId) {
selectMailbox(mailboxId);
selectEmail(null);
}
// On mobile, close sidebar and go to list view
if (isMobile) {
setSidebarOpen(false);
setActiveView("list");
}
// On tablet, show the list again
if (isTablet) {
setTabletListVisible(true);
}
if (isTogglingOff) {
// Disable the unread filter and show all emails
clearSearchFilters();
if (client) {
await fetchEmails(client, mailboxId);
}
} else {
// Enable unread filter
clearSearchFilters();
setSearchFilters({ isUnread: true });
if (client) {
await advancedSearch(client);
}
}
};
const handleLogout = () => {
logout();
router.push('/login');
@@ -709,12 +762,18 @@ export default function Home() {
const primaryIdentity = identities[0];
// Append signature from the primary identity
let finalBody = body;
if (primaryIdentity?.textSignature) {
finalBody = body + '\n\n-- \n' + primaryIdentity.textSignature;
}
// Send reply with just the body text
await sendEmail(
client,
[sender.email],
`Re: ${selectedEmail.subject || "(no subject)"}`,
body,
finalBody,
undefined,
undefined,
primaryIdentity?.id,
@@ -794,6 +853,17 @@ export default function Home() {
setActiveView("list");
};
// Navigate to next/previous email in the list
const selectedEmailIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1;
const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < emails.length - 1
? () => handleEmailSelect(emails[selectedEmailIndex + 1])
: undefined;
const handleNavigatePrev = selectedEmailIndex > 0
? () => handleEmailSelect(emails[selectedEmailIndex - 1])
: undefined;
// Handle opening conversation view on mobile
const handleOpenConversation = async (thread: ThreadGroup) => {
if (!client) return;
@@ -904,6 +974,7 @@ export default function Home() {
selectedKeyword={selectedKeyword}
onMailboxSelect={handleMailboxSelect}
onTagSelect={handleTagSelect}
onUnreadFilterClick={handleUnreadFilterClick}
onCompose={() => {
setComposerMode('compose');
setShowComposer(true);
@@ -1279,6 +1350,7 @@ export default function Home() {
cc: selectedEmail.cc,
subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
receivedAt: selectedEmail.receivedAt
} : undefined)}
initialDraftText={composerDraftText}
@@ -1389,10 +1461,19 @@ export default function Home() {
setTabletListVisible(true);
selectEmail(null);
}}
onNavigateNext={handleNavigateNext}
onNavigatePrev={handleNavigatePrev}
onShowShortcuts={() => setShowShortcutsModal(true)}
currentUserEmail={client?.["username"]}
currentUserName={client?.["username"]?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
onMoveToMailbox={async (mailboxId) => {
if (client && selectedEmail) {
await moveToMailbox(client, selectedEmail.id, mailboxId);
}
}}
className={isMobile ? "flex-1" : undefined}
/>
</ErrorBoundary>
+182 -45
View File
@@ -1,9 +1,29 @@
"use client";
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useRouter } from '@/i18n/navigation';
import { useTranslations } from 'next-intl';
import { ArrowLeft, ChevronRight, LogOut, Settings as SettingsIcon } from 'lucide-react';
import {
ArrowLeft,
ChevronRight,
LogOut,
Settings as SettingsIcon,
Palette,
Mail,
User,
Shield,
UserPen,
PalmtreeIcon,
Calendar,
Filter,
FileText,
FolderOpen,
Tags,
HardDrive,
Wrench,
BookUser,
type LucideIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { EmailSettings } from '@/components/settings/email-settings';
@@ -11,38 +31,90 @@ import { AccountSettings } from '@/components/settings/account-settings';
import { IdentitySettings } from '@/components/settings/identity-settings';
import { VacationSettings } from '@/components/settings/vacation-settings';
import { CalendarSettings } from '@/components/settings/calendar-settings';
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
import { FilterSettings } from '@/components/settings/filter-settings';
import { TemplateSettings } from '@/components/settings/template-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings';
import { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useIsDesktop } from '@/hooks/use-media-query';
import { NavigationRail } from '@/components/layout/navigation-rail';
import { ResizeHandle } from '@/components/layout/resize-handle';
import { useConfig } from '@/hooks/use-config';
import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'folders' | 'keywords' | 'advanced';
type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'advanced';
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
interface TabDef {
id: Tab;
label: string;
icon: LucideIcon;
group: TabGroup;
}
const tabIcons: Record<Tab, LucideIcon> = {
appearance: Palette,
email: Mail,
account: User,
security: Shield,
identities: UserPen,
vacation: PalmtreeIcon,
calendar: Calendar,
contacts: BookUser,
filters: Filter,
templates: FileText,
folders: FolderOpen,
keywords: Tags,
files: HardDrive,
advanced: Wrench,
};
const tabGroupOrder: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
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();
// Sidebar resize state
const [settingsSidebarWidth, setSettingsSidebarWidth] = useState(() => {
try { const v = localStorage.getItem('settings-sidebar-width'); return v ? Number(v) : 256; } catch { return 256; }
});
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256);
// 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;
@@ -51,24 +123,37 @@ export default function SettingsPage() {
const supportsVacation = client?.supportsVacationResponse() ?? false;
const supportsCalendar = client?.supportsCalendars() ?? false;
const supportsSieve = client?.supportsSieve() ?? false;
const supportsFiles = client?.supportsFiles() ?? false;
const tabs: { id: Tab; label: string }[] = [
{ id: 'appearance', label: t('tabs.appearance') },
{ id: 'email', label: t('tabs.email') },
{ id: 'account', label: t('tabs.account') },
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security') }] : []),
{ id: 'identities', label: t('tabs.identities') },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation') }] : []),
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar') }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters') }] : []),
{ id: 'templates', label: t('tabs.templates') },
{ id: 'folders', label: t('tabs.folders') },
{ id: 'keywords', label: t('tabs.keywords') },
{ id: 'advanced', label: t('tabs.advanced') },
const tabs: TabDef[] = [
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' },
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' },
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []),
{ id: 'templates', label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' },
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
{ id: 'keywords', label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' },
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
];
// Group tabs by category
const groupedTabs = tabGroupOrder
.map((group) => ({
group,
label: t(`tab_groups.${group}`),
items: tabs.filter((tab) => tab.group === group),
}))
.filter((g) => g.items.length > 0);
const handleTabSelect = (tabId: Tab) => {
setActiveTab(tabId);
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
if (!isDesktop) {
setMobileShowContent(true);
}
@@ -84,11 +169,13 @@ export default function SettingsPage() {
{activeTab === 'security' && <AccountSecuritySettings />}
{activeTab === 'identities' && <IdentitySettings />}
{activeTab === 'vacation' && <VacationSettings />}
{activeTab === 'calendar' && <CalendarSettings />}
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{activeTab === 'contacts' && <ContactsSettings />}
{activeTab === 'filters' && <FilterSettings />}
{activeTab === 'templates' && <TemplateSettings />}
{activeTab === 'folders' && <FolderSettings />}
{activeTab === 'keywords' && <KeywordSettings />}
{activeTab === 'files' && <FilesSettingsComponent />}
{activeTab === 'advanced' && <AdvancedSettings />}
</>
);
@@ -147,15 +234,31 @@ export default function SettingsPage() {
{/* Tab list */}
<div className="flex-1 overflow-y-auto">
<div className="py-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabSelect(tab.id)}
className="w-full flex items-center justify-between px-5 py-3.5 text-sm text-foreground hover:bg-muted transition-colors duration-150"
>
<span>{tab.label}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
{groupedTabs.map((group, groupIndex) => (
<div key={group.group}>
{groupIndex > 0 && <div className="mx-5 my-2 border-t border-border" />}
<div className="px-5 pt-3 pb-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => handleTabSelect(tab.id)}
className="w-full flex items-center justify-between px-5 py-3.5 text-sm text-foreground hover:bg-muted transition-colors duration-150"
>
<span className="flex items-center gap-3">
<Icon className="w-4 h-4 text-muted-foreground" />
{tab.label}
</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
);
})}
</div>
))}
</div>
@@ -191,7 +294,13 @@ export default function SettingsPage() {
</div>
{/* Settings Sidebar */}
<div className="w-64 border-r border-border bg-secondary flex flex-col">
<div
className={cn(
"border-r border-border bg-secondary flex flex-col",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${settingsSidebarWidth}px` }}
>
{/* Header */}
<div className="p-4 border-b border-border">
<Button
@@ -207,25 +316,53 @@ export default function SettingsPage() {
{/* Tabs */}
<div className="flex-1 overflow-y-auto py-2">
<div className="px-2 space-y-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150',
activeTab === tab.id
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
{tab.label}
</button>
<div className="px-2 space-y-0.5">
{groupedTabs.map((group, groupIndex) => (
<div key={group.group}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
<div className="px-3 pt-2.5 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
activeTab === tab.id
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<Icon className={cn(
'w-4 h-4 shrink-0',
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{tab.label}
</button>
);
})}
</div>
))}
</div>
</div>
</div>
{/* Sidebar resize handle */}
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem('settings-sidebar-width', String(settingsSidebarWidth));
}}
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
/>
{/* Settings Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto p-8">
+80 -21
View File
@@ -746,7 +746,7 @@ const addressBooks = [
const contacts = [
// --- Personal address book ---
{ id: 'contact-001', addressBookIds: { 'ab-1': true }, kind: 'individual',
{ id: 'contact-001', uid: 'urn:uuid:c0000001-0000-0000-0000-000000000001', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Sophie' }, { kind: 'surname', value: 'Müller' }] },
emails: { e1: { address: 'sophie@eurotech.example' } },
phones: { p1: { number: '+49 30 8844 2200' } },
@@ -754,7 +754,7 @@ const contacts = [
addresses: { a1: { street: [{ value: 'Kurfürstendamm 42' }], locality: 'Berlin', region: '', country: 'Germany', postcode: '10719' } },
notes: { n1: { note: 'Frontend lead. Always brings Kuchen to the office.' } },
},
{ id: 'contact-002', addressBookIds: { 'ab-1': true }, kind: 'individual',
{ id: 'contact-002', uid: 'urn:uuid:c0000002-0000-0000-0000-000000000002', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Pierre' }, { kind: 'surname', value: 'Dubois' }] },
emails: { e1: { address: 'pierre@dubois.example' } },
phones: { p1: { number: '+33 1 42 68 53 00' } },
@@ -762,7 +762,7 @@ const contacts = [
addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } },
notes: { n1: { note: 'Product manager. Knows every boulangerie in Paris.' } },
},
{ id: 'contact-003', addressBookIds: { 'ab-1': true }, kind: 'individual',
{ id: 'contact-003', uid: 'urn:uuid:c0000003-0000-0000-0000-000000000003', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Chiara' }, { kind: 'surname', value: 'Rossi' }] },
emails: { e1: { address: 'chiara@rossi.example' } },
phones: { p1: { number: '+39 02 7634 5678' } },
@@ -770,14 +770,14 @@ const contacts = [
addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } },
notes: { n1: { note: 'UX designer. Her risotto recipes are legendary.' } },
},
{ id: 'contact-004', addressBookIds: { 'ab-1': true }, kind: 'individual',
{ id: 'contact-004', uid: 'urn:uuid:c0000004-0000-0000-0000-000000000004', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Karel' }, { kind: 'surname', value: 'de Vries' }] },
emails: { e1: { address: 'karel@devries.example' } },
phones: { p1: { number: '+31 20 555 0142' } },
addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } },
notes: { n1: { note: 'Backend developer. Cycles to work rain or shine — true Dutchman.' } },
},
{ id: 'contact-005', addressBookIds: { 'ab-1': true }, kind: 'individual',
{ id: 'contact-005', uid: 'urn:uuid:c0000005-0000-0000-0000-000000000005', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] },
emails: { e1: { address: 'lars.johansson@fjord-systems.example' } },
phones: { p1: { number: '+46 8 123 456 78' } },
@@ -785,7 +785,7 @@ const contacts = [
addresses: { a1: { street: [{ value: 'Drottninggatan 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '111 51' } },
notes: { n1: { note: 'Tech lead. FIKA is sacred. Do not schedule meetings during fika.' } },
},
{ id: 'contact-006', addressBookIds: { 'ab-1': true }, kind: 'individual',
{ id: 'contact-006', uid: 'urn:uuid:c0000006-0000-0000-0000-000000000006', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Élise' }, { kind: 'surname', value: 'Moreau' }] },
emails: { e1: { address: 'elise.moreau@fjord-systems.example' } },
phones: { p1: { number: '+33 6 12 34 56 78' } },
@@ -793,14 +793,14 @@ const contacts = [
addresses: { a1: { street: [{ value: '15 Boulevard Saint-Germain' }], locality: 'Paris', country: 'France', postcode: '75005' } },
notes: { n1: { note: 'Backend dev. Remote from Paris. Once fixed a production bug from a café terrace.' } },
},
{ id: 'contact-007', addressBookIds: { 'ab-1': true }, kind: 'individual',
{ id: 'contact-007', uid: 'urn:uuid:c0000007-0000-0000-0000-000000000007', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Francesco' }, { kind: 'surname', value: 'Bianchi' }] },
emails: { e1: { address: 'francesco@bianchi.example' } },
phones: { p1: { number: '+39 06 9876 5432' } },
addresses: { a1: { street: [{ value: 'Via dei Condotti 22' }], locality: 'Roma', country: 'Italy', postcode: '00187' } },
notes: { n1: { note: 'Old university friend. Once tried to implement RFC 2549 (IP over Avian Carriers) with actual pigeons. It did not scale.' } },
},
{ id: 'contact-008', addressBookIds: { 'ab-1': true }, kind: 'individual',
{ id: 'contact-008', uid: 'urn:uuid:c0000008-0000-0000-0000-000000000008', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Astrid' }, { kind: 'surname', value: 'van der Berg' }] },
emails: { e1: { address: 'astrid@berglabs.example' } },
phones: { p1: { number: '+31 70 362 4242' } },
@@ -808,7 +808,7 @@ const contacts = [
addresses: { a1: { street: [{ value: 'Prinsengracht 263' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1016 GV' } },
notes: { n1: { note: 'Solutions architect. Her whiteboard diagrams belong in a museum.' } },
},
{ id: 'contact-009', addressBookIds: { 'ab-1': true }, kind: 'individual',
{ id: 'contact-009', uid: 'urn:uuid:c0000009-0000-0000-0000-000000000009', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Henrik' }, { kind: 'surname', value: 'Nielsen' }] },
emails: { e1: { address: 'henrik@nielsen-konsult.example' } },
phones: { p1: { number: '+45 33 42 42 42' } },
@@ -816,7 +816,7 @@ const contacts = [
addresses: { a1: { street: [{ value: 'Nyhavn 42' }], locality: 'København', country: 'Denmark', postcode: '1051' } },
notes: { n1: { note: 'Freelance DevOps. Speaks 5 languages. Kubernetes kubectl alias: k → kansen.' } },
},
{ id: 'contact-010', addressBookIds: { 'ab-1': true }, kind: 'individual',
{ id: 'contact-010', uid: 'urn:uuid:c0000010-0000-0000-0000-000000000010', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Isabelle' }, { kind: 'surname', value: 'Martin' }] },
emails: { e1: { address: 'isabelle.martin@sorbonne.example' } },
phones: { p1: { number: '+33 1 44 27 42 42' } },
@@ -825,7 +825,7 @@ const contacts = [
notes: { n1: { note: 'Professor of computer science. Thesis on formal verification of email protocols.' } },
},
// --- Work address book ---
{ id: 'contact-011', addressBookIds: { 'ab-2': true }, kind: 'individual',
{ id: 'contact-011', uid: 'urn:uuid:c0000011-0000-0000-0000-000000000011', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Jacques' }, { kind: 'surname', value: 'Lefèvre' }] },
emails: { e1: { address: 'jacques@lefevre-avocats.example' } },
phones: { p1: { number: '+33 1 53 67 42 00' } },
@@ -833,7 +833,7 @@ const contacts = [
addresses: { a1: { street: [{ value: '8 Avenue de l\'Opéra' }], locality: 'Paris', country: 'France', postcode: '75001' } },
notes: { n1: { note: 'Lawyer. Specializes in IP and tech law. Always replies within 42 minutes.' } },
},
{ id: 'contact-012', addressBookIds: { 'ab-2': true }, kind: 'individual',
{ id: 'contact-012', uid: 'urn:uuid:c0000012-0000-0000-0000-000000000012', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Katrin' }, { kind: 'surname', value: 'Bauer' }] },
emails: { e1: { address: 'katrin.bauer@charite.example' } },
phones: { p1: { number: '+49 30 450 570 000' } },
@@ -841,7 +841,7 @@ const contacts = [
addresses: { a1: { street: [{ value: 'Charitéplatz 1' }], locality: 'Berlin', country: 'Germany', postcode: '10117' } },
notes: { n1: { note: 'Medical center admin. Organizes the best team events in Berlin.' } },
},
{ id: 'contact-013', addressBookIds: { 'ab-2': true }, kind: 'individual',
{ id: 'contact-013', uid: 'urn:uuid:c0000013-0000-0000-0000-000000000013', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Liam' }, { kind: 'surname', value: 'Ó Donaill' }] },
emails: { e1: { address: 'liam.odonaill@finanz.example' } },
phones: { p1: { number: '+353 1 677 4242' } },
@@ -849,7 +849,7 @@ const contacts = [
addresses: { a1: { street: [{ value: '42 St. Stephen\'s Green' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 HX65' } },
notes: { n1: { note: 'Finance lead. Can explain SEPA regulations over a pint of Guinness.' } },
},
{ id: 'contact-014', addressBookIds: { 'ab-2': true }, kind: 'individual',
{ id: 'contact-014', uid: 'urn:uuid:c0000014-0000-0000-0000-000000000014', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'María' }, { kind: 'surname', value: 'García' }] },
emails: { e1: { address: 'maria@garcia-design.example' } },
phones: { p1: { number: '+34 91 420 4242' } },
@@ -857,7 +857,7 @@ const contacts = [
addresses: { a1: { street: [{ value: 'Calle Gran Vía 42' }], locality: 'Madrid', country: 'Spain', postcode: '28013' } },
notes: { n1: { note: 'Brand designer. Her color palettes are pure art. Siesta enthusiast.' } },
},
{ id: 'contact-015', addressBookIds: { 'ab-2': true }, kind: 'individual',
{ id: 'contact-015', uid: 'urn:uuid:c0000015-0000-0000-0000-000000000015', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] },
emails: { e1: { address: 'nils@digitaal.example' } },
phones: { p1: { number: '+31 20 624 1337' } },
@@ -865,7 +865,7 @@ const contacts = [
addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } },
notes: { n1: { note: 'Platform engineer. fika buddy. Appreciates a good kanelbulle.' } },
},
{ id: 'contact-016', addressBookIds: { 'ab-2': true }, kind: 'individual',
{ id: 'contact-016', uid: 'urn:uuid:c0000016-0000-0000-0000-000000000016', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Kowalska' }] },
emails: { e1: { address: 'olivia@kowalska-marketing.example' } },
phones: { p1: { number: '+48 22 505 4242' } },
@@ -873,7 +873,7 @@ const contacts = [
addresses: { a1: { street: [{ value: 'ul. Nowy Świat 42' }], locality: 'Warszawa', country: 'Poland', postcode: '00-363' } },
notes: { n1: { note: 'Marketing strategist. Her campaign analytics dashboards are works of art.' } },
},
{ id: 'contact-017', addressBookIds: { 'ab-2': true }, kind: 'individual',
{ id: 'contact-017', uid: 'urn:uuid:c0000017-0000-0000-0000-000000000017', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Pádraig' }, { kind: 'surname', value: 'Murphy' }] },
emails: { e1: { address: 'padraig@murphy-bau.example' } },
phones: { p1: { number: '+353 86 123 4242' } },
@@ -881,7 +881,7 @@ const contacts = [
addresses: { a1: { street: [{ value: 'Grafton Street 42' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 R296' } },
notes: { n1: { note: 'Construction project manager. Irish-German bilingual. Builds things that last.' } },
},
{ id: 'contact-018', addressBookIds: { 'ab-2': true }, kind: 'individual',
{ id: 'contact-018', uid: 'urn:uuid:c0000018-0000-0000-0000-000000000018', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Raquel' }, { kind: 'surname', value: 'Ferreira' }] },
emails: { e1: { address: 'raquel@ferreira-media.example' } },
phones: { p1: { number: '+351 21 342 4242' } },
@@ -889,7 +889,7 @@ const contacts = [
addresses: { a1: { street: [{ value: 'Rua Augusta 42' }], locality: 'Lisboa', country: 'Portugal', postcode: '1100-053' } },
notes: { n1: { note: 'Media consultant. Can turn any press release into poetry. Loves pastéis de nata.' } },
},
{ id: 'contact-019', addressBookIds: { 'ab-2': true }, kind: 'individual',
{ id: 'contact-019', uid: 'urn:uuid:c0000019-0000-0000-0000-000000000019', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Sébastien' }, { kind: 'surname', value: 'Dumont' }] },
emails: { e1: { address: 'sebastien@dumont-conseil.example' } },
phones: { p1: { number: '+32 2 555 4242' } },
@@ -897,7 +897,7 @@ const contacts = [
addresses: { a1: { street: [{ value: 'Avenue Louise 42' }], locality: 'Bruxelles', country: 'Belgium', postcode: '1050' } },
notes: { n1: { note: 'Strategy consultant. Knows the difference between Belgian and French chocolate. Will argue passionately about it.' } },
},
{ id: 'contact-020', addressBookIds: { 'ab-2': true }, kind: 'individual',
{ id: 'contact-020', uid: 'urn:uuid:c0000020-0000-0000-0000-000000000020', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Annika' }, { kind: 'surname', value: 'Lindgren' }] },
emails: { e1: { address: 'annika@lindgren.example' }, e2: { address: 'annika.personal@proton.example' } },
phones: { p1: { number: '+46 70 123 4242' } },
@@ -906,6 +906,22 @@ const contacts = [
nicknames: { n1: { name: 'Anni' } },
notes: { n1: { note: 'Independent consultant specializing in GDPR compliance. Yes, she has opinions about cookie banners.' } },
},
// --- Groups ---
{ id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const,
uid: 'urn:uuid:g0000001-0000-0000-0000-000000000001',
name: { components: [{ kind: 'given' as const, value: 'Fjord Systems Team' }], isOrdered: true },
members: { 'urn:uuid:c0000005-0000-0000-0000-000000000005': true, 'urn:uuid:c0000006-0000-0000-0000-000000000006': true },
},
{ id: 'contact-group-002', addressBookIds: { 'ab-1': true }, kind: 'group' as const,
uid: 'urn:uuid:g0000002-0000-0000-0000-000000000002',
name: { components: [{ kind: 'given' as const, value: 'Design Friends' }], isOrdered: true },
members: { 'urn:uuid:c0000003-0000-0000-0000-000000000003': true, 'urn:uuid:c0000007-0000-0000-0000-000000000007': true },
},
{ id: 'contact-group-003', addressBookIds: { 'ab-2': true }, kind: 'group' as const,
uid: 'urn:uuid:g0000003-0000-0000-0000-000000000003',
name: { components: [{ kind: 'given' as const, value: 'Legal & Finance' }], isOrdered: true },
members: { 'urn:uuid:c0000011-0000-0000-0000-000000000011': true, 'urn:uuid:c0000013-0000-0000-0000-000000000013': true },
},
];
// ---------------------------------------------------------------------------
@@ -1602,7 +1618,50 @@ const METHOD_HANDLERS: Record<string, (args: MethodArgs, callId: string) => Meth
'VacationResponse/get': handleVacationResponseGet,
'VacationResponse/set': (_args, callId) => ['VacationResponse/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), updated: { 'vacation-1': null } }, callId],
'ContactCard/get': handleContactCardGet,
'ContactCard/set': (_args, callId) => ['ContactCard/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: null, updated: null, destroyed: null }, callId],
'ContactCard/set': (args, callId) => {
const created: Record<string, unknown> = {};
const updated: Record<string, unknown> = {};
const destroyed: string[] = [];
if (args.create) {
for (const [tempId, data] of Object.entries(args.create as Record<string, Record<string, unknown>>)) {
const newId = `contact-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const newUid = `urn:uuid:${crypto.randomUUID()}`;
const newContact = { id: newId, uid: newUid, ...data, addressBookIds: data.addressBookIds || { 'ab-1': true } };
contacts.push(newContact as typeof contacts[number]);
created[tempId] = { id: newId, uid: newUid };
}
}
if (args.update) {
for (const [id, patches] of Object.entries(args.update as Record<string, Record<string, unknown>>)) {
const idx = contacts.findIndex(c => c.id === id);
if (idx !== -1) {
contacts[idx] = { ...contacts[idx], ...patches } as typeof contacts[number];
updated[id] = null;
}
}
}
if (args.destroy) {
for (const id of args.destroy as string[]) {
const idx = contacts.findIndex(c => c.id === id);
if (idx !== -1) {
contacts.splice(idx, 1);
destroyed.push(id);
}
}
}
return ['ContactCard/set', {
accountId: ACCOUNT_ID,
oldState: nextState(),
newState: nextState(),
created: Object.keys(created).length > 0 ? created : null,
updated: Object.keys(updated).length > 0 ? updated : null,
destroyed: destroyed.length > 0 ? destroyed : null,
}, callId];
},
'ContactCard/query': (_args, callId) => ['ContactCard/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: contacts.map(c => c.id), total: contacts.length, position: 0 }, callId],
'AddressBook/get': handleAddressBookGet,
'Calendar/get': handleCalendarGet,
+108
View File
@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from 'next/server';
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const FETCH_TIMEOUT_MS = 15000;
function isValidExternalUrl(urlString: string): boolean {
let url: URL;
try {
url = new URL(urlString);
} catch {
return false;
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return false;
}
const hostname = url.hostname.toLowerCase();
// Block private/internal hostnames
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.endsWith('.local') ||
hostname.endsWith('.internal') ||
hostname.endsWith('.arpa') ||
hostname.startsWith('10.') ||
hostname.startsWith('192.168.') ||
hostname.startsWith('169.254.') ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
) {
return false;
}
// Block URLs with credentials
if (url.username || url.password) {
return false;
}
return true;
}
export async function POST(request: NextRequest) {
let body: { url?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const { url } = body;
if (!url || typeof url !== 'string') {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
if (!isValidExternalUrl(url)) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const response = await fetch(url, {
signal: controller.signal,
headers: {
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
},
redirect: 'follow',
});
clearTimeout(timeout);
if (!response.ok) {
return NextResponse.json(
{ error: `Remote server returned ${response.status}` },
{ status: 502 }
);
}
const contentLength = response.headers.get('content-length');
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
return NextResponse.json({ error: 'File too large' }, { status: 413 });
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength > MAX_RESPONSE_SIZE) {
return NextResponse.json({ error: 'File too large' }, { status: 413 });
}
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type': 'text/calendar',
'Content-Length': buffer.byteLength.toString(),
},
});
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
return NextResponse.json({ error: 'Request timed out' }, { status: 504 });
}
return NextResponse.json({ error: 'Failed to fetch calendar' }, { status: 502 });
}
}
+110
View File
@@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
/**
* POST /api/webdav
* Proxies WebDAV requests to the Stalwart server.
*
* Headers:
* X-WebDAV-Method: The actual WebDAV method (PROPFIND, MKCOL, GET, PUT, DELETE, MOVE, COPY)
* X-WebDAV-Path: Resource path relative to the user's DAV root (default: /)
* X-WebDAV-Destination: Destination path for MOVE/COPY (relative to user's DAV root)
* Depth: WebDAV Depth header (forwarded as-is)
* Content-Type: Forwarded for PROPFIND (XML) and PUT (file upload)
* Overwrite: WebDAV Overwrite header for MOVE/COPY
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const method = request.headers.get('X-WebDAV-Method')?.toUpperCase();
if (!method || !ALLOWED_METHODS.has(method)) {
return NextResponse.json({ error: 'Invalid WebDAV method' }, { status: 400 });
}
const davPath = request.headers.get('X-WebDAV-Path') || '/';
const cleanPath = davPath.replace(/^\/+/, '');
const baseUrl = creds.apiUrl.replace(/\/$/, '');
const targetUrl = cleanPath
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
// Build headers for the upstream request
const upstreamHeaders: Record<string, string> = {
'Authorization': creds.authHeader,
};
// Forward relevant WebDAV headers
const depth = request.headers.get('Depth');
if (depth) upstreamHeaders['Depth'] = depth;
const contentType = request.headers.get('Content-Type');
if (contentType) upstreamHeaders['Content-Type'] = contentType;
// For MOVE/COPY, construct the full Destination URL from the relative path
const destination = request.headers.get('X-WebDAV-Destination');
if (destination) {
const cleanDest = destination.replace(/^\/+/, '');
upstreamHeaders['Destination'] = cleanDest
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
}
const overwrite = request.headers.get('Overwrite');
if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
// Forward request body for methods that need it
let body: ArrayBuffer | null = null;
if (method === 'PROPFIND' || method === 'PUT') {
body = await request.arrayBuffer();
}
const response = await fetch(targetUrl, {
method,
headers: upstreamHeaders,
body,
redirect: 'follow',
});
// For file downloads (GET), stream the response back
if (method === 'GET') {
const headers = new Headers();
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/octet-stream');
const contentLength = response.headers.get('Content-Length');
if (contentLength) headers.set('Content-Length', contentLength);
headers.set('X-WebDAV-Request-URI', targetUrl);
return new NextResponse(response.body, {
status: response.status,
headers,
});
}
// For PROPFIND, return XML with the actual request URI for href comparison
if (method === 'PROPFIND') {
const text = await response.text();
const headers = new Headers();
headers.set('Content-Type', 'application/xml; charset=utf-8');
headers.set('X-WebDAV-Request-URI', targetUrl);
return new NextResponse(text, {
status: response.status,
headers,
});
}
// For other methods (MKCOL, DELETE, MOVE, COPY, PUT), return the status
return new NextResponse(null, {
status: response.status,
});
} catch (error) {
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+20
View File
@@ -242,6 +242,26 @@ body {
list-style-type: decimal;
}
/* Reply quoted HTML - preserves original inline styles/colors */
.email-reply-quote {
overflow-wrap: break-word;
word-wrap: break-word;
max-width: none;
}
.email-reply-quote p {
margin: 0.5rem 0;
}
.email-reply-quote img {
max-width: 100%;
height: auto;
}
.email-reply-quote a {
text-decoration: underline;
}
/* Only style tables that are actual data tables, not layout tables */
.email-content table.data-table,
.email-content table[border="1"] {
+163 -18
View File
@@ -1,23 +1,99 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Globe, Plus, RefreshCw, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store";
import type { JMAPClient } from "@/lib/jmap/client";
interface CalendarSidebarPanelProps {
calendars: Calendar[];
selectedCalendarIds: string[];
onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void;
onSubscribe?: () => void;
client?: JMAPClient | null;
}
export function CalendarSidebarPanel({
calendars,
selectedCalendarIds,
onToggleVisibility,
onColorChange,
onSubscribe,
client,
}: CalendarSidebarPanelProps) {
const t = useTranslations("calendar");
const tSub = useTranslations("calendar.subscription");
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
if (calendars.length === 0) return null;
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const colorPickerRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!colorPickerId && !contextMenuCalId) return;
const handleClick = (e: MouseEvent) => {
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
setColorPickerId(null);
}
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenuCalId(null);
}
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setColorPickerId(null);
setContextMenuCalId(null);
}
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [colorPickerId, contextMenuCalId]);
const getSubscriptionForCalendar = (calendarId: string) => {
return icalSubscriptions.find(s => s.calendarId === calendarId);
};
const handleRefreshSubscription = async (subId: string) => {
if (!client) return;
setRefreshingSubId(subId);
setContextMenuCalId(null);
try {
await refreshICalSubscription(client, subId);
toast.success(tSub('refresh_success'));
} catch {
toast.error(tSub('refresh_error'));
} finally {
setRefreshingSubId(null);
}
};
const handleUnsubscribe = async (subId: string) => {
if (!client) return;
setContextMenuCalId(null);
try {
await removeICalSubscription(client, subId);
toast.success(tSub('deleted'));
} catch {
toast.error(tSub('delete_error'));
}
};
if (calendars.length === 0 && !onSubscribe) return null;
return (
<div className="mt-4">
@@ -30,25 +106,94 @@ export function CalendarSidebarPanel({
const color = cal.color || "#3b82f6";
return (
<button
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
"hover:bg-muted"
)}
>
<span
<div key={cal.id} className="relative">
<button
onClick={() => onToggleVisibility(cal.id)}
onContextMenu={(e) => {
e.preventDefault();
if (isSubscriptionCalendar(cal.id) && client) {
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
setColorPickerId(null);
} else if (onColorChange) {
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
setContextMenuCalId(null);
}
}}
className={cn(
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
"hover:bg-muted"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
</button>
>
<span
className={cn(
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
{isSubscriptionCalendar(cal.id) && (
<>
<Globe className="w-3 h-3 text-muted-foreground flex-shrink-0" />
{refreshingSubId === getSubscriptionForCalendar(cal.id)?.id && (
<RefreshCw className="w-3 h-3 text-muted-foreground flex-shrink-0 animate-spin" />
)}
</>
)}
</button>
{/* Subscription context menu on right-click */}
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
const sub = getSubscriptionForCalendar(cal.id);
if (!sub) return null;
return (
<div
ref={contextMenuRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
>
<button
onClick={() => handleRefreshSubscription(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
{tSub('refresh')}
</button>
<button
onClick={() => handleUnsubscribe(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
{tSub('unsubscribe')}
</button>
{sub.lastRefreshed && (
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
{tSub('last_refreshed', { time: new Date(sub.lastRefreshed).toLocaleString() })}
</div>
)}
</div>
);
})()}
{/* Color picker popover on right-click */}
{colorPickerId === cal.id && onColorChange && (
<div
ref={colorPickerRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
>
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
<CalendarColorPicker
value={color}
onChange={(c) => {
onColorChange(cal.id, c);
setColorPickerId(null);
}}
allowCustom
/>
</div>
)}
</div>
);
})}
</div>
+46 -6
View File
@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown } from "lucide-react";
import { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
@@ -18,6 +18,7 @@ interface CalendarToolbarProps {
onViewModeChange: (mode: CalendarViewMode) => void;
onCreateEvent: () => void;
onImport?: () => void;
onSubscribe?: () => void;
isMobile?: boolean;
firstDayOfWeek?: number;
onNavigateBack?: () => void;
@@ -35,6 +36,7 @@ export function CalendarToolbar({
onViewModeChange,
onCreateEvent,
onImport,
onSubscribe,
isMobile,
firstDayOfWeek = 1,
calendars,
@@ -87,9 +89,22 @@ export function CalendarToolbar({
}
};
const [showImportDropdown, setShowImportDropdown] = useState(false);
const importDropdownRef = useRef<HTMLDivElement>(null);
const [showViewDropdown, setShowViewDropdown] = useState(false);
const viewDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showImportDropdown) return;
function handleClickOutside(e: MouseEvent) {
if (importDropdownRef.current && !importDropdownRef.current.contains(e.target as Node)) {
setShowImportDropdown(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showImportDropdown]);
useEffect(() => {
if (!showViewDropdown) return;
function handleClickOutside(e: MouseEvent) {
@@ -219,11 +234,36 @@ export function CalendarToolbar({
</div>
)}
{onImport && !isMobile && (
<Button variant="outline" size="sm" onClick={onImport}>
<Upload className="w-4 h-4 mr-1" />
{t("import.title")}
</Button>
{(onImport || onSubscribe) && !isMobile && (
<div className="relative" ref={importDropdownRef}>
<Button variant="outline" size="sm" onClick={() => setShowImportDropdown((v) => !v)}>
<Upload className="w-4 h-4 mr-1" />
{t("import.title")}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
{showImportDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
{onImport && (
<button
onClick={() => { onImport(); setShowImportDropdown(false); }}
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors text-foreground"
>
<Upload className="w-4 h-4" />
{t("import.title")}
</button>
)}
{onSubscribe && (
<button
onClick={() => { onSubscribe(); setShowImportDropdown(false); }}
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors text-foreground"
>
<Globe className="w-4 h-4" />
{t("subscription.title")}
</button>
)}
</div>
)}
</div>
)}
{!isMobile && (
+2 -1
View File
@@ -30,7 +30,8 @@ function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
return sanitizeColor(event.color, sanitizeColor(calendar?.color));
}
function parseDuration(duration: string): number {
function parseDuration(duration: string | undefined): number {
if (!duration) return 0;
let totalMinutes = 0;
const weekMatch = duration.match(/(\d+)W/);
const hourMatch = duration.match(/(\d+)H/);
+135 -24
View File
@@ -3,7 +3,7 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Upload, Check, Loader2, RefreshCw } from "lucide-react";
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
import { format, parseISO } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { JMAPClient } from "@/lib/jmap/client";
@@ -20,6 +20,7 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
type ImportStep = "select" | "preview" | "importing";
type ImportMode = "file" | "url";
export function ICalImportModal({ calendars, client, onClose }: ICalImportModalProps) {
const t = useTranslations("calendar.import");
@@ -38,6 +39,9 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
const [isParsing, setIsParsing] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const [importMode, setImportMode] = useState<ImportMode>("file");
const [urlInput, setUrlInput] = useState("");
const [isFetchingUrl, setIsFetchingUrl] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
@@ -103,6 +107,57 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
if (file) handleFile(file);
}, [handleFile]);
const handleUrlFetch = useCallback(async () => {
const trimmed = urlInput.trim();
if (!trimmed) return;
try {
new URL(trimmed);
} catch {
setError(t("invalid_url"));
return;
}
setError(null);
setIsFetchingUrl(true);
setIsParsing(true);
try {
const response = await fetch("/api/fetch-ical", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: trimmed }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || t("url_fetch_failed"));
}
const blob = await response.blob();
const file = new File([blob], "calendar.ics", { type: "text/calendar" });
const uploaded = await client.uploadBlob(file);
const accountId = client.getCalendarsAccountId();
const events = await client.parseCalendarEvents(accountId, uploaded.blobId);
if (events.length === 0) {
setError(t("no_events"));
setIsFetchingUrl(false);
setIsParsing(false);
return;
}
setParsedEvents(events);
setSelectedIndices(new Set(events.map((_, i) => i)));
setStep("preview");
} catch (err) {
setError(err instanceof Error ? err.message : t("url_fetch_failed"));
} finally {
setIsFetchingUrl(false);
setIsParsing(false);
}
}, [urlInput, client, t]);
const toggleEvent = useCallback((index: number) => {
setSelectedIndices((prev) => {
const next = new Set(prev);
@@ -202,29 +257,85 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
<div className="px-6 py-4 space-y-4">
{step === "select" && !isParsing && (
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors ${
isDragging
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-muted/50"
}`}
>
<Upload className="w-8 h-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium">{t("select_file")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("drop_file")}</p>
<p className="text-xs text-muted-foreground mt-2">{t("supported_formats")}</p>
<input
ref={fileInputRef}
type="file"
accept=".ics,.ical"
onChange={handleFileChange}
className="hidden"
/>
</div>
<>
<div className="flex border-b border-border mb-4">
<button
onClick={() => { setImportMode("file"); setError(null); }}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
importMode === "file"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Upload className="w-4 h-4" />
{t("tab_file")}
</button>
<button
onClick={() => { setImportMode("url"); setError(null); }}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
importMode === "url"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Globe className="w-4 h-4" />
{t("tab_url")}
</button>
</div>
{importMode === "file" && (
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors ${
isDragging
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-muted/50"
}`}
>
<Upload className="w-8 h-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium">{t("select_file")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("drop_file")}</p>
<p className="text-xs text-muted-foreground mt-2">{t("supported_formats")}</p>
<input
ref={fileInputRef}
type="file"
accept=".ics,.ical"
onChange={handleFileChange}
className="hidden"
/>
</div>
)}
{importMode === "url" && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">{t("url_description")}</p>
<div className="flex gap-2">
<input
type="url"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
placeholder={t("url_placeholder")}
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown={(e) => { if (e.key === "Enter") handleUrlFetch(); }}
/>
<Button
onClick={handleUrlFetch}
disabled={!urlInput.trim() || isFetchingUrl}
>
{isFetchingUrl ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
t("fetch")
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("url_hint")}</p>
</div>
)}
</>
)}
{isParsing && (
@@ -0,0 +1,205 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, Globe } from "lucide-react";
import type { JMAPClient } from "@/lib/jmap/client";
import { useCalendarStore } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { toast } from "@/stores/toast-store";
interface ICalSubscriptionModalProps {
client: JMAPClient;
onClose: () => void;
}
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) {
const t = useTranslations("calendar.subscription");
const tCommon = useTranslations("common");
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
const [url, setUrl] = useState("");
const [name, setName] = useState("");
const [color, setColor] = useState("#3b82f6");
const [refreshInterval, setRefreshInterval] = useState(60);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
const isValid = url.trim().length > 0 && name.trim().length > 0;
const handleSubmit = useCallback(async () => {
let trimmedUrl = url.trim();
if (!trimmedUrl || !name.trim()) return;
// Convert webcal:// to https://
if (trimmedUrl.startsWith("webcal://")) {
trimmedUrl = trimmedUrl.replace(/^webcal:\/\//, "https://");
}
try {
new URL(trimmedUrl);
} catch {
setError(t("invalid_url"));
return;
}
setError(null);
setIsSubmitting(true);
try {
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
if (subscription) {
toast.success(t("success", { name: name.trim() }));
onClose();
} else {
setError(t("error"));
}
} catch {
setError(t("error"));
} finally {
setIsSubmitting(false);
}
}, [url, name, color, refreshInterval, client, addICalSubscription, onClose, t]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
useEffect(() => {
const modal = modalRef.current;
if (!modal) return;
const focusableEls = modal.querySelectorAll<HTMLElement>(
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
);
const firstEl = focusableEls[0];
const lastEl = focusableEls[focusableEls.length - 1];
const handler = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl?.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl?.focus();
}
};
modal.addEventListener("keydown", handler);
firstEl?.focus();
return () => modal.removeEventListener("keydown", handler);
}, []);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("title")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("title")}</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4">
<p className="text-sm text-muted-foreground">{t("description")}</p>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("url_label")}
</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t("url_placeholder")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmitting}
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name_label")}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("name_placeholder")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmitting}
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("color_label")}
</label>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("refresh_interval")}
</label>
<select
value={refreshInterval}
onChange={(e) => setRefreshInterval(Number(e.target.value))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmitting}
>
<option value={15}>{t("interval_15")}</option>
<option value={30}>{t("interval_30")}</option>
<option value={60}>{t("interval_60")}</option>
<option value={360}>{t("interval_360")}</option>
<option value={1440}>{t("interval_1440")}</option>
</select>
</div>
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-md px-3 py-2">
{error}
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
{tCommon("cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{t("subscribing")}
</>
) : (
t("subscribe")
)}
</Button>
</div>
</div>
</div>
);
}
+18 -5
View File
@@ -22,7 +22,20 @@ function formatPhoneFeatures(features?: Record<string, boolean>): string {
return Object.keys(features).filter(k => features[k]).join(", ");
}
function formatDate(dateStr: string): string {
function formatDate(dateInput: string | Record<string, unknown>): string {
// Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? }
if (typeof dateInput === 'object' && dateInput !== null) {
const year = dateInput.year as number | undefined;
const month = dateInput.month as number | undefined;
const day = dateInput.day as number | undefined;
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const parts: string[] = [];
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]);
if (day) parts.push(String(day));
if (year) parts.push(String(year));
return parts.join(' ') || String(dateInput);
}
const dateStr = String(dateInput);
// Handle both ISO dates and partial dates like 1990-01-15 or --01-15
if (dateStr.startsWith("--")) {
// Partial date without year
@@ -216,12 +229,12 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
<Section icon={Globe} title={t("detail.online_services")} category="digital">
{onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2 group">
{svc.uri.startsWith("http") ? (
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{svc.user || svc.uri}
</a>
) : (
<span className="text-sm break-all">{svc.user || svc.uri}</span>
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
)}
{svc.service && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{svc.service}</span>
@@ -320,12 +333,12 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
<Section icon={KeyRound} title={t("detail.crypto_keys")} category="digital">
{cryptoKeys.map((key, i) => (
<div key={i} className="text-sm break-all">
{key.uri.startsWith("http") ? (
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{key.uri}
</a>
) : (
<span className="text-muted-foreground">{key.uri.substring(0, 80)}{key.uri.length > 80 ? "…" : ""}</span>
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
)}
</div>
))}
+1 -9
View File
@@ -2,7 +2,7 @@
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Search, Plus, BookUser, Info, Check, Trash2, Users, Download, X, UserPlus, Upload } from "lucide-react";
import { Search, Plus, BookUser, Info, Check, Trash2, Users, Download, X, UserPlus } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
@@ -17,7 +17,6 @@ interface ContactListProps {
onSearchChange: (query: string) => void;
onSelectContact: (id: string) => void;
onCreateNew: () => void;
onImport?: () => void;
supportsSync: boolean;
className?: string;
selectedContactIds: Set<string>;
@@ -36,7 +35,6 @@ export function ContactList({
onSearchChange,
onSelectContact,
onCreateNew,
onImport,
supportsSync,
className,
selectedContactIds,
@@ -185,12 +183,6 @@ export function ContactList({
<UserPlus className="w-4 h-4 mr-1.5" />
{t("create_new")}
</Button>
{onImport && (
<Button variant="outline" size="sm" onClick={onImport}>
<Upload className="w-4 h-4 mr-1.5" />
{t("import_vcard")}
</Button>
)}
</div>
</>
)}
+152 -18
View File
@@ -9,7 +9,9 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma
import { cn, formatFileSize } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useContactStore } from "@/stores/contact-store";
import { useTemplateStore } from "@/stores/template-store";
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
@@ -41,6 +43,7 @@ interface EmailComposerProps {
bcc: string[];
subject: string;
body: string;
htmlBody?: string;
draftId?: string;
fromEmail?: string;
fromName?: string;
@@ -59,6 +62,7 @@ interface EmailComposerProps {
cc?: { email?: string; name?: string }[];
subject?: string;
body?: string;
htmlBody?: string;
receivedAt?: string;
};
}
@@ -112,16 +116,22 @@ export function EmailComposer({
const getInitialBody = () => {
const prefix = initialDraftText || "";
if (!replyTo?.body) return prefix;
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : "";
const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
// When HTML body is available, don't include quoted text in the textarea
// The HTML original will be shown separately below the textarea
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
return prefix;
}
if (mode === 'forward') {
return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`;
} else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n> ${replyTo.body.split('\n').join('\n> ')}`;
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n> ${(replyTo.body || '').split('\n').join('\n> ')}`;
}
return prefix;
};
@@ -137,6 +147,18 @@ export function EmailComposer({
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const autoResizeTextarea = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
}, []);
useEffect(() => {
autoResizeTextarea();
}, [body, autoResizeTextarea]);
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
@@ -146,6 +168,7 @@ export function EmailComposer({
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const [showCloseDialog, setShowCloseDialog] = useState(false);
const [showAllAttachments, setShowAllAttachments] = useState(false);
const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate,
@@ -159,7 +182,9 @@ export function EmailComposer({
restoreFocus: true,
});
const { client, identities, primaryIdentity } = useAuthStore();
const { client } = useAuthStore();
const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null;
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
const addTemplate = useTemplateStore((s) => s.addTemplate);
@@ -330,13 +355,9 @@ export function EmailComposer({
return () => window.removeEventListener('keydown', handleTemplateKey);
}, []);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!client || !event.target.files) return;
const addFiles = useCallback(async (files: File[]) => {
if (!client || files.length === 0) return;
const files = Array.from(event.target.files);
// AbortController tracks cancellation state but uploadBlob doesn't accept a signal,
// so abort only prevents post-upload state updates (cosmetic cancellation)
const newAttachments = files.map(file => {
const controller = new AbortController();
return { file, uploading: true, abortController: controller };
@@ -372,12 +393,60 @@ export function EmailComposer({
);
}
}
}, [client, t]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return;
await addFiles(Array.from(event.target.files));
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const [isDraggingOver, setIsDraggingOver] = useState(false);
const dragTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const clearDragState = useCallback(() => {
if (dragTimeoutRef.current) clearTimeout(dragTimeoutRef.current);
dragTimeoutRef.current = null;
setIsDraggingOver(false);
}, []);
const resetDragTimeout = useCallback(() => {
if (dragTimeoutRef.current) clearTimeout(dragTimeoutRef.current);
dragTimeoutRef.current = setTimeout(clearDragState, 150);
}, [clearDragState]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes('Files')) {
setIsDraggingOver(true);
resetDragTimeout();
}
}, [resetDragTimeout]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
resetDragTimeout();
}, [resetDragTimeout]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
resetDragTimeout();
}, [resetDragTimeout]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
clearDragState();
if (e.dataTransfer.files?.length) {
addFiles(Array.from(e.dataTransfer.files));
}
}, [addFiles, clearDragState]);
const removeAttachment = (index: number) => {
const att = attachments[index];
att?.abortController?.abort();
@@ -549,13 +618,37 @@ export function EmailComposer({
: currentIdentity.email
: undefined;
// Append signature from the selected identity
let finalBody = body;
if (currentIdentity?.textSignature) {
finalBody = body + '\n\n-- \n' + currentIdentity.textSignature;
}
// Build HTML body when replying/forwarding with original HTML content
let finalHtmlBody: string | undefined;
if (replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
const escapedBody = body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
const signatureHtml = currentIdentity?.textSignature
? `<br><br>-- <br>${currentIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`
: '';
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : '';
const fromAddr = replyTo.from?.[0];
const fromStr = fromAddr ? `${fromAddr.name || fromAddr.email}` : tCommon('unknown');
const quoteHeader = mode === 'forward'
? `---------- ${t('prefix.forward')} ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
: `On ${date}, ${fromStr} wrote:<br>`;
finalHtmlBody = `<div>${escapedBody}</div>${signatureHtml}<br><div><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote></div>`;
}
try {
await onSend?.({
to: toAddresses,
cc: ccAddresses,
bcc: bccAddresses,
subject,
body,
body: finalBody,
htmlBody: finalHtmlBody,
draftId: finalDraftId || undefined,
fromEmail,
fromName: currentIdentity?.name || undefined,
@@ -617,7 +710,22 @@ export function EmailComposer({
};
return (
<div className={cn("flex flex-col h-full bg-background", className)}>
<div
className={cn("flex flex-col h-full bg-background relative", className)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Drag overlay */}
{isDraggingOver && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 border-2 border-dashed border-primary rounded-lg pointer-events-none">
<div className="flex flex-col items-center gap-2 text-primary">
<Paperclip className="w-8 h-8" />
<span className="text-sm font-medium">{t('drop_files')}</span>
</div>
</div>
)}
{/* Header - mobile: clean bar with close/send, desktop: title bar */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-background">
<div className="flex items-center gap-3">
@@ -659,7 +767,7 @@ export function EmailComposer({
</Button>
</div>
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 min-h-0 overflow-auto">
{/* Fields section */}
<div className="space-y-0 border-b">
{/* From field */}
@@ -825,10 +933,11 @@ export function EmailComposer({
</div>
{/* Body */}
<div className="flex-1 px-4 py-3 min-h-0">
<div className="px-4 py-3">
<textarea
ref={textareaRef}
className={cn(
"w-full h-full resize-none outline-none text-sm bg-transparent text-foreground placeholder:text-muted-foreground rounded",
"w-full resize-none outline-none text-sm bg-transparent text-foreground placeholder:text-muted-foreground rounded min-h-[100px] overflow-hidden",
validationErrors.body && "ring-2 ring-red-500 dark:ring-red-400"
)}
placeholder={t('body_placeholder')}
@@ -841,11 +950,29 @@ export function EmailComposer({
/>
</div>
{/* Quoted original HTML */}
{replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward') && (
<div className="border-t border-border">
<div className="px-4 py-2 text-xs text-muted-foreground">
{mode === 'forward'
? `---------- ${t('prefix.forward')} ----------`
: `${replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : ''}, ${replyTo.from?.[0]?.name || replyTo.from?.[0]?.email || tCommon('unknown')}:`
}
</div>
<div
className="email-reply-quote px-4 pb-3 border-l-2 border-muted-foreground/30 ml-4 max-w-none rounded"
style={{ backgroundColor: '#ffffff', color: '#1a1a1a', fontSize: '14px' }}
dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(replyTo.htmlBody) }}
/>
</div>
)}
</div>
{/* Attachments */}
{attachments.length > 0 && (
<div className="px-4 py-2 border-t">
<div className="px-4 py-2 border-t shrink-0">
<div className="flex flex-wrap gap-2">
{attachments.map((att, index) => (
{(showAllAttachments ? attachments : attachments.slice(0, 3)).map((att, index) => (
<div
key={index}
className={cn(
@@ -881,12 +1008,20 @@ export function EmailComposer({
</div>
</div>
))}
{attachments.length > 3 && (
<button
onClick={() => setShowAllAttachments(prev => !prev)}
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-sm bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
{showAllAttachments ? t('show_less') : `+${attachments.length - 3}`}
</button>
)}
</div>
</div>
)}
{/* Bottom toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background">
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0">
{/* Left side actions */}
<div className="flex items-center gap-1">
<input
@@ -946,7 +1081,6 @@ export function EmailComposer({
</Button>
</div>
</div>
</div>
{showTemplatePicker && (
<TemplatePicker
+47 -2
View File
@@ -4,7 +4,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
import { ThreadListItem } from "./thread-list-item";
import { EmailContextMenu } from "./email-context-menu";
import { cn } from "@/lib/utils";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX } from "lucide-react";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle } from "lucide-react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
@@ -74,6 +74,7 @@ export function EmailList({
isLoadingMore,
mailboxes,
selectedMailbox,
emptyMailbox,
expandedThreadIds,
threadEmailsCache,
isLoadingThread,
@@ -172,6 +173,28 @@ export function EmailList({
}
};
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isEmptyableFolder = currentMailbox?.role === 'trash' || currentMailbox?.role === 'junk';
const handleEmptyFolder = async () => {
if (!client || isProcessing || !currentMailbox) return;
const confirmed = await confirmDialog({
title: t('empty_folder.confirm_title'),
message: t('empty_folder.confirm_message'),
confirmText: t('empty_folder.confirm_button'),
variant: "destructive",
});
if (!confirmed) return;
setIsProcessing(true);
try {
await emptyMailbox(client, currentMailbox.id);
} finally {
setTimeout(() => setIsProcessing(false), 500);
}
};
const handleLoadMore = useCallback(() => {
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
loadMoreEmails(client);
@@ -308,7 +331,29 @@ export function EmailList({
/>
)}
{/* Empty Folder Banner for Junk/Trash */}
{isEmptyableFolder && emails.length > 0 && !hasSelection && (
<div className="px-4 py-2 border-b border-border bg-muted/30 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertTriangle className="w-4 h-4" />
<span>{currentMailbox?.role === 'junk' ? t('empty_folder.junk_hint') : t('empty_folder.trash_hint')}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleEmptyFolder}
disabled={isProcessing}
className="text-destructive border-destructive/30 hover:bg-destructive/10 text-xs"
>
{isProcessing ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Trash2 className="w-3 h-3 mr-1" />
)}
{t('empty_folder.button')}
</Button>
</div>
)}
{/* Email List */}
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative">
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+232
View File
@@ -0,0 +1,232 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { X, Download, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
interface FilePreviewModalProps {
name: string;
onClose: () => void;
onDownload: (name: string) => Promise<void>;
getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>;
}
const TEXT_EXTENSIONS = new Set([
"txt", "md", "markdown", "json", "xml", "html", "htm", "css", "js", "ts",
"jsx", "tsx", "py", "rb", "java", "c", "cpp", "h", "hpp", "go", "rs",
"sh", "bash", "zsh", "yaml", "yml", "toml", "ini", "cfg", "conf", "env",
"log", "csv", "sql", "graphql", "vue", "svelte", "astro", "php", "pl",
"swift", "kt", "scala", "r", "lua", "vim",
]);
function getFileType(name: string): "text" | "pdf" | "audio" | "video" | "markdown" | "unknown" {
const ext = name.split(".").pop()?.toLowerCase() || "";
const baseName = name.toLowerCase();
if (ext === "md" || ext === "markdown") return "markdown";
if (ext === "pdf") return "pdf";
if (["mp3", "wav", "ogg", "flac", "aac", "m4a", "wma", "opus"].includes(ext)) return "audio";
if (["mp4", "webm", "ogv", "mov", "avi", "mkv", "m4v"].includes(ext)) return "video";
if (TEXT_EXTENSIONS.has(ext) || ["dockerfile", "makefile", "readme", "license", "changelog"].includes(baseName)) return "text";
return "unknown";
}
function SimpleMarkdown({ content }: { content: string }) {
const lines = content.split("\n");
const elements: React.ReactNode[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Headers
if (line.startsWith("### ")) {
elements.push(<h3 key={i} className="text-lg font-semibold mt-4 mb-2">{processInline(line.slice(4))}</h3>);
} else if (line.startsWith("## ")) {
elements.push(<h2 key={i} className="text-xl font-semibold mt-5 mb-2">{processInline(line.slice(3))}</h2>);
} else if (line.startsWith("# ")) {
elements.push(<h1 key={i} className="text-2xl font-bold mt-6 mb-3">{processInline(line.slice(2))}</h1>);
} else if (line.startsWith("---") || line.startsWith("***")) {
elements.push(<hr key={i} className="my-4 border-border" />);
} else if (line.startsWith("- ") || line.startsWith("* ")) {
elements.push(<li key={i} className="ml-4 list-disc">{processInline(line.slice(2))}</li>);
} else if (/^\d+\. /.test(line)) {
elements.push(<li key={i} className="ml-4 list-decimal">{processInline(line.replace(/^\d+\. /, ""))}</li>);
} else if (line.startsWith("> ")) {
elements.push(<blockquote key={i} className="border-l-4 border-border pl-4 italic text-muted-foreground my-2">{processInline(line.slice(2))}</blockquote>);
} else if (line.startsWith("```")) {
// Code block - collect until closing ```
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
elements.push(
<pre key={i} className="bg-muted rounded p-3 my-2 overflow-x-auto text-sm font-mono">
<code>{codeLines.join("\n")}</code>
</pre>
);
} else if (line.trim() === "") {
elements.push(<div key={i} className="h-2" />);
} else {
elements.push(<p key={i} className="my-1">{processInline(line)}</p>);
}
}
return <div className="prose prose-sm dark:prose-invert max-w-none">{elements}</div>;
}
function processInline(text: string): React.ReactNode {
// Process bold, italic, code inline
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
while (remaining.length > 0) {
// Bold
const boldMatch = remaining.match(/\*\*(.+?)\*\*/);
// Inline code
const codeMatch = remaining.match(/`([^`]+)`/);
// Italic
const italicMatch = remaining.match(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/);
const matches = [
boldMatch && { type: "bold", match: boldMatch },
codeMatch && { type: "code", match: codeMatch },
italicMatch && { type: "italic", match: italicMatch },
].filter(Boolean).sort((a, b) => (a!.match.index ?? 0) - (b!.match.index ?? 0));
if (matches.length === 0) {
parts.push(remaining);
break;
}
const first = matches[0]!;
const idx = first.match.index ?? 0;
if (idx > 0) {
parts.push(remaining.slice(0, idx));
}
if (first.type === "bold") {
parts.push(<strong key={key++}>{first.match[1]}</strong>);
} else if (first.type === "code") {
parts.push(<code key={key++} className="bg-muted px-1 py-0.5 rounded text-sm font-mono">{first.match[1]}</code>);
} else {
parts.push(<em key={key++}>{first.match[1]}</em>);
}
remaining = remaining.slice(idx + first.match[0].length);
}
return parts.length === 1 ? parts[0] : <>{parts}</>;
}
export function FilePreviewModal({ name, onClose, onDownload, getFileContent }: FilePreviewModalProps) {
const t = useTranslations("files");
const [content, setContent] = useState<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const fileType = getFileType(name);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const { blob, contentType } = await getFileContent(name);
if (cancelled) return;
if (fileType === "text" || fileType === "markdown") {
const text = await blob.text();
if (!cancelled) setContent(text);
} else {
const url = URL.createObjectURL(blob);
if (!cancelled) setObjectUrl(url);
}
} catch {
if (!cancelled) setError(true);
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [name]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return (
<div role="dialog" aria-label={name} className="fixed inset-0 z-50 flex flex-col bg-black/80" onClick={onClose}>
<div className="flex items-center justify-between px-4 py-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-medium truncate">{name}</h3>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onDownload(name)}>
<Download className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
</div>
<div className="flex-1 flex items-center justify-center overflow-auto p-4" onClick={(e) => e.stopPropagation()}>
{loading && (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
)}
{error && (
<p className="text-sm text-destructive">{t("preview_error")}</p>
)}
{!loading && !error && (fileType === "text") && content !== null && (
<pre className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm font-mono whitespace-pre-wrap break-words">
{content}
</pre>
)}
{!loading && !error && fileType === "markdown" && content !== null && (
<div className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm">
<SimpleMarkdown content={content} />
</div>
)}
{!loading && !error && fileType === "pdf" && objectUrl && (
<iframe
src={objectUrl}
className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name}
/>
)}
{!loading && !error && fileType === "audio" && objectUrl && (
<div className="bg-background rounded-lg p-8 max-w-lg w-full">
<p className="text-sm font-medium mb-4 text-center">{name}</p>
<audio controls className="w-full" src={objectUrl} />
</div>
)}
{!loading && !error && fileType === "video" && objectUrl && (
<video controls className="max-w-4xl max-h-full rounded-lg" src={objectUrl} />
)}
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import { Upload, FolderPlus, FilePlus } from "lucide-react";
import { Button } from "@/components/ui/button";
interface FileUploadAreaProps {
onUpload: (files: File[]) => Promise<void>;
onCreateFolder: () => void;
onCreateTextFile?: () => void;
}
export function FileUploadArea({ onUpload, onCreateFolder, onCreateTextFile }: FileUploadAreaProps) {
const t = useTranslations("files");
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
await onUpload(files);
}
}, [onUpload]);
return (
<div className="flex items-center justify-center h-full p-8">
<div
className={`flex flex-col items-center gap-4 p-12 rounded-xl border-2 border-dashed transition-colors max-w-md w-full ${
isDragging ? "border-primary bg-primary/5" : "border-border"
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center">
<Upload className="w-8 h-8 text-muted-foreground" />
</div>
<div className="text-center">
<h3 className="text-base font-medium">{t("empty_state_title")}</h3>
<p className="text-sm text-muted-foreground mt-1">{t("empty_state_description")}</p>
<p className="text-xs text-muted-foreground mt-2">{t("drop_files_here")}</p>
</div>
<div className="flex gap-2 flex-wrap justify-center">
<Button
variant="outline"
size="sm"
onClick={onCreateFolder}
>
<FolderPlus className="w-4 h-4 mr-2" />
{t("new_folder")}
</Button>
{onCreateTextFile && (
<Button
variant="outline"
size="sm"
onClick={onCreateTextFile}
>
<FilePlus className="w-4 h-4 mr-2" />
{t("new_text_file")}
</Button>
)}
</div>
</div>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "@/components/settings/settings-section";
export type FolderLayout = "inline" | "sidebar";
export interface FilesSettings {
defaultViewMode: "list" | "grid";
showIcons: boolean;
coloredIcons: boolean;
defaultSortKey: "name" | "size" | "modified";
defaultSortDir: "asc" | "desc";
showHiddenFiles: boolean;
showThumbnails: boolean;
folderLayout: FolderLayout;
}
export const DEFAULT_FILES_SETTINGS: FilesSettings = {
defaultViewMode: "list",
showIcons: true,
coloredIcons: true,
defaultSortKey: "name",
defaultSortDir: "asc",
showHiddenFiles: false,
showThumbnails: true,
folderLayout: "inline",
};
export function loadFilesSettings(): FilesSettings {
if (typeof window === "undefined") return DEFAULT_FILES_SETTINGS;
try {
const raw = localStorage.getItem("files-settings");
if (raw) return { ...DEFAULT_FILES_SETTINGS, ...JSON.parse(raw) };
} catch { /* ignore */ }
return DEFAULT_FILES_SETTINGS;
}
export function saveFilesSettings(settings: FilesSettings) {
localStorage.setItem("files-settings", JSON.stringify(settings));
// Dispatch custom event for same-tab listeners (StorageEvent only fires cross-tab)
window.dispatchEvent(new CustomEvent("files-settings-changed"));
}
interface FilesSettingsDialogProps {
isOpen: boolean;
onClose: () => void;
settings: FilesSettings;
onSettingsChange: (settings: FilesSettings) => void;
}
export function FilesSettingsDialog({ isOpen, onClose, settings, onSettingsChange }: FilesSettingsDialogProps) {
const t = useTranslations("files");
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
if (isOpen) window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
if (isOpen) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen, onClose]);
if (!isOpen) return null;
const update = (patch: Partial<FilesSettings>) => {
const next = { ...settings, ...patch };
onSettingsChange(next);
saveFilesSettings(next);
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("settings_title")}
className="bg-background border border-border rounded-lg shadow-lg w-full max-w-md mx-4 max-h-[80vh] flex flex-col"
>
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold">{t("settings_title")}</h2>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
<div className="overflow-y-auto p-4 space-y-6">
<SettingsSection title={t("settings_display")}>
<SettingItem label={t("settings_folder_layout")} description={t("settings_folder_layout_desc")}>
<RadioGroup
value={settings.folderLayout}
onChange={(v) => update({ folderLayout: v as FolderLayout })}
options={[
{ value: "inline", label: t("settings_folder_layout_inline") },
{ value: "sidebar", label: t("settings_folder_layout_sidebar") },
]}
/>
</SettingItem>
<SettingItem label={t("settings_default_view")} description={t("settings_default_view_desc")}>
<RadioGroup
value={settings.defaultViewMode}
onChange={(v) => update({ defaultViewMode: v as "list" | "grid" })}
options={[
{ value: "list", label: t("list_view") },
{ value: "grid", label: t("grid_view") },
]}
/>
</SettingItem>
<SettingItem label={t("settings_default_sort")} description={t("settings_default_sort_desc")}>
<RadioGroup
value={settings.defaultSortKey}
onChange={(v) => update({ defaultSortKey: v as "name" | "size" | "modified" })}
options={[
{ value: "name", label: t("name") },
{ value: "size", label: t("size") },
{ value: "modified", label: t("modified") },
]}
/>
</SettingItem>
<SettingItem label={t("settings_sort_direction")} description={t("settings_sort_direction_desc")}>
<RadioGroup
value={settings.defaultSortDir}
onChange={(v) => update({ defaultSortDir: v as "asc" | "desc" })}
options={[
{ value: "asc", label: t("settings_ascending") },
{ value: "desc", label: t("settings_descending") },
]}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t("settings_icons")}>
<SettingItem label={t("settings_show_icons")} description={t("settings_show_icons_desc")}>
<ToggleSwitch
checked={settings.showIcons}
onChange={(v) => update({ showIcons: v })}
/>
</SettingItem>
<SettingItem label={t("settings_colored_icons")} description={t("settings_colored_icons_desc")}>
<ToggleSwitch
checked={settings.coloredIcons}
onChange={(v) => update({ coloredIcons: v })}
disabled={!settings.showIcons}
/>
</SettingItem>
<SettingItem label={t("settings_show_thumbnails")} description={t("settings_show_thumbnails_desc")}>
<ToggleSwitch
checked={settings.showThumbnails}
onChange={(v) => update({ showThumbnails: v })}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t("settings_behavior")}>
<SettingItem label={t("settings_show_hidden")} description={t("settings_show_hidden_desc")}>
<ToggleSwitch
checked={settings.showHiddenFiles}
onChange={(v) => update({ showHiddenFiles: v })}
/>
</SettingItem>
</SettingsSection>
</div>
</div>
</div>
);
}
+297
View File
@@ -0,0 +1,297 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import {
Folder,
FolderOpen,
ChevronRight,
ChevronDown,
Home,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useFileStore, type FileResource } from "@/stores/file-store";
interface FolderNode {
id: string;
name: string;
path: string;
}
interface FolderTreeSidebarProps {
currentPath: string;
onNavigate: (path: string, resourceId?: string | null) => void;
listByParentId: (parentId: string | null) => Promise<FileResource[]>;
width?: number;
isResizing?: boolean;
}
export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 256, isResizing }: FolderTreeSidebarProps) {
const t = useTranslations("files");
const client = useFileStore(s => s.client);
const [rootChildren, setRootChildren] = useState<FolderNode[] | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(["root"]));
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
// Cache: parentId (or "root") -> FolderNode[]
const [childrenCache, setChildrenCache] = useState<Map<string, FolderNode[]>>(new Map());
// Map folder path -> id for reverse lookup
const pathToIdRef = useRef<Map<string, string>>(new Map());
const loadChildren = useCallback(async (parentId: string | null, parentPath: string) => {
const cacheKey = parentId ?? "root";
// Skip if already loading or cached
if (childrenCache.has(cacheKey)) return;
setLoadingIds(prev => new Set(prev).add(cacheKey));
try {
const resources = await listByParentId(parentId);
const folders = resources
.filter(r => r.isDirectory)
.sort((a, b) => a.name.localeCompare(b.name))
.map(r => {
const folderPath = parentPath === "/" ? `/${r.name}` : `${parentPath}/${r.name}`;
pathToIdRef.current.set(folderPath, r.id);
return {
id: r.id,
name: r.name,
path: folderPath,
};
});
// Don't cache empty root results — empty root likely means client wasn't ready yet
if (folders.length > 0 || parentId !== null) {
setChildrenCache(prev => new Map(prev).set(cacheKey, folders));
}
if (parentId === null) {
setRootChildren(folders);
}
} catch {
// Silently fail
} finally {
setLoadingIds(prev => {
const next = new Set(prev);
next.delete(cacheKey);
return next;
});
}
}, [childrenCache, listByParentId]);
// Load root folders when client is available (handles page refresh timing)
useEffect(() => {
if (client) {
loadChildren(null, "/");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client]);
// Auto-expand along the current path when navigating
useEffect(() => {
if (currentPath === "/") return;
const segments = currentPath.split("/").filter(Boolean);
// Walk down the path and expand + load each ancestor
let ancestorPath = "";
for (let i = 0; i < segments.length; i++) {
ancestorPath = "/" + segments.slice(0, i + 1).join("/");
const folderId = pathToIdRef.current.get(ancestorPath);
if (folderId) {
setExpandedIds(prev => {
if (prev.has(folderId)) return prev;
return new Set(prev).add(folderId);
});
if (!childrenCache.has(folderId)) {
loadChildren(folderId, ancestorPath);
}
}
}
}, [currentPath, childrenCache, loadChildren]);
const handleToggleExpand = useCallback(async (folderId: string, folderPath: string) => {
setExpandedIds(prev => {
const next = new Set(prev);
if (next.has(folderId)) {
next.delete(folderId);
} else {
next.add(folderId);
}
return next;
});
if (!childrenCache.has(folderId)) {
await loadChildren(folderId, folderPath);
}
}, [childrenCache, loadChildren]);
const handleFolderClick = useCallback((path: string, id: string | null) => {
onNavigate(path, id);
}, [onNavigate]);
return (
<div
className={cn(
"border-r border-border bg-secondary overflow-hidden shrink-0 hidden lg:flex flex-col",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${width}px` }}
>
<div className="flex-1 overflow-y-auto py-1">
{/* Root / Home entry */}
<div
style={{ paddingBlock: "var(--density-sidebar-py)" }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 px-2",
currentPath === "/"
? "bg-accent text-accent-foreground"
: "hover:bg-muted text-foreground",
"font-medium"
)}
>
<button
onClick={() => handleFolderClick("/", null)}
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
>
<Home className={cn("w-4 h-4 flex-shrink-0 mr-2 transition-colors")} />
<span className="truncate">{t("breadcrumb_root")}</span>
</button>
</div>
{/* Folder tree */}
{rootChildren === null && loadingIds.has("root") ? (
<div className="px-3 py-2 space-y-2">
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
<div className="h-4 w-20 bg-muted animate-pulse rounded" />
<div className="h-4 w-28 bg-muted animate-pulse rounded" />
</div>
) : (
rootChildren?.map(folder => (
<FolderTreeItem
key={folder.id}
node={folder}
depth={0}
currentPath={currentPath}
expandedIds={expandedIds}
loadingIds={loadingIds}
childrenCache={childrenCache}
onToggleExpand={handleToggleExpand}
onFolderClick={handleFolderClick}
onLoadChildren={loadChildren}
/>
))
)}
</div>
</div>
);
}
function FolderTreeItem({
node,
depth,
currentPath,
expandedIds,
loadingIds,
childrenCache,
onToggleExpand,
onFolderClick,
onLoadChildren,
}: {
node: FolderNode;
depth: number;
currentPath: string;
expandedIds: Set<string>;
loadingIds: Set<string>;
childrenCache: Map<string, FolderNode[]>;
onToggleExpand: (folderId: string, folderPath: string) => void;
onFolderClick: (path: string, id: string | null) => void;
onLoadChildren: (parentId: string, parentPath: string) => Promise<void>;
}) {
const isExpanded = expandedIds.has(node.id);
const isSelected = currentPath === node.path;
const isLoading = loadingIds.has(node.id);
const children = childrenCache.get(node.id);
const hasChildren = children !== undefined && children.length > 0;
const indentPx = depth * 16;
const Icon = isExpanded && hasChildren ? FolderOpen : Folder;
// Eagerly load children on mount to know if subfolders exist
useEffect(() => {
if (children === undefined && !loadingIds.has(node.id)) {
onLoadChildren(node.id, node.path);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div
style={{ paddingBlock: "var(--density-sidebar-py)" }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 px-2",
isSelected
? "bg-accent text-accent-foreground"
: "hover:bg-muted text-foreground",
depth === 0 && "font-medium"
)}
>
{/* Expand/collapse chevron */}
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation();
onToggleExpand(node.id, node.path);
}}
className={cn(
"p-0.5 rounded mr-1 transition-all duration-200",
"hover:bg-muted active:bg-accent"
)}
style={{ marginLeft: `${indentPx}px` }}
>
{isExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
</button>
) : null}
{/* Folder name */}
<button
onClick={() => onFolderClick(node.path, node.id)}
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
style={{
paddingBlock: "var(--density-sidebar-py)",
paddingLeft: hasChildren ? "4px" : `${indentPx + 24}px`,
}}
>
<Icon className={cn(
"w-4 h-4 flex-shrink-0 mr-2 transition-colors",
isExpanded && hasChildren && "text-primary",
!hasChildren && depth > 0 && "text-muted-foreground"
)} />
<span className="truncate">{node.name}</span>
</button>
</div>
{/* Children */}
{isExpanded && children && (
<div className="relative">
{children.map(child => (
<FolderTreeItem
key={child.id}
node={child}
depth={depth + 1}
currentPath={currentPath}
expandedIds={expandedIds}
loadingIds={loadingIds}
childrenCache={childrenCache}
onToggleExpand={onToggleExpand}
onFolderClick={onFolderClick}
onLoadChildren={onLoadChildren}
/>
))}
</div>
)}
</>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { X, Download, ZoomIn, ZoomOut, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "next-intl";
interface ImagePreviewModalProps {
name: string;
onClose: () => void;
onDownload: (name: string) => Promise<void>;
getImageUrl: (name: string) => Promise<string>;
}
export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: ImagePreviewModalProps) {
const t = useTranslations("files");
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
useEffect(() => {
let revoke: string | null = null;
setLoading(true);
setError(false);
getImageUrl(name)
.then((url) => {
revoke = url;
setImageUrl(url);
setLoading(false);
})
.catch(() => {
setError(true);
setLoading(false);
});
return () => {
if (revoke) URL.revokeObjectURL(revoke);
};
}, [name, getImageUrl]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "+" || e.key === "=") setZoom((z) => Math.min(z + 0.25, 5));
if (e.key === "-") setZoom((z) => Math.max(z - 0.25, 0.25));
if (e.key === "r") setRotation((r) => r + 90);
}, [onClose]);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
return (
<div
role="dialog"
aria-label={name}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
onClick={onClose}
>
{/* Header */}
<div className="absolute top-0 left-0 right-0 flex items-center justify-between px-4 py-3 bg-gradient-to-b from-black/60 to-transparent z-10">
<span className="text-white text-sm font-medium truncate max-w-[50%]">{name}</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setZoom((z) => Math.min(z + 0.25, 5)); }}>
<ZoomIn className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setZoom((z) => Math.max(z - 0.25, 0.25)); }}>
<ZoomOut className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setRotation((r) => r + 90); }}>
<RotateCw className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); onDownload(name); }}>
<Download className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
</div>
{/* Image */}
<div className="flex items-center justify-center w-full h-full p-16" onClick={(e) => e.stopPropagation()}>
{loading && (
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin" />
)}
{error && (
<p className="text-white/70 text-sm">{t("preview_error")}</p>
)}
{imageUrl && !error && (
<img
src={imageUrl}
alt={name}
className="max-w-full max-h-full object-contain transition-transform duration-200"
style={{ transform: `scale(${zoom}) rotate(${rotation}deg)` }}
onLoad={() => setLoading(false)}
draggable={false}
/>
)}
</div>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface NewFolderDialogProps {
onConfirm: (name: string) => Promise<void>;
onCancel: () => void;
}
export function NewFolderDialog({ onConfirm, onCancel }: NewFolderDialogProps) {
const t = useTranslations("files");
const [name, setName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
setIsSubmitting(true);
try {
await onConfirm(trimmed);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div
className="bg-background border border-border rounded-lg shadow-lg p-6 w-full max-w-sm mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold mb-4">{t("new_folder")}</h2>
<form onSubmit={handleSubmit}>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("new_folder_name")}
className="mb-4"
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button type="submit" disabled={!name.trim() || isSubmitting}>
{t("create")}
</Button>
</div>
</form>
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface RenameDialogProps {
currentName: string;
title?: string;
label?: string;
onConfirm: (newName: string) => Promise<void>;
onCancel: () => void;
}
export function RenameDialog({ currentName, title, label, onConfirm, onCancel }: RenameDialogProps) {
const t = useTranslations("files");
const [name, setName] = useState(currentName);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
setIsSubmitting(true);
try {
await onConfirm(trimmed);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div
className="bg-background border border-border rounded-lg shadow-lg p-6 w-full max-w-sm mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold mb-4">{title || t("rename_title")}</h2>
<form onSubmit={handleSubmit}>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={label || t("new_name")}
className="mb-4"
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button type="submit" disabled={!name.trim() || isSubmitting}>
{t("save")}
</Button>
</div>
</form>
</div>
</div>
);
}
@@ -9,6 +9,11 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { IdentityForm } from './identity-form';
import { useIdentityStore } from '@/stores/identity-store';
import { useAuthStore } from '@/stores/auth-store';
function useSyncIdentities() {
const syncIdentities = useAuthStore((state) => state.syncIdentities);
return syncIdentities;
}
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { toast } from '@/stores/toast-store';
import { useFocusTrap } from '@/hooks/use-focus-trap';
@@ -34,6 +39,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const client = useAuthStore((state) => state.client);
const { identities, addIdentity, updateIdentityLocal, removeIdentity } = useIdentityStore();
const syncIdentities = useSyncIdentities();
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
@@ -82,6 +88,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
);
addIdentity(newIdentity);
syncIdentities();
setIsCreating(false);
toast.success(tNotif('identity_created'));
} catch (error) {
@@ -104,6 +111,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
});
updateIdentityLocal(identity.id, data);
syncIdentities();
setEditingId(null);
toast.success(tNotif('identity_updated'));
} catch (error) {
@@ -133,6 +141,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
try {
await client.deleteIdentity(identity.id);
removeIdentity(identity.id);
syncIdentities();
toast.success(tNotif('identity_deleted'));
} catch (error) {
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
+4 -1
View File
@@ -2,11 +2,12 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { Mail, Calendar, BookUser, Settings, LogOut } from "lucide-react";
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut } from "lucide-react";
import { usePathname, Link } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store";
import { useEmailStore } from "@/stores/email-store";
import { useWebDAVStore } from "@/stores/webdav-store";
import { cn, formatFileSize } from "@/lib/utils";
interface NavItem {
@@ -140,12 +141,14 @@ export function NavigationRail({
const pathname = usePathname();
const { supportsCalendar } = useCalendarStore();
const { mailboxes } = useEmailStore();
const { supportsWebDAV } = useWebDAVStore();
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const navItems: NavItem[] = [
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false },
{ id: "settings", icon: Settings, labelKey: "settings", href: "/settings" },
];
+28 -6
View File
@@ -46,6 +46,7 @@ interface SidebarProps {
onTagSelect?: (keywordId: string | null) => void;
onCompose?: () => void;
onSidebarClose?: () => void;
onUnreadFilterClick?: (mailboxId: string) => void;
className?: string;
}
@@ -84,6 +85,7 @@ function MailboxTreeItem({
onMailboxSelect,
onToggleExpand,
isCollapsed,
onUnreadFilterClick,
}: {
node: MailboxNode;
selectedMailbox: string;
@@ -91,6 +93,7 @@ function MailboxTreeItem({
onMailboxSelect?: (id: string) => void;
onToggleExpand: (id: string) => void;
isCollapsed: boolean;
onUnreadFilterClick?: (mailboxId: string) => void;
}) {
const t = useTranslations('sidebar');
const tNotifications = useTranslations('notifications');
@@ -188,12 +191,28 @@ function MailboxTreeItem({
<span className="flex-1 truncate">{node.name}</span>
<span className="flex items-center gap-1.5 ml-2 flex-shrink-0">
{node.unreadEmails > 0 && (
<span className={cn(
"text-xs rounded-full px-2 py-0.5 font-medium",
selectedMailbox === node.id
? "bg-primary text-primary-foreground"
: "bg-foreground text-background"
)}>
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onUnreadFilterClick?.(node.id);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onUnreadFilterClick?.(node.id);
}
}}
className={cn(
"text-xs rounded-full px-2 py-0.5 font-medium cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all",
selectedMailbox === node.id
? "bg-primary text-primary-foreground"
: "bg-foreground text-background"
)}
title={node.unreadEmails + " unread"}
>
{node.unreadEmails}
</span>
)}
@@ -217,6 +236,7 @@ function MailboxTreeItem({
onMailboxSelect={onMailboxSelect}
onToggleExpand={onToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
/>
))}
</div>
@@ -336,6 +356,7 @@ export function Sidebar({
onTagSelect,
onCompose,
onSidebarClose,
onUnreadFilterClick,
className,
}: SidebarProps) {
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
@@ -482,6 +503,7 @@ export function Sidebar({
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
/>
))}
</>
@@ -0,0 +1,611 @@
"use client";
import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import { toast } from '@/stores/toast-store';
import { SettingsSection } from './settings-section';
import { Plus, Pencil, Trash2, Check, X, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
const CALENDAR_COLORS = [
"#3b82f6", // blue
"#ef4444", // red
"#22c55e", // green
"#f59e0b", // amber
"#8b5cf6", // violet
"#ec4899", // pink
"#14b8a6", // teal
"#f97316", // orange
"#06b6d4", // cyan
"#84cc16", // lime
"#6366f1", // indigo
"#a855f7", // purple
"#e11d48", // rose
"#0ea5e9", // sky
"#10b981", // emerald
"#d946ef", // fuchsia
];
function CalendarColorPicker({
value,
onChange,
allowCustom,
}: {
value: string;
onChange: (color: string) => void;
allowCustom?: boolean;
}) {
const selectedIsPreset = CALENDAR_COLORS.includes(value);
return (
<div className="flex flex-wrap items-center gap-1.5">
{CALENDAR_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => onChange(color)}
className={cn(
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
value === color && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
style={{ backgroundColor: color }}
aria-label={color}
/>
))}
{allowCustom && (
<label
className={cn(
"relative w-6 h-6 rounded-full cursor-pointer transition-transform hover:scale-110 overflow-hidden border-2 border-dashed border-muted-foreground/40",
!selectedIsPreset && value && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
style={!selectedIsPreset && value ? { backgroundColor: value } : undefined}
title="Custom color"
>
<input
type="color"
value={value || "#3b82f6"}
onChange={(e) => onChange(e.target.value)}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
/>
{(selectedIsPreset || !value) && (
<span className="absolute inset-0 flex items-center justify-center text-muted-foreground text-xs font-bold">+</span>
)}
</label>
)}
</div>
);
}
function CalendarEditForm({
initial,
onSave,
onCancel,
isLoading,
}: {
initial?: { name: string; color: string };
onSave: (data: { name: string; color: string }) => void;
onCancel: () => void;
isLoading: boolean;
}) {
const t = useTranslations('calendar.management');
const [name, setName] = useState(initial?.name || '');
const [color, setColor] = useState(initial?.color || '#3b82f6');
const isValid = name.trim().length > 0;
return (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t('name')}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && isValid) onSave({ name: name.trim(), color });
if (e.key === 'Escape') onCancel();
}}
placeholder={t('name_placeholder')}
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
disabled={isLoading}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t('color')}
</label>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => isValid && onSave({ name: name.trim(), color })}
disabled={isLoading || !isValid}
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{initial ? t('save') : t('create')}
</button>
<button
onClick={onCancel}
disabled={isLoading}
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
</div>
);
}
export { CalendarColorPicker, CALENDAR_COLORS };
export function CalendarManagementSettings() {
const t = useTranslations('calendar.management');
const { client, serverUrl, username } = useAuthStore();
const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
const [isCreating, setIsCreating] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [clearingId, setClearingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [deletingSubId, setDeletingSubId] = useState<string | null>(null);
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const tImport = useTranslations('calendar.import');
const tSub = useTranslations('calendar.subscription');
const colorPickerRef = useRef<HTMLDivElement>(null);
// Load calendars if not yet loaded
useEffect(() => {
if (client && calendars.length === 0) {
fetchCalendars(client);
}
}, [client, calendars.length, fetchCalendars]);
const handleRefreshSubscription = async (subId: string) => {
if (!client) return;
setRefreshingSubId(subId);
try {
await refreshICalSubscription(client, subId);
toast.success(tSub('refresh_success'));
} catch {
toast.error(tSub('refresh_error'));
} finally {
setRefreshingSubId(null);
}
};
const handleDeleteSubscription = async (subId: string) => {
if (!client) return;
setIsLoading(true);
try {
await removeICalSubscription(client, subId);
setDeletingSubId(null);
toast.success(tSub('deleted'));
} catch {
toast.error(tSub('delete_error'));
} finally {
setIsLoading(false);
}
};
// Close color picker on click outside
useEffect(() => {
if (!colorPickerId) return;
const handleClick = (e: MouseEvent) => {
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
setColorPickerId(null);
}
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setColorPickerId(null);
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [colorPickerId]);
const handleCreate = async (data: { name: string; color: string }) => {
if (!client) return;
setIsLoading(true);
try {
await createCalendar(client, {
name: data.name,
color: data.color,
isVisible: true,
isSubscribed: true,
});
setIsCreating(false);
toast.success(t('calendar_created'));
} catch {
toast.error(t('error_create'));
} finally {
setIsLoading(false);
}
};
const handleUpdate = async (calendarId: string, data: { name: string; color: string }) => {
if (!client) return;
setIsLoading(true);
try {
await updateCalendar(client, calendarId, { name: data.name, color: data.color });
setEditingId(null);
toast.success(t('calendar_updated'));
} catch {
toast.error(t('error_update'));
} finally {
setIsLoading(false);
}
};
const handleColorChange = async (calendarId: string, color: string) => {
if (!client) return;
try {
await updateCalendar(client, calendarId, { color });
toast.success(t('color_updated'));
} catch {
toast.error(t('error_update'));
}
setColorPickerId(null);
};
const handleDelete = async (calendarId: string) => {
if (!client) return;
setIsLoading(true);
try {
await removeCalendar(client, calendarId);
setDeletingId(null);
toast.success(t('calendar_deleted'));
} catch {
toast.error(t('error_delete'));
} finally {
setIsLoading(false);
}
};
const handleClear = async (calendarId: string) => {
if (!client) return;
setIsLoading(true);
try {
const count = await clearCalendarEvents(client, calendarId);
setClearingId(null);
toast.success(t('events_cleared', { count }));
} catch {
toast.error(t('error_clear'));
} finally {
setIsLoading(false);
}
};
const buildCalDavUrl = (calendarId: string) => {
if (!serverUrl || !username) return null;
const base = serverUrl.replace(/\/$/, '');
return `${base}/dav/calendars/user/${encodeURIComponent(username)}/${encodeURIComponent(calendarId)}/`;
};
const handleCopyUrl = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
toast.success(t('url_copied'));
} catch {
// Fallback for non-HTTPS contexts
const textArea = document.createElement('textarea');
textArea.value = url;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
toast.success(t('url_copied'));
}
};
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="space-y-2">
{calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => {
const color = cal.color || '#3b82f6';
if (editingId === cal.id) {
return (
<CalendarEditForm
key={cal.id}
initial={{ name: cal.name, color }}
onSave={(data) => handleUpdate(cal.id, data)}
onCancel={() => setEditingId(null)}
isLoading={isLoading}
/>
);
}
if (deletingId === cal.id) {
return (
<div key={cal.id} className="flex items-center gap-3 py-2.5 px-3 bg-destructive/5 rounded-md border border-destructive/20">
<Trash2 className="w-4 h-4 text-destructive flex-shrink-0" />
<p className="text-sm text-foreground flex-1">
{t('confirm_delete', { name: cal.name })}
</p>
<button
onClick={() => handleDelete(cal.id)}
disabled={isLoading}
className="px-3 py-1 text-xs font-medium bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 disabled:opacity-50"
>
{t('delete')}
</button>
<button
onClick={() => setDeletingId(null)}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
);
}
if (clearingId === cal.id) {
return (
<div key={cal.id} className="flex items-center gap-3 py-2.5 px-3 bg-amber-500/5 rounded-md border border-amber-500/20">
<Eraser className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<p className="text-sm text-foreground flex-1">
{t('confirm_clear', { name: cal.name })}
</p>
<button
onClick={() => handleClear(cal.id)}
disabled={isLoading}
className="px-3 py-1 text-xs font-medium bg-amber-600 text-white rounded-md hover:bg-amber-700 disabled:opacity-50"
>
{t('clear_events')}
</button>
<button
onClick={() => setClearingId(null)}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
);
}
return (
<div
key={cal.id}
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
>
{/* Color swatch - clickable to change color */}
<div className="relative">
<button
type="button"
onClick={() => setColorPickerId(colorPickerId === cal.id ? null : cal.id)}
className="w-5 h-5 rounded-full shrink-0 transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
style={{ backgroundColor: color }}
title={t('change_color')}
/>
{/* Inline color picker popover */}
{colorPickerId === cal.id && (
<div
ref={colorPickerRef}
className="absolute left-0 top-full mt-2 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
>
<CalendarColorPicker
value={color}
onChange={(c) => handleColorChange(cal.id, c)}
allowCustom
/>
</div>
)}
</div>
<CalendarIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{cal.name}</span>
{(() => {
const caldavUrl = buildCalDavUrl(cal.id);
if (!caldavUrl) return null;
return (
<div className="flex items-center gap-1 mt-0.5">
<Link className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<span className="text-xs text-muted-foreground truncate" title={caldavUrl}>
{caldavUrl}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleCopyUrl(caldavUrl);
}}
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
title={t('copy_url')}
>
<Copy className="w-3 h-3" />
</button>
</div>
);
})()}
</div>
{cal.isDefault && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{t('default')}
</span>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingId(cal.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t('edit')}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setClearingId(cal.id)}
className="p-1.5 rounded-md hover:bg-amber-500/10 text-muted-foreground hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
title={t('clear_events')}
>
<Eraser className="w-3.5 h-3.5" />
</button>
{!cal.isDefault && (
<button
type="button"
onClick={() => setDeletingId(cal.id)}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={t('delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
})}
{isCreating ? (
<CalendarEditForm
onSave={handleCreate}
onCancel={() => setIsCreating(false)}
isLoading={isLoading}
/>
) : (
<div className="flex gap-2">
<button
type="button"
onClick={() => setIsCreating(true)}
className="flex items-center gap-2 flex-1 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
>
<Plus className="w-4 h-4" />
{t('add_calendar')}
</button>
<button
type="button"
onClick={() => setShowImportModal(true)}
className="flex items-center gap-2 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
>
<Upload className="w-4 h-4" />
{tImport('title')}
</button>
<button
type="button"
onClick={() => setShowSubscriptionModal(true)}
className="flex items-center gap-2 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
>
<Globe className="w-4 h-4" />
{tSub('title')}
</button>
</div>
)}
</div>
{/* iCal Subscriptions */}
{icalSubscriptions.length > 0 && (
<div className="mt-6 space-y-2">
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
<Globe className="w-4 h-4 text-muted-foreground" />
{tSub('section_title')}
</h4>
{icalSubscriptions.map((sub) => {
if (deletingSubId === sub.id) {
return (
<div key={sub.id} className="flex items-center gap-3 py-2.5 px-3 bg-destructive/5 rounded-md border border-destructive/20">
<Trash2 className="w-4 h-4 text-destructive flex-shrink-0" />
<p className="text-sm text-foreground flex-1">
{tSub('confirm_delete', { name: sub.name })}
</p>
<button
onClick={() => handleDeleteSubscription(sub.id)}
disabled={isLoading}
className="px-3 py-1 text-xs font-medium bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 disabled:opacity-50"
>
{t('delete')}
</button>
<button
onClick={() => setDeletingSubId(null)}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
);
}
return (
<div
key={sub.id}
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
>
<span
className="w-5 h-5 rounded-full shrink-0"
style={{ backgroundColor: sub.color }}
/>
<Globe className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{sub.name}</span>
<span className="text-xs text-muted-foreground truncate block" title={sub.url}>
{sub.url}
</span>
{sub.lastRefreshed && (
<span className="text-xs text-muted-foreground">
{tSub('last_refreshed', { time: new Date(sub.lastRefreshed).toLocaleString() })}
</span>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => handleRefreshSubscription(sub.id)}
disabled={refreshingSubId === sub.id}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
title={tSub('refresh')}
>
<RefreshCw className={cn("w-3.5 h-3.5", refreshingSubId === sub.id && "animate-spin")} />
</button>
<button
type="button"
onClick={() => setDeletingSubId(sub.id)}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={tSub('unsubscribe')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
)}
{showImportModal && client && (
<ICalImportModal
calendars={calendars}
client={client}
onClose={() => setShowImportModal(false)}
/>
)}
{showSubscriptionModal && client && (
<ICalSubscriptionModal
client={client}
onClose={() => setShowSubscriptionModal(false)}
/>
)}
</SettingsSection>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Upload, Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsSection, SettingItem } from "./settings-section";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
export function ContactsSettings() {
const t = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const {
contacts,
supportsSync,
importContacts,
} = useContactStore();
const [showImport, setShowImport] = useState(false);
const individuals = contacts.filter(c => c.kind !== "group");
const handleImport = useCallback(async (importedContacts: import("@/lib/jmap/types").ContactCard[]) => {
return importContacts(
supportsSync && client ? client : null,
importedContacts
);
}, [supportsSync, client, importContacts]);
const handleExport = () => {
if (individuals.length > 0) {
exportContacts(individuals);
toast.success(t("export.success", { count: individuals.length }));
}
};
if (showImport) {
return (
<div className="border border-border rounded-lg overflow-hidden" style={{ minHeight: 400 }}>
<ContactImportDialog
existingContacts={contacts}
onImport={handleImport}
onClose={() => setShowImport(false)}
/>
</div>
);
}
return (
<SettingsSection
title={tSettings("title")}
description={tSettings("description")}
>
<SettingItem
label={tSettings("import_label")}
description={tSettings("import_description")}
>
<Button variant="outline" size="sm" onClick={() => setShowImport(true)}>
<Upload className="w-4 h-4 mr-2" />
{t("import.title")}
</Button>
</SettingItem>
<SettingItem
label={tSettings("export_label")}
description={tSettings("export_description")}
>
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={individuals.length === 0}
>
<Download className="w-4 h-4 mr-2" />
{t("export.title")}
</Button>
</SettingItem>
</SettingsSection>
);
}
+9
View File
@@ -14,6 +14,7 @@ export function EmailSettings() {
const {
markAsReadDelay,
deleteAction,
permanentlyDeleteJunk,
showPreview,
emailsPerPage,
externalContentPolicy,
@@ -65,6 +66,14 @@ export function EmailSettings() {
</div>
</SettingItem>
{/* Permanently Delete Junk */}
<SettingItem label={t('permanently_delete_junk.label')} description={t('permanently_delete_junk.description')}>
<ToggleSwitch
checked={permanentlyDeleteJunk}
onChange={(checked) => updateSetting('permanentlyDeleteJunk', checked)}
/>
</SettingItem>
{/* Show Preview */}
<SettingItem label={t('show_preview.label')} description={t('show_preview.description')}>
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
+280
View File
@@ -0,0 +1,280 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Folder, FolderOpen, FileText, FileCode, ImageIcon, FileAudio, File, Home, ChevronRight, ChevronDown } from "lucide-react";
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section";
import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog";
import { cn } from "@/lib/utils";
interface SampleFile {
name: string;
isFolder: boolean;
size: number;
modified: string;
hidden?: boolean;
thumbnailUrl?: string;
}
const SAMPLE_FILES: SampleFile[] = [
{ name: "Documents", isFolder: true, size: 0, modified: "2026-03-10" },
{ name: "Photos", isFolder: true, size: 0, modified: "2026-03-14" },
{ name: "report.pdf", isFolder: false, size: 245000, modified: "2026-03-15" },
{ name: "notes.md", isFolder: false, size: 1200, modified: "2026-03-12" },
{ name: "vacation.jpg", isFolder: false, size: 3400000, modified: "2026-03-08", thumbnailUrl: "/branding/Bulwark_Logo_Color.png" },
{ name: "song.mp3", isFolder: false, size: 5200000, modified: "2026-03-01" },
{ name: ".config", isFolder: false, size: 340, modified: "2026-02-20", hidden: true },
];
function getPreviewIcon(file: SampleFile, colored: boolean, size: "sm" | "lg") {
const cls = size === "sm" ? "w-4 h-4 flex-shrink-0" : "w-8 h-8 flex-shrink-0";
if (file.isFolder) {
return <Folder className={cn(cls, colored ? "text-blue-500" : "text-muted-foreground")} />;
}
const ext = file.name.split(".").pop()?.toLowerCase();
switch (ext) {
case "jpg": case "png": case "gif":
return <ImageIcon className={cn(cls, colored ? "text-emerald-500" : "text-muted-foreground")} />;
case "mp3": case "wav":
return <FileAudio className={cn(cls, colored ? "text-purple-500" : "text-muted-foreground")} />;
case "pdf":
return <FileText className={cn(cls, colored ? "text-red-600" : "text-muted-foreground")} />;
case "md": case "json": case "js": case "ts":
return <FileCode className={cn(cls, colored ? "text-yellow-600" : "text-muted-foreground")} />;
default:
return <File className={cn(cls, "text-muted-foreground")} />;
}
}
function formatSize(bytes: number): string {
if (bytes === 0) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
const sortedFiles = useMemo(() => {
let files = SAMPLE_FILES.filter((f) => {
if (!settings.showHiddenFiles && f.hidden) return false;
if (settings.folderLayout === "sidebar" && f.isFolder) return false;
return true;
});
files.sort((a, b) => {
// Folders first
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
let cmp = 0;
switch (settings.defaultSortKey) {
case "name": cmp = a.name.localeCompare(b.name); break;
case "size": cmp = a.size - b.size; break;
case "modified": cmp = a.modified.localeCompare(b.modified); break;
}
return settings.defaultSortDir === "desc" ? -cmp : cmp;
});
return files;
}, [settings.showHiddenFiles, settings.folderLayout, settings.defaultSortKey, settings.defaultSortDir]);
const listView = (
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-3 px-2 py-1 text-[10px] font-medium text-muted-foreground border-b border-border bg-muted/50">
<span className="flex-1 min-w-0">Name</span>
<span className="w-14 text-right">Size</span>
<span className="w-16 text-right">Modified</span>
</div>
{sortedFiles.map((file) => (
<div
key={file.name}
className={cn(
"flex items-center gap-2 px-2 py-1.5 border-b border-border last:border-b-0 transition-colors hover:bg-muted/50",
file.hidden && "opacity-50"
)}
>
{settings.showThumbnails && file.thumbnailUrl ? (
<img src={file.thumbnailUrl} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" />
) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "sm")
) : null}
<span className={cn("flex-1 min-w-0 truncate text-[11px]", file.isFolder && "font-medium")}>
{file.name}
</span>
<span className="w-14 text-right text-[10px] text-muted-foreground tabular-nums">
{formatSize(file.size)}
</span>
<span className="w-16 text-right text-[10px] text-muted-foreground tabular-nums">
{file.modified.slice(5)}
</span>
</div>
))}
</div>
);
const gridView = (
<div className="flex-1 min-w-0 p-2">
<div className="grid grid-cols-[repeat(auto-fill,minmax(4.5rem,1fr))] gap-1.5">
{sortedFiles.map((file) => (
<div
key={file.name}
className={cn(
"flex flex-col items-center gap-1 p-2 rounded-md transition-colors hover:bg-muted/50",
file.hidden && "opacity-50"
)}
>
{settings.showThumbnails && file.thumbnailUrl ? (
<img src={file.thumbnailUrl} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "lg")
) : (
<div className="w-8 h-8" />
)}
<span className={cn("text-[9px] truncate w-full text-center", file.isFolder && "font-medium")}>
{file.name}
</span>
</div>
))}
</div>
</div>
);
const sidebar = settings.folderLayout === "sidebar" && (
<div className="w-24 border-r border-border bg-muted/30 py-1.5 flex-shrink-0">
<div className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-medium text-foreground">
<Home className="w-3 h-3 flex-shrink-0" />
<span className="truncate">Files</span>
</div>
<div className="flex items-center gap-1 px-2 py-0.5 text-[10px] text-foreground bg-accent rounded-sm mx-1">
<ChevronDown className="w-2.5 h-2.5 flex-shrink-0" />
<FolderOpen className="w-3 h-3 flex-shrink-0 text-blue-500" />
<span className="truncate">Documents</span>
</div>
<div className="flex items-center gap-1 px-2 py-0.5 text-[10px] text-muted-foreground" style={{ paddingLeft: "1.25rem" }}>
<ChevronRight className="w-2.5 h-2.5 flex-shrink-0" />
<Folder className="w-3 h-3 flex-shrink-0 text-blue-500" />
<span className="truncate">Photos</span>
</div>
</div>
);
return (
<div className="mt-4 rounded-lg border border-border overflow-hidden bg-background text-xs select-none">
<div className="flex" style={{ minHeight: "10rem" }}>
{sidebar}
{settings.defaultViewMode === "grid" ? gridView : listView}
</div>
</div>
);
}
export function FilesSettingsComponent() {
const t = useTranslations("settings.files");
const [settings, setSettings] = useState<FilesSettings>(loadFilesSettings);
// Listen for external changes (e.g. if file-browser updates settings)
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === "files-settings") {
setSettings(loadFilesSettings());
}
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
const update = useCallback((patch: Partial<FilesSettings>) => {
setSettings(prev => {
const next = { ...prev, ...patch };
saveFilesSettings(next);
return next;
});
}, []);
return (
<div>
<div className="sticky top-0 z-10 bg-background pb-4 -mx-4 px-4 -mt-4 pt-4 lg:-mx-6 lg:px-6 lg:-mt-6 lg:pt-6 border-b border-border mb-6">
<p className="text-sm font-medium text-foreground mb-1">{t("preview.label")}</p>
<FilesSettingsPreview settings={settings} />
</div>
<div className="space-y-8">
<SettingsSection title={t("display.title")} description={t("display.description")}>
<SettingItem label={t("folder_layout.label")} description={t("folder_layout.description")}>
<RadioGroup
value={settings.folderLayout}
onChange={(v) => update({ folderLayout: v as FolderLayout })}
options={[
{ value: "inline", label: t("folder_layout.inline") },
{ value: "sidebar", label: t("folder_layout.sidebar") },
]}
/>
</SettingItem>
<SettingItem label={t("default_view.label")} description={t("default_view.description")}>
<RadioGroup
value={settings.defaultViewMode}
onChange={(v) => update({ defaultViewMode: v as "list" | "grid" })}
options={[
{ value: "list", label: t("default_view.list") },
{ value: "grid", label: t("default_view.grid") },
]}
/>
</SettingItem>
<SettingItem label={t("default_sort.label")} description={t("default_sort.description")}>
<RadioGroup
value={settings.defaultSortKey}
onChange={(v) => update({ defaultSortKey: v as "name" | "size" | "modified" })}
options={[
{ value: "name", label: t("default_sort.name") },
{ value: "size", label: t("default_sort.size") },
{ value: "modified", label: t("default_sort.modified") },
]}
/>
</SettingItem>
<SettingItem label={t("sort_direction.label")} description={t("sort_direction.description")}>
<RadioGroup
value={settings.defaultSortDir}
onChange={(v) => update({ defaultSortDir: v as "asc" | "desc" })}
options={[
{ value: "asc", label: t("sort_direction.ascending") },
{ value: "desc", label: t("sort_direction.descending") },
]}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t("icons.title")} description={t("icons.description")}>
<SettingItem label={t("show_icons.label")} description={t("show_icons.description")}>
<ToggleSwitch
checked={settings.showIcons}
onChange={(v) => update({ showIcons: v })}
/>
</SettingItem>
<SettingItem label={t("colored_icons.label")} description={t("colored_icons.description")}>
<ToggleSwitch
checked={settings.coloredIcons}
onChange={(v) => update({ coloredIcons: v })}
disabled={!settings.showIcons}
/>
</SettingItem>
<SettingItem label={t("show_thumbnails.label")} description={t("show_thumbnails.description")}>
<ToggleSwitch
checked={settings.showThumbnails}
onChange={(v) => update({ showThumbnails: v })}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t("behavior.title")} description={t("behavior.description")}>
<SettingItem label={t("show_hidden.label")} description={t("show_hidden.description")}>
<ToggleSwitch
checked={settings.showHiddenFiles}
onChange={(v) => update({ showHiddenFiles: v })}
/>
</SettingItem>
</SettingsSection>
</div>
</div>
);
}
+1 -1
View File
@@ -29,7 +29,7 @@ interface AppConfig extends ConfigData {
let configCache: ConfigData | null = null;
let configPromise: Promise<ConfigData> | null = null;
async function fetchConfig(): Promise<ConfigData> {
export async function fetchConfig(): Promise<ConfigData> {
// Return cached config if available
if (configCache) {
return configCache;
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
/**
* Tests for the signature appending logic used in the email composer
* and quick-reply paths. These test the pure transformation that should
* be applied when an identity has a textSignature.
*/
function appendSignature(body: string, textSignature: string | undefined): string {
if (textSignature) {
return body + '\n\n-- \n' + textSignature;
}
return body;
}
describe('signature appending', () => {
it('should append text signature with standard separator', () => {
const result = appendSignature('Hello world', 'Best regards,\nAlice');
expect(result).toBe('Hello world\n\n-- \nBest regards,\nAlice');
});
it('should not modify body when signature is undefined', () => {
const result = appendSignature('Hello world', undefined);
expect(result).toBe('Hello world');
});
it('should not modify body when signature is empty string', () => {
const result = appendSignature('Hello world', '');
expect(result).toBe('Hello world');
});
it('should handle empty body with signature', () => {
const result = appendSignature('', 'My Signature');
expect(result).toBe('\n\n-- \nMy Signature');
});
it('should handle multiline body and signature', () => {
const body = 'Dear Bob,\n\nHow are you?\n\nCheers';
const sig = 'Alice Smith\nCompany Inc.\nhttp://example.com';
const result = appendSignature(body, sig);
expect(result).toContain('Dear Bob,');
expect(result).toContain('-- \n');
expect(result).toContain('Alice Smith');
expect(result).toContain('Company Inc.');
});
it('should use RFC 3676 signature separator (dash dash space newline)', () => {
const result = appendSignature('body', 'sig');
// The separator should be "-- \n" (two dashes, a space, then newline)
expect(result).toContain('-- \n');
});
it('should place signature after two blank lines from body', () => {
const result = appendSignature('body text', 'sig');
expect(result).toBe('body text\n\n-- \nsig');
// Verify the structure: body + \n\n + "-- \n" + signature
const parts = result.split('\n\n');
expect(parts[0]).toBe('body text');
expect(parts[1]).toBe('-- \nsig');
});
it('should handle body that already ends with newlines', () => {
const result = appendSignature('body\n\n', 'sig');
// Still adds the separator - this matches the composer behavior
expect(result).toBe('body\n\n\n\n-- \nsig');
});
});
+86 -2
View File
@@ -2,7 +2,8 @@ import fs from 'fs';
import path from 'path';
import { describe, expect, it } from 'vitest';
const localesDir = path.resolve(__dirname, '../../locales');
const rootDir = path.resolve(__dirname, '../..');
const localesDir = path.join(rootDir, 'locales');
const referenceLocale = 'en';
function getLeafKeys(obj: Record<string, unknown>, prefix = ''): string[] {
@@ -24,11 +25,74 @@ function loadLocale(locale: string): Record<string, unknown> {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
function resolveKey(obj: Record<string, unknown>, key: string): unknown {
return key.split('.').reduce<unknown>((o, p) => (o && typeof o === 'object' ? (o as Record<string, unknown>)[p] : undefined), obj);
}
// Collect source files recursively
function getSourceFiles(dir: string): string[] {
const results: string[] = [];
let entries: fs.Dirent[];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules' && entry.name !== '__tests__') {
results.push(...getSourceFiles(fullPath));
} else if (entry.isFile() && /\.(tsx?|jsx?)$/.test(entry.name)) {
results.push(fullPath);
}
}
return results;
}
/**
* Extract translation keys from source, respecting which variable maps to which namespace.
* Handles multiple useTranslations calls per file (even reusing the same variable name
* in different functions) by finding, for each t("key") call, the nearest preceding
* useTranslations assignment to that variable.
*/
function extractUsedKeys(filePath: string): string[] {
const content = fs.readFileSync(filePath, 'utf-8');
const keys: string[] = [];
// Collect all variable→namespace assignments with their positions
const assignRegex = /const\s+(\w+)\s*=\s*useTranslations\(\s*["']([^"']*)["']\s*\)/g;
const assignments: { varName: string; namespace: string; index: number }[] = [];
let m: RegExpExecArray | null;
while ((m = assignRegex.exec(content)) !== null) {
assignments.push({ varName: m[1], namespace: m[2], index: m.index });
}
if (assignments.length === 0) return keys;
// Get unique variable names
const varNames = [...new Set(assignments.map((a) => a.varName))];
// For each variable, find its t("key") calls and resolve namespace by position
for (const varName of varNames) {
const varAssignments = assignments.filter((a) => a.varName === varName);
const callRegex = new RegExp(`\\b${varName}\\(\\s*["']([^"'{}]+)["']`, 'g');
while ((m = callRegex.exec(content)) !== null) {
const key = m[1];
if (key.startsWith('.')) continue;
// Find the nearest preceding assignment for this variable
const ns = varAssignments
.filter((a) => a.index < m!.index)
.sort((a, b) => b.index - a.index)[0]?.namespace;
if (ns === undefined) continue;
keys.push(ns ? `${ns}.${key}` : key);
}
}
return [...new Set(keys)];
}
const locales = fs
.readdirSync(localesDir)
.filter((entry) => fs.statSync(path.join(localesDir, entry)).isDirectory());
const referenceKeys = getLeafKeys(loadLocale(referenceLocale));
const referenceData = loadLocale(referenceLocale);
const referenceKeys = getLeafKeys(referenceData);
describe('translations completeness', () => {
it('reference locale (en) should have keys', () => {
@@ -52,3 +116,23 @@ describe('translations completeness', () => {
expect(extra, `Extra ${extra.length} keys in "${locale}":\n${extra.join('\n')}`).toEqual([]);
});
});
describe('translations used in source code exist in en locale', () => {
const srcDirs = ['components', 'app', 'hooks', 'lib', 'stores', 'contexts'].map((d) => path.join(rootDir, d));
const allFiles = srcDirs.flatMap((d) => getSourceFiles(d));
const usedKeys = new Set<string>();
for (const f of allFiles) {
for (const k of extractUsedKeys(f)) {
usedKeys.add(k);
}
}
it('all translation keys referenced in source should exist in en locale', () => {
const missing = [...usedKeys].sort().filter((key) => resolveKey(referenceData, key) === undefined);
expect(
missing,
`${missing.length} translation key(s) used in source code but missing from en locale:\n${missing.join('\n')}`,
).toEqual([]);
});
});
+373 -39
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode, FileNodeFilter } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import { toWildcardQuery } from "./search-utils";
@@ -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;
@@ -800,6 +808,34 @@ export class JMAPClient {
}
}
async emptyMailbox(mailboxId: string): Promise<number> {
let totalDestroyed = 0;
let hasMore = true;
while (hasMore) {
const response = await this.request([
["Email/query", {
accountId: this.accountId,
filter: { inMailbox: mailboxId },
limit: 500,
}, "0"],
["Email/set", {
accountId: this.accountId,
"#destroy": { resultOf: "0", name: "Email/query", path: "/ids" },
}, "1"],
]);
const queryResult = response.methodResponses?.[0]?.[1];
const setResult = response.methodResponses?.[1]?.[1];
const destroyed = setResult?.destroyed?.length || 0;
totalDestroyed += destroyed;
hasMore = destroyed > 0 && (queryResult?.total || 0) > destroyed;
}
return totalDestroyed;
}
async markAsSpam(emailId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId;
@@ -1343,7 +1379,8 @@ export class JMAPClient {
identityId?: string,
fromEmail?: string,
draftId?: string,
fromName?: string
fromName?: string,
htmlBody?: string
): Promise<void> {
const emailId = draftId || `draft-${Date.now()}`;
const mailboxes = await this.getMailboxes();
@@ -1386,21 +1423,33 @@ export class JMAPClient {
create: { "1": { emailId: draftId, identityId: finalIdentityId } },
}, "1"]);
} else {
// Build email body parts - include HTML if available
const emailCreate: Record<string, unknown> = {
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })),
subject,
keywords: { "$seen": true },
mailboxIds: { [sentMailbox.id]: true },
};
if (htmlBody) {
// Send as multipart/alternative with both text and HTML
emailCreate.bodyValues = {
"text": { value: body },
"html": { value: htmlBody },
};
emailCreate.textBody = [{ partId: "text" }];
emailCreate.htmlBody = [{ partId: "html" }];
} else {
emailCreate.bodyValues = { "1": { value: body } };
emailCreate.textBody = [{ partId: "1" }];
}
methodCalls.push(["Email/set", {
accountId: this.accountId,
create: {
[emailId]: {
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })),
subject,
keywords: { "$seen": true },
mailboxIds: { [sentMailbox.id]: true },
bodyValues: { "1": { value: body } },
textBody: [{ partId: "1" }],
},
},
create: { [emailId]: emailCreate },
}, "0"]);
methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId,
@@ -2052,7 +2101,8 @@ export class JMAPClient {
const response = await this.request([
["Calendar/set", {
accountId,
destroy: [calendarId]
destroy: [calendarId],
onDestroyRemoveEvents: true
}, "0"]
], this.calendarUsing());
@@ -2070,30 +2120,31 @@ export class JMAPClient {
}
async getCalendarEvents(calendarIds?: string[]): Promise<CalendarEvent[]> {
try {
const accountId = this.getCalendarsAccountId();
const accountId = this.getCalendarsAccountId();
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
if (calendarIds && calendarIds.length > 0) {
queryArgs.filter = { inCalendars: calendarIds };
}
const response = await this.request([
["CalendarEvent/query", queryArgs, "0"],
["CalendarEvent/get", {
accountId,
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
}, "1"]
], this.calendarUsing());
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
return (response.methodResponses[1][1].list || []) as CalendarEvent[];
}
return [];
} catch (error) {
console.error('Failed to get calendar events:', error);
return [];
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
if (calendarIds && calendarIds.length > 0) {
queryArgs.filter = { inCalendars: calendarIds };
}
const response = await this.request([
["CalendarEvent/query", queryArgs, "0"],
["CalendarEvent/get", {
accountId,
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
}, "1"]
], this.calendarUsing());
// Check for JMAP method-level errors
if (response.methodResponses?.[0]?.[0] === "error") {
const error = response.methodResponses[0][1];
throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
}
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
return (response.methodResponses[1][1].list || []) as CalendarEvent[];
}
return [];
}
async queryCalendarEvents(
@@ -2107,7 +2158,7 @@ export class JMAPClient {
const queryArgs: Record<string, unknown> = {
accountId,
filter,
limit: limit || 100,
limit: limit || 1000,
};
if (sort) {
queryArgs.sort = sort;
@@ -2279,6 +2330,289 @@ export class JMAPClient {
throw new Error("Failed to delete calendar event");
}
async batchDeleteCalendarEvents(eventIds: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
if (eventIds.length === 0) return { destroyed: [], notDestroyed: [] };
const accountId = this.getCalendarsAccountId();
const response = await this.request([
["CalendarEvent/set", { accountId, destroy: eventIds }, "0"]
], this.calendarUsing());
const destroyed: string[] = [];
const notDestroyed: string[] = [];
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1];
if (result.destroyed) destroyed.push(...result.destroyed);
if (result.notDestroyed) notDestroyed.push(...Object.keys(result.notDestroyed));
}
return { destroyed, notDestroyed };
}
// ─── JMAP FileNode methods (draft-ietf-jmap-filenode) ───
supportsFiles(): boolean {
return this.hasCapability("urn:ietf:params:jmap:filenode");
}
async probeFileNodeSupport(): Promise<boolean> {
// Some servers support FileNode without advertising a specific capability.
// Try a minimal FileNode/query to detect support at runtime.
if (this.supportsFiles()) return true;
if (!this.apiUrl) return false;
try {
const accountId = this.getFilesAccountId();
const response = await this.authenticatedFetch(this.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
using: ["urn:ietf:params:jmap:core"],
methodCalls: [["FileNode/query", { accountId, filter: {}, limit: 1 }, "probe0"]],
}),
});
if (!response.ok) return false;
const data = await response.json();
const result = data.methodResponses?.[0];
return result && result[0] === "FileNode/query";
} catch {
return false;
}
}
getFilesAccountId(): string {
const filesAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:filenode"];
return filesAccount || this.accountId;
}
private fileUsing(): string[] {
const using = ["urn:ietf:params:jmap:core"];
if (this.hasCapability("urn:ietf:params:jmap:filenode")) {
using.push("urn:ietf:params:jmap:filenode");
}
return using;
}
private static FILE_NODE_PROPERTIES = ["id", "parentId", "name", "type", "blobId", "size", "created", "updated"];
async getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]> {
const accountId = this.getFilesAccountId();
const args: Record<string, unknown> = { accountId, ids, properties: properties || JMAPClient.FILE_NODE_PROPERTIES };
const response = await this.request(
[["FileNode/get", args, "fn0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/get failed");
}
return (result[1].list || []) as FileNode[];
}
async queryFileNodes(filter: FileNodeFilter, sort?: { property: string; isAscending: boolean }[]): Promise<string[]> {
const accountId = this.getFilesAccountId();
const args: Record<string, unknown> = { accountId, filter };
if (sort) args.sort = sort;
const response = await this.request(
[["FileNode/query", args, "fnq0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/query failed");
}
return (result[1].ids || []) as string[];
}
async listFileNodes(parentId: string | null): Promise<FileNode[]> {
const accountId = this.getFilesAccountId();
const filter: Record<string, unknown> = {};
if (parentId !== null) {
filter.parentId = parentId;
}
// When parentId is null (root level), use empty filter to get all nodes.
// Stalwart's FileNode/query does not support parentId: null as a filter value.
const response = await this.request(
[
["FileNode/query", { accountId, filter }, "fnq0"],
["FileNode/get", { accountId, "#ids": { resultOf: "fnq0", name: "FileNode/query", path: "/ids" }, properties: JMAPClient.FILE_NODE_PROPERTIES }, "fng0"],
],
this.fileUsing(),
);
// Check if query failed first
const queryResult = response.methodResponses?.find(r => r[0] === "FileNode/query" || (r[0] === "error" && r[2] === "fnq0"));
if (queryResult && queryResult[0] === "error") {
console.error('[Files] FileNode/query error:', queryResult[1]);
throw new Error(queryResult[1]?.description || "FileNode/query failed");
}
const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get" || (r[0] === "error" && r[2] === "fnq0"));
if (!getResult) {
console.error('[Files] No FileNode/get response. Full response:', JSON.stringify(response.methodResponses));
throw new Error("FileNode list failed - no response");
}
if (getResult[0] === "error") {
console.error('[Files] FileNode/get error:', getResult[1]);
throw new Error(getResult[1]?.description || "FileNode list failed");
}
const nodes = (getResult[1].list || []) as FileNode[];
// When listing root, filter client-side to only show root-level items
if (parentId === null) {
return nodes.filter(n => n.parentId === null);
}
return nodes;
}
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
const accountId = this.getFilesAccountId();
// Stalwart requires a blobId even for directories — upload an empty blob
const emptyBlob = new File([], name, { type: 'application/x-directory' });
const { blobId } = await this.uploadBlob(emptyBlob);
const dirProps: Record<string, unknown> = { name, type: "d", blobId, size: 0 };
if (parentId !== null) {
dirProps.parentId = parentId;
}
const response = await this.request(
[["FileNode/set", {
accountId,
create: {
dir0: dirProps,
},
}, "fns0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set create failed");
}
const created = result[1].created?.dir0;
if (!created) {
const err = result[1].notCreated?.dir0;
throw new Error(err?.description || "Failed to create directory");
}
return created as FileNode;
}
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode> {
const accountId = this.getFilesAccountId();
const fileProps: Record<string, unknown> = { name, type, blobId, size };
if (parentId !== null) {
fileProps.parentId = parentId;
}
const response = await this.request(
[["FileNode/set", {
accountId,
create: {
file0: fileProps,
},
}, "fns0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set create failed");
}
const created = result[1].created?.file0;
if (!created) {
const err = result[1].notCreated?.file0;
throw new Error(err?.description || "Failed to create file node");
}
return created as FileNode;
}
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void> {
const accountId = this.getFilesAccountId();
const response = await this.request(
[["FileNode/set", {
accountId,
update: { [id]: updates },
}, "fns0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set update failed");
}
if (result[1].notUpdated?.[id]) {
throw new Error(result[1].notUpdated[id].description || "Failed to update file node");
}
}
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
const accountId = this.getFilesAccountId();
const response = await this.request(
[["FileNode/set", {
accountId,
destroy: ids,
}, "fns0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set destroy failed");
}
return {
destroyed: result[1].destroyed || [],
notDestroyed: result[1].notDestroyed ? Object.keys(result[1].notDestroyed) : [],
};
}
async copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode> {
// Copy: get original, upload blob reference, create new node
const nodes = await this.getFileNodes([id]);
if (nodes.length === 0) throw new Error('File node not found');
const original = nodes[0];
const accountId = this.getFilesAccountId();
const createProps: Record<string, unknown> = {
name: newName,
type: original.type,
blobId: original.blobId,
size: original.size,
};
if (parentId !== null) {
createProps.parentId = parentId;
}
const response = await this.request(
[["FileNode/set", {
accountId,
create: {
copy0: createProps,
},
}, "fns0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode copy failed");
}
const created = result[1].created?.copy0;
if (!created) {
const err = result[1].notCreated?.copy0;
throw new Error(err?.description || "Failed to copy file node");
}
return created as FileNode;
}
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
const url = this.getBlobDownloadUrl(blobId, name, type);
const response = await this.authenticatedFetch(url, {});
+19
View File
@@ -618,4 +618,23 @@ export interface AccountStates {
Mailbox?: string;
Thread?: string;
};
}
// JMAP FileNode types (draft-ietf-jmap-filenode / Stalwart implementation)
export interface FileNode {
id: string;
parentId: string | null;
name: string;
type: string; // "d" for directory, MIME type for files
blobId: string | null;
size: number;
created: string;
updated: string;
}
export interface FileNodeFilter {
parentId?: string | null;
name?: string;
type?: string;
}
+16 -3
View File
@@ -117,6 +117,16 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
});
});
// Helper to recursively recalculate depths after tree is built
const recalculateDepths = (nodes: MailboxNode[], baseDepth: number) => {
for (const node of nodes) {
node.depth = baseDepth;
if (node.children.length > 0) {
recalculateDepths(node.children, baseDepth + 1);
}
}
};
// Second pass: build tree structure for own mailboxes
ownMailboxes.forEach(mailbox => {
const node = mailboxMap.get(mailbox.id)!;
@@ -124,14 +134,15 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
if (mailbox.parentId && mailboxMap.has(mailbox.parentId)) {
const parent = mailboxMap.get(mailbox.parentId)!;
parent.children.push(node);
node.depth = parent.depth + 1;
} else {
// Root level mailbox or orphaned mailbox
rootMailboxes.push(node);
node.depth = 0;
}
});
// Third pass: correctly calculate depths from the root down
recalculateDepths(rootMailboxes, 0);
// If we have shared mailboxes, create a virtual "Shared Folders" parent
if (sharedMailboxes.length > 0) {
// Group shared mailboxes by account
@@ -168,12 +179,14 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
if (mailbox.parentId && accountMailboxMap.has(mailbox.parentId)) {
const parent = accountMailboxMap.get(mailbox.parentId)!;
parent.children.push(node);
node.depth = parent.depth + 1;
} else {
accountRootNodes.push(node);
}
});
// Correctly calculate depths from account root level down
recalculateDepths(accountRootNodes, 2);
// Create virtual account folder node
const accountName = accountMailboxes[0]?.accountName || accountId;
const accountNode: MailboxNode = {
+294
View File
@@ -0,0 +1,294 @@
/**
* WebDAV client that proxies through /api/webdav to avoid CORS issues.
* The server-side proxy handles auth and forwards requests to Stalwart's /dav/file/ endpoint.
*/
export interface WebDAVResource {
href: string;
name: string;
isDirectory: boolean;
contentType: string;
contentLength: number;
lastModified: string;
etag: string;
}
export class WebDAVClient {
private proxyUrl = '/api/webdav';
/**
* Send a WebDAV request through the proxy.
*/
private async request(method: string, path: string, options?: {
headers?: Record<string, string>;
body?: string | ArrayBuffer | Blob;
}): Promise<Response> {
const headers: Record<string, string> = {
'X-WebDAV-Method': method,
'X-WebDAV-Path': path,
...options?.headers,
};
return fetch(this.proxyUrl, {
method: 'POST',
headers,
body: options?.body,
});
}
/**
* Check if WebDAV is available by sending a PROPFIND to the root.
*/
async checkSupport(): Promise<boolean> {
try {
const response = await this.request('PROPFIND', '/', {
headers: {
'Depth': '0',
'Content-Type': 'application/xml; charset=utf-8',
},
body: `<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:resourcetype/>
</D:prop>
</D:propfind>`,
});
return response.status === 207;
} catch {
return false;
}
}
/**
* List contents of a directory via PROPFIND with Depth: 1
*/
async list(path: string = '/'): Promise<WebDAVResource[]> {
const response = await this.request('PROPFIND', path, {
headers: {
'Depth': '1',
'Content-Type': 'application/xml; charset=utf-8',
},
body: `<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:resourcetype/>
<D:getcontenttype/>
<D:getcontentlength/>
<D:getlastmodified/>
<D:getetag/>
<D:displayname/>
</D:prop>
</D:propfind>`,
});
if (response.status !== 207) {
throw new Error(`PROPFIND failed: ${response.status} ${response.statusText}`);
}
const text = await response.text();
const requestUri = response.headers.get('X-WebDAV-Request-URI') || '';
return this.parseMultistatus(text, requestUri);
}
/**
* Create a new directory
*/
async createDirectory(path: string): Promise<void> {
const response = await this.request('MKCOL', path);
if (response.status !== 201 && response.status !== 204) {
throw new Error(`MKCOL failed: ${response.status} ${response.statusText}`);
}
}
/**
* Upload a file with optional progress tracking
*/
async uploadFile(
path: string,
file: File | Blob,
contentType?: string,
onProgress?: (loaded: number, total: number) => void,
signal?: AbortSignal,
): Promise<void> {
if (onProgress) {
// Use XMLHttpRequest for progress tracking
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', this.proxyUrl);
xhr.setRequestHeader('X-WebDAV-Method', 'PUT');
xhr.setRequestHeader('X-WebDAV-Path', path);
xhr.setRequestHeader('Content-Type',
contentType || (file instanceof File ? file.type : 'application/octet-stream'));
if (signal) {
signal.addEventListener('abort', () => {
xhr.abort();
reject(new DOMException('Upload aborted', 'AbortError'));
});
}
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(e.loaded, e.total);
};
xhr.onload = () => {
if (xhr.status === 200 || xhr.status === 201 || xhr.status === 204) {
resolve();
} else {
reject(new Error(`PUT failed: ${xhr.status} ${xhr.statusText}`));
}
};
xhr.onerror = () => reject(new Error('Upload failed'));
xhr.send(file);
});
}
const response = await this.request('PUT', path, {
headers: {
'Content-Type': contentType || (file instanceof File ? file.type : 'application/octet-stream'),
},
body: file,
});
if (response.status !== 201 && response.status !== 204 && response.status !== 200) {
throw new Error(`PUT failed: ${response.status} ${response.statusText}`);
}
}
/**
* Download a file
*/
async downloadFile(path: string): Promise<{ blob: Blob; contentType: string; filename: string }> {
const response = await this.request('GET', path);
if (!response.ok) {
throw new Error(`GET failed: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
const contentType = response.headers.get('Content-Type') || 'application/octet-stream';
const filename = path.split('/').pop() || 'download';
return { blob, contentType, filename };
}
/**
* Delete a file or directory
*/
async delete(path: string): Promise<void> {
const response = await this.request('DELETE', path);
if (response.status !== 204 && response.status !== 200) {
throw new Error(`DELETE failed: ${response.status} ${response.statusText}`);
}
}
/**
* Move/rename a resource
*/
async move(fromPath: string, toPath: string): Promise<void> {
const response = await this.request('MOVE', fromPath, {
headers: {
'X-WebDAV-Destination': toPath,
'Overwrite': 'F',
},
});
if (response.status !== 201 && response.status !== 204) {
throw new Error(`MOVE failed: ${response.status} ${response.statusText}`);
}
}
/**
* Copy a resource
*/
async copy(fromPath: string, toPath: string): Promise<void> {
const response = await this.request('COPY', fromPath, {
headers: {
'X-WebDAV-Destination': toPath,
'Overwrite': 'F',
},
});
if (response.status !== 201 && response.status !== 204) {
throw new Error(`COPY failed: ${response.status} ${response.statusText}`);
}
}
/**
* Parse a WebDAV multistatus XML response into WebDAVResource[]
*/
private parseMultistatus(xml: string, requestUrl: string): WebDAVResource[] {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'application/xml');
const responses = doc.getElementsByTagNameNS('DAV:', 'response');
const resources: WebDAVResource[] = [];
// Normalize the request URL for comparison (skip the "self" entry)
const normalizedRequestUrl = requestUrl.replace(/\/+$/, '');
for (let i = 0; i < responses.length; i++) {
const resp = responses[i];
const hrefEl = resp.getElementsByTagNameNS('DAV:', 'href')[0];
if (!hrefEl?.textContent) continue;
const href = decodeURIComponent(hrefEl.textContent);
// Skip the directory itself (the parent being listed)
const normalizedHref = href.replace(/\/+$/, '');
if (this.isSameResource(normalizedHref, normalizedRequestUrl)) continue;
const propstat = resp.getElementsByTagNameNS('DAV:', 'propstat')[0];
if (!propstat) continue;
const statusEl = propstat.getElementsByTagNameNS('DAV:', 'status')[0];
if (statusEl?.textContent && !statusEl.textContent.includes('200')) continue;
const prop = propstat.getElementsByTagNameNS('DAV:', 'prop')[0];
if (!prop) continue;
const resourceType = prop.getElementsByTagNameNS('DAV:', 'resourcetype')[0];
const isDirectory = !!resourceType?.getElementsByTagNameNS('DAV:', 'collection')[0];
const displayName = prop.getElementsByTagNameNS('DAV:', 'displayname')[0]?.textContent || '';
const contentType = prop.getElementsByTagNameNS('DAV:', 'getcontenttype')[0]?.textContent || '';
const contentLengthStr = prop.getElementsByTagNameNS('DAV:', 'getcontentlength')[0]?.textContent || '0';
const lastModified = prop.getElementsByTagNameNS('DAV:', 'getlastmodified')[0]?.textContent || '';
const etag = prop.getElementsByTagNameNS('DAV:', 'getetag')[0]?.textContent || '';
// Extract the name from the href path
const segments = href.replace(/\/+$/, '').split('/');
const name = displayName || segments[segments.length - 1] || '';
resources.push({
href,
name,
isDirectory,
contentType: isDirectory ? '' : contentType,
contentLength: parseInt(contentLengthStr, 10) || 0,
lastModified,
etag,
});
}
// Sort: directories first, then alphabetically
resources.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
return resources;
}
private isSameResource(href1: string, href2: string): boolean {
// Compare by path only (ignore origin differences)
try {
const path1 = new URL(href1, 'http://dummy').pathname.replace(/\/+$/, '');
const path2 = new URL(href2, 'http://dummy').pathname.replace(/\/+$/, '');
return path1 === path2;
} catch {
return href1 === href2;
}
}
}
+258 -10
View File
@@ -61,6 +61,7 @@
"contacts": "Kontakte",
"calendar": "Kalender",
"settings": "Einstellungen",
"files": "Dateien",
"loading_mailboxes": "Postfächer werden geladen...",
"push_connected": "Echtzeit-Updates aktiv",
"push_disconnected": "Echtzeit-Updates inaktiv",
@@ -129,7 +130,15 @@
"permanent_delete": "Endgültig löschen",
"permanent_delete_confirm_title": "Endgültig löschen",
"permanent_delete_confirm_message": "Diese E-Mail wird endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"permanent_delete_confirm_batch_message": "Diese {count, plural, one {1 E-Mail} other {# E-Mails}} werden endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden."
"permanent_delete_confirm_batch_message": "Diese {count, plural, one {1 E-Mail} other {# E-Mails}} werden endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"empty_folder": {
"button": "Ordner leeren",
"confirm_title": "Ordner leeren",
"confirm_message": "Alle E-Mails in diesem Ordner werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"confirm_button": "Ordner leeren",
"junk_hint": "Sie können den Spam-Ordner leeren, um alle Nachrichten dauerhaft zu entfernen.",
"trash_hint": "Sie können den Papierkorb leeren, um alle Nachrichten dauerhaft zu entfernen."
}
},
"email_viewer": {
"no_email_selected": "Keine E-Mail ausgewählt",
@@ -175,6 +184,7 @@
"set_color": "Label setzen",
"tag": "Label",
"more_actions": "Weitere Aktionen",
"move_to": "Verschieben nach...",
"remove_color": "Label entfernen",
"more_count": "+{count} weitere",
"characters_count": "{count} Zeichen",
@@ -250,7 +260,9 @@
"delete": "Löschen",
"star": "Markieren (s)",
"unstar": "Markierung entfernen (s)",
"compose": "Verfassen (c)"
"compose": "Verfassen (c)",
"previous": "Vorherige E-Mail",
"next": "Nächste E-Mail"
},
"spam": {
"button_title": "Spam melden",
@@ -290,7 +302,10 @@
"no_calendar": "Kalender nicht verfügbar",
"select_calendar": "Kalender auswählen",
"already_in_calendar": "Bereits in deinem Kalender"
}
},
"previous": "Zurück",
"next": "Weiter",
"send": "Senden"
},
"email_composer": {
"new_message": "Neue Nachricht",
@@ -352,7 +367,9 @@
"continue_draft": "Entwurf fortsetzen",
"close_draft_title": "Entwurf speichern oder verwerfen?",
"close_draft_message": "Sie haben ungespeicherte Änderungen. Möchten Sie diese als Entwurf speichern oder verwerfen?",
"save_draft": "Entwurf speichern"
"save_draft": "Entwurf speichern",
"drop_files": "Dateien zum Anhängen ablegen",
"show_less": "Weniger anzeigen"
},
"confirm_dialog": {
"confirm": "Bestätigen",
@@ -375,7 +392,8 @@
"yes": "Ja",
"no": "Nein",
"unknown": "Unbekannt",
"app_title": "Webmail"
"app_title": "Webmail",
"reconnecting": "Verbindung verloren. Verbindung wird wiederhergestellt…"
},
"notifications": {
"email_sent": "E-Mail erfolgreich gesendet",
@@ -481,12 +499,15 @@
"templates": "Vorlagen",
"folders": "Ordner",
"keywords": "Schlüsselwörter",
"security": "Sicherheit"
"security": "Sicherheit",
"files": "Dateien"
},
"tab_groups": {
"general": "Allgemein",
"account": "Konto",
"organization": "Organisation"
"account": "Konto & Identität",
"organization": "E-Mail-Organisation",
"apps": "Apps",
"system": "System"
},
"appearance": {
"title": "Darstellung",
@@ -592,6 +613,10 @@
"permanent": "Dauerhaft löschen",
"warning": "E-Mails werden dauerhaft gelöscht und können nicht wiederhergestellt werden. Diese Aktion ist unwiderruflich."
},
"permanently_delete_junk": {
"label": "Spam dauerhaft löschen",
"description": "E-Mails aus dem Spam-Ordner dauerhaft löschen, anstatt sie in den Papierkorb zu verschieben"
},
"show_preview": {
"label": "Vorschautext anzeigen",
"description": "E-Mail-Vorschau in der Liste anzeigen"
@@ -1041,6 +1066,58 @@
"empty": "Vorlagenname ist erforderlich",
"too_long": "Vorlagenname darf maximal 200 Zeichen haben"
}
},
"files": {
"display": {
"title": "Anzeige",
"description": "Konfigurieren Sie die Anzeige von Dateien und Ordnern"
},
"default_view": {
"label": "Standardansicht",
"description": "Wählen Sie zwischen Raster- und Listenansicht",
"list": "Liste",
"grid": "Raster"
},
"default_sort": {
"label": "Standardsortierung",
"description": "Wählen Sie die Standardsortierung für Dateien",
"name": "Name",
"size": "Größe",
"modified": "Geändert"
},
"sort_direction": {
"label": "Sortierrichtung",
"description": "Wählen Sie auf- oder absteigende Reihenfolge",
"ascending": "Aufsteigend",
"descending": "Absteigend"
},
"icons": {
"title": "Symbole",
"description": "Darstellung der Dateisymbole konfigurieren"
},
"show_icons": {
"label": "Dateisymbole anzeigen",
"description": "Symbole neben Dateien und Ordnern anzeigen"
},
"colored_icons": {
"label": "Farbige Symbole",
"description": "Farbige statt einfarbige Symbole verwenden"
},
"show_thumbnails": {
"label": "Vorschaubilder anzeigen",
"description": "Bildvorschauen statt Symbole für Bilddateien anzeigen"
},
"behavior": {
"title": "Verhalten",
"description": "Verhalten des Dateibrowsers konfigurieren"
},
"show_hidden": {
"label": "Versteckte Dateien anzeigen",
"description": "Dateien und Ordner anzeigen, die mit einem Punkt beginnen"
},
"preview": {
"label": "Vorschau"
}
}
},
"errors": {
@@ -1194,7 +1271,9 @@
"identity_name": "Gesendet mit Identität: {name}",
"identity_short": "über {name}",
"subaddress_tag": "+{tag}"
}
},
"delete_button": "Löschen",
"delete_confirm_title": "Identität löschen"
},
"templates": {
"picker_title": "Vorlage wählen",
@@ -1230,6 +1309,7 @@
"empty_search_hint": "Versuchen Sie einen anderen Suchbegriff",
"clear_search": "Suche löschen",
"import_vcard": "vCard importieren",
"delete_confirm_title": "Kontakt löschen",
"delete_confirm": "Möchten Sie diesen Kontakt wirklich löschen?",
"local_mode": "Kontakte werden lokal gespeichert (Server unterstützt kein JMAP Contacts)",
"back_to_mail": "Zurück zur E-Mail",
@@ -1370,12 +1450,14 @@
"name_required": "Mindestens ein Vor- oder Nachname ist erforderlich",
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"email_error_inline": "Ungültiges E-Mail-Format",
"save_failed": "Kontakt konnte nicht gespeichert werden"
"save_failed": "Kontakt konnte nicht gespeichert werden",
"delete": "Löschen"
},
"groups": {
"create": "Neue Gruppe",
"edit": "Gruppe bearbeiten",
"empty": "Keine Gruppen",
"delete_confirm_title": "Gruppe löschen",
"delete_confirm": "Möchten Sie diese Gruppe wirklich löschen?",
"name_label": "Gruppenname",
"name_placeholder": "z.B. Team, Familie",
@@ -1412,6 +1494,7 @@
"selected": "{count, plural, one {1 ausgewählt} other {# ausgewählt}}",
"select_all": "Alle auswählen",
"delete": "Löschen",
"delete_confirm_title": "Kontakte löschen",
"delete_confirm": "{count, plural, one {1 Kontakt} other {# Kontakte}} löschen?",
"deleted": "{count, plural, one {1 Kontakt gelöscht} other {# Kontakte gelöscht}}",
"add_to_group": "Zur Gruppe hinzufügen",
@@ -1598,9 +1681,17 @@
"nav_next": "Weiter",
"import": {
"title": "Kalender importieren",
"tab_file": "Datei",
"tab_url": "URL",
"select_file": ".ics-Datei auswählen",
"drop_file": "oder Datei hier ablegen",
"supported_formats": "iCalendar (.ics) Dateien werden unterstützt",
"url_description": "Geben Sie die URL eines externen iCalendar (.ics) Feeds ein, um Termine zu importieren.",
"url_placeholder": "https://example.com/calendar.ics",
"url_hint": "Unterstützt CalDAV und iCalendar (.ics) URLs",
"fetch": "Abrufen",
"invalid_url": "Bitte geben Sie eine gültige URL ein",
"url_fetch_failed": "Kalender konnte nicht von der URL abgerufen werden",
"parsing": "Kalenderdatei wird analysiert...",
"parsed_events": "{count} Termine gefunden",
"no_events": "Keine Termine in der Datei gefunden",
@@ -1613,6 +1704,65 @@
"error": "Kalender konnte nicht importiert werden",
"file_too_large": "Datei überschreitet das 5-MB-Limit",
"invalid_format": "Ungültiges Kalenderdateiformat"
},
"management": {
"title": "Kalenderverwaltung",
"description": "Erstellen, umbenennen und anpassen Ihrer Kalender. Rechtsklick auf einen Kalender in der Seitenleiste, um die Farbe schnell zu ändern.",
"name": "Name",
"name_placeholder": "Kalendername",
"color": "Farbe",
"change_color": "Farbe ändern",
"add_calendar": "Kalender hinzufügen",
"edit": "Bearbeiten",
"delete": "Löschen",
"save": "Speichern",
"create": "Erstellen",
"cancel": "Abbrechen",
"default": "Standard",
"confirm_delete": "\"{name}\" löschen? Alle Termine in diesem Kalender werden entfernt.",
"calendar_created": "Kalender erstellt",
"calendar_updated": "Kalender aktualisiert",
"calendar_deleted": "Kalender gelöscht",
"color_updated": "Kalenderfarbe aktualisiert",
"error_create": "Kalender konnte nicht erstellt werden",
"error_update": "Kalender konnte nicht aktualisiert werden",
"error_delete": "Kalender konnte nicht gelöscht werden",
"caldav_url": "CalDAV-URL",
"copy_url": "CalDAV-URL kopieren",
"url_copied": "CalDAV-URL in die Zwischenablage kopiert",
"confirm_clear": "Alle Ereignisse aus \"{name}\" löschen? Dies kann nicht rückgängig gemacht werden.",
"clear_events": "Ereignisse löschen",
"events_cleared": "{count} Ereignisse gelöscht",
"error_clear": "Kalenderereignisse konnten nicht gelöscht werden"
},
"subscription": {
"title": "iCal-Abonnement",
"section_title": "iCal-Abonnements",
"description": "Abonnieren Sie einen externen iCalendar-Feed. Ereignisse werden automatisch in einen eigenen Kalender synchronisiert. Unterstützt https://- und webcal://-URLs.",
"url_label": "Kalender-URL",
"url_placeholder": "https://example.com/calendar.ics oder webcal://...",
"name_label": "Kalendername",
"name_placeholder": "z.B. Feiertage",
"color_label": "Farbe",
"refresh_interval": "Aktualisierungsintervall",
"interval_15": "Alle 15 Minuten",
"interval_30": "Alle 30 Minuten",
"interval_60": "Jede Stunde",
"interval_360": "Alle 6 Stunden",
"interval_1440": "Jeden Tag",
"subscribe": "Abonnieren",
"subscribing": "Abonniere...",
"invalid_url": "Bitte geben Sie eine gültige URL ein",
"success": "\"{name}\" abonniert",
"error": "Abonnement konnte nicht hinzugefügt werden",
"refresh": "Jetzt aktualisieren",
"refresh_success": "Abonnement aktualisiert",
"refresh_error": "Abonnement konnte nicht aktualisiert werden",
"unsubscribe": "Abbestellen",
"confirm_delete": "\"{name}\" abbestellen? Der Kalender und alle seine Ereignisse werden entfernt.",
"deleted": "Abonnement entfernt",
"delete_error": "Abonnement konnte nicht entfernt werden",
"last_refreshed": "Zuletzt aktualisiert: {time}"
}
},
"advanced_search": {
@@ -1654,5 +1804,103 @@
"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": "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",
"settings_title": "Dateieinstellungen",
"settings_display": "Anzeige",
"settings_default_view": "Standardansicht",
"settings_default_view_desc": "Wählen Sie zwischen Raster- und Listenansicht",
"settings_default_sort": "Standardsortierung",
"settings_default_sort_desc": "Wählen Sie die Standardsortierung für Dateien",
"settings_sort_direction": "Sortierrichtung",
"settings_sort_direction_desc": "Wählen Sie auf- oder absteigende Reihenfolge",
"settings_ascending": "Aufsteigend",
"settings_descending": "Absteigend",
"settings_icons": "Symbole",
"settings_show_icons": "Dateisymbole anzeigen",
"settings_show_icons_desc": "Symbole neben Dateien und Ordnern anzeigen",
"settings_colored_icons": "Farbige Symbole",
"settings_colored_icons_desc": "Farbige statt einfarbige Symbole verwenden",
"settings_show_thumbnails": "Vorschaubilder anzeigen",
"settings_show_thumbnails_desc": "Bildvorschauen statt Symbole für Bilddateien anzeigen",
"settings_behavior": "Verhalten",
"settings_show_hidden": "Versteckte Dateien anzeigen",
"settings_show_hidden_desc": "Dateien und Ordner anzeigen, die mit einem Punkt beginnen"
}
}
+280 -13
View File
@@ -8,7 +8,7 @@
"sign_in": "Sign in",
"signing_in": "Signing in...",
"loading": "Loading...",
"reconnecting": "Connection lost. Attempting to reconnect\u2026",
"reconnecting": "Connection lost. Attempting to reconnect",
"error": {
"invalid_credentials": "Invalid email or password. Please check your credentials and try again.",
"connection_failed": "Unable to reach the server. Check your internet connection and try again.",
@@ -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",
@@ -129,7 +130,15 @@
"permanent_delete": "Delete permanently",
"permanent_delete_confirm_title": "Permanently delete",
"permanent_delete_confirm_message": "This email will be permanently deleted. This action cannot be undone.",
"permanent_delete_confirm_batch_message": "These {count, plural, one {1 email} other {# emails}} will be permanently deleted. This action cannot be undone."
"permanent_delete_confirm_batch_message": "These {count, plural, one {1 email} other {# emails}} will be permanently deleted. This action cannot be undone.",
"empty_folder": {
"button": "Empty folder",
"confirm_title": "Empty folder",
"confirm_message": "All emails in this folder will be permanently deleted. This action cannot be undone.",
"confirm_button": "Empty folder",
"junk_hint": "You can empty the Junk folder to permanently remove all messages.",
"trash_hint": "You can empty the Trash folder to permanently remove all messages."
}
},
"email_viewer": {
"no_email_selected": "No email selected",
@@ -175,6 +184,9 @@
"set_color": "Set tag",
"tag": "Tag",
"more_actions": "More actions",
"previous": "Prev",
"next": "Next",
"move_to": "Move to...",
"remove_color": "Remove tag",
"more_count": "+{count} more",
"characters_count": "{count} characters",
@@ -250,7 +262,9 @@
"delete": "Delete (# or Del)",
"star": "Star (s)",
"unstar": "Unstar (s)",
"compose": "Compose (c)"
"compose": "Compose (c)",
"previous": "Previous email",
"next": "Next email"
},
"spam": {
"button_title": "Report spam",
@@ -290,7 +304,8 @@
"no_calendar": "Calendar not available",
"select_calendar": "Select calendar",
"already_in_calendar": "Already in your calendar"
}
},
"send": "Send"
},
"email_composer": {
"new_message": "New Message",
@@ -348,6 +363,8 @@
"upload_progress": "Uploading {uploaded} / {total}",
"upload_cancel": "Cancel upload",
"upload_failed": "Failed to upload {filename}",
"drop_files": "Drop files to attach",
"show_less": "Show less",
"send_failed": "Failed to send email",
"continue_draft": "Continue draft",
"close_draft_title": "Save or discard draft?",
@@ -375,7 +392,8 @@
"yes": "Yes",
"no": "No",
"unknown": "Unknown",
"app_title": "Webmail"
"app_title": "Webmail",
"reconnecting": "Connection lost. Attempting to reconnect…"
},
"notifications": {
"email_sent": "Email sent successfully",
@@ -481,12 +499,16 @@
"templates": "Templates",
"folders": "Folders",
"keywords": "Keywords",
"security": "Security"
"security": "Security",
"files": "Files",
"contacts": "Contacts"
},
"tab_groups": {
"general": "General",
"account": "Account",
"organization": "Organization"
"account": "Account & Identity",
"organization": "Mail Organization",
"apps": "Apps",
"system": "System"
},
"appearance": {
"title": "Appearance",
@@ -592,16 +614,20 @@
"permanent": "Delete Permanently",
"warning": "Emails will be permanently deleted and cannot be recovered. This action is irreversible."
},
"permanently_delete_junk": {
"label": "Permanently Delete Junk",
"description": "Permanently delete emails from the Junk/Spam folder instead of moving them to Trash"
},
"show_preview": {
"label": "Show Preview Text",
"description": "Display email preview in the list"
},
"emails_per_page": {
"label": "Emails Per Page",
"description": "Number of emails to load at once",
"25": "25 emails",
"50": "50 emails",
"100": "100 emails"
"100": "100 emails",
"label": "Emails Per Page",
"description": "Number of emails to load at once"
},
"external_content": {
"label": "External Content",
@@ -892,6 +918,14 @@
"button": "Import"
}
},
"contacts": {
"title": "Contacts",
"description": "Import and export your contacts",
"import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts",
"export_description": "Export all contacts as a vCard (.vcf) file"
},
"filters": {
"title": "Email Filters",
"description": "Create rules to automatically sort, label, and manage incoming emails",
@@ -1041,6 +1075,64 @@
"empty": "Template name is required",
"too_long": "Template name must be 200 characters or less"
}
},
"files": {
"display": {
"title": "Display",
"description": "Configure how files and folders are displayed"
},
"folder_layout": {
"label": "Folder Navigation",
"description": "Choose how folders are displayed: inline with files or in a sidebar tree",
"inline": "Inline",
"sidebar": "Sidebar"
},
"default_view": {
"label": "Default View",
"description": "Choose between grid and list layout",
"list": "List",
"grid": "Grid"
},
"default_sort": {
"label": "Default Sort",
"description": "Choose the default sorting for files",
"name": "Name",
"size": "Size",
"modified": "Modified"
},
"sort_direction": {
"label": "Sort Direction",
"description": "Choose ascending or descending order",
"ascending": "Ascending",
"descending": "Descending"
},
"icons": {
"title": "Icons",
"description": "Configure file icon appearance"
},
"show_icons": {
"label": "Show File Icons",
"description": "Display icons next to files and folders"
},
"colored_icons": {
"label": "Colored Icons",
"description": "Use colorful icons instead of monochrome"
},
"show_thumbnails": {
"label": "Show Thumbnails",
"description": "Display image previews instead of icons for image files"
},
"behavior": {
"title": "Behavior",
"description": "Configure file browser behavior"
},
"show_hidden": {
"label": "Show Hidden Files",
"description": "Display files and folders that start with a dot"
},
"preview": {
"label": "Preview"
}
}
},
"errors": {
@@ -1194,7 +1286,9 @@
"identity_name": "Sent using identity: {name}",
"identity_short": "via {name}",
"subaddress_tag": "+{tag}"
}
},
"delete_button": "Delete",
"delete_confirm_title": "Delete Identity"
},
"templates": {
"picker_title": "Choose a Template",
@@ -1230,6 +1324,7 @@
"empty_search_hint": "Try a different search term",
"clear_search": "Clear search",
"import_vcard": "Import vCard",
"delete_confirm_title": "Delete contact",
"delete_confirm": "Are you sure you want to delete this contact?",
"local_mode": "Contacts are stored locally (server does not support JMAP Contacts)",
"back_to_mail": "Back to mail",
@@ -1370,12 +1465,14 @@
"name_required": "At least a first name or last name is required",
"email_invalid": "Please enter a valid email address",
"email_error_inline": "Invalid email format",
"save_failed": "Failed to save contact"
"save_failed": "Failed to save contact",
"delete": "Delete"
},
"groups": {
"create": "New Group",
"edit": "Edit Group",
"empty": "No groups yet",
"delete_confirm_title": "Delete group",
"delete_confirm": "Are you sure you want to delete this group?",
"name_label": "Group Name",
"name_placeholder": "e.g., Team, Family",
@@ -1412,6 +1509,7 @@
"selected": "{count, plural, one {1 selected} other {# selected}}",
"select_all": "Select all",
"delete": "Delete",
"delete_confirm_title": "Delete contacts",
"delete_confirm": "Delete {count, plural, one {1 contact} other {# contacts}}?",
"deleted": "{count, plural, one {1 contact deleted} other {# contacts deleted}}",
"add_to_group": "Add to group",
@@ -1598,9 +1696,17 @@
"nav_next": "Next",
"import": {
"title": "Import Calendar",
"tab_file": "File",
"tab_url": "URL",
"select_file": "Select .ics file",
"drop_file": "or drop file here",
"supported_formats": "Supports iCalendar (.ics) files",
"url_description": "Enter the URL of an external iCalendar (.ics) feed to import events.",
"url_placeholder": "https://example.com/calendar.ics",
"url_hint": "Supports CalDAV and iCalendar (.ics) URLs",
"fetch": "Fetch",
"invalid_url": "Please enter a valid URL",
"url_fetch_failed": "Failed to fetch calendar from URL",
"parsing": "Parsing calendar file...",
"parsed_events": "{count} events found",
"no_events": "No events found in file",
@@ -1613,6 +1719,65 @@
"error": "Failed to import calendar",
"file_too_large": "File exceeds 5MB limit",
"invalid_format": "Invalid calendar file format"
},
"management": {
"title": "Calendar Management",
"description": "Create, rename, and customize your calendars. Right-click a calendar in the sidebar to quickly change its color.",
"name": "Name",
"name_placeholder": "Calendar name",
"color": "Color",
"change_color": "Change color",
"add_calendar": "Add calendar",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"create": "Create",
"cancel": "Cancel",
"default": "Default",
"confirm_delete": "Delete \"{name}\"? All events in this calendar will be removed.",
"confirm_clear": "Clear all events from \"{name}\"? This cannot be undone.",
"clear_events": "Clear events",
"events_cleared": "{count} events cleared",
"error_clear": "Failed to clear calendar events",
"calendar_created": "Calendar created",
"calendar_updated": "Calendar updated",
"calendar_deleted": "Calendar deleted",
"color_updated": "Calendar color updated",
"error_create": "Failed to create calendar",
"error_update": "Failed to update calendar",
"error_delete": "Failed to delete calendar",
"caldav_url": "CalDAV URL",
"copy_url": "Copy CalDAV URL",
"url_copied": "CalDAV URL copied to clipboard"
},
"subscription": {
"title": "iCal Subscription",
"section_title": "iCal Subscriptions",
"description": "Subscribe to an external iCalendar feed. Events will be synced automatically into their own calendar. Supports https:// and webcal:// URLs.",
"url_label": "Calendar URL",
"url_placeholder": "https://example.com/calendar.ics or webcal://...",
"name_label": "Calendar name",
"name_placeholder": "e.g. Public Holidays",
"color_label": "Color",
"refresh_interval": "Refresh interval",
"interval_15": "Every 15 minutes",
"interval_30": "Every 30 minutes",
"interval_60": "Every hour",
"interval_360": "Every 6 hours",
"interval_1440": "Every day",
"subscribe": "Subscribe",
"subscribing": "Subscribing...",
"invalid_url": "Please enter a valid URL",
"success": "Subscribed to \"{name}\"",
"error": "Failed to add subscription",
"refresh": "Refresh now",
"refresh_success": "Subscription refreshed",
"refresh_error": "Failed to refresh subscription",
"unsubscribe": "Unsubscribe",
"confirm_delete": "Unsubscribe from \"{name}\"? The calendar and all its events will be removed.",
"deleted": "Subscription removed",
"delete_error": "Failed to remove subscription",
"last_refreshed": "Last updated: {time}"
}
},
"advanced_search": {
@@ -1654,5 +1819,107 @@
"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": "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",
"settings_title": "File Settings",
"settings_display": "Display",
"settings_default_view": "Default View",
"settings_default_view_desc": "Choose between grid and list layout",
"settings_default_sort": "Default Sort",
"settings_default_sort_desc": "Choose the default sorting for files",
"settings_sort_direction": "Sort Direction",
"settings_sort_direction_desc": "Choose ascending or descending order",
"settings_ascending": "Ascending",
"settings_descending": "Descending",
"settings_icons": "Icons",
"settings_show_icons": "Show File Icons",
"settings_show_icons_desc": "Display icons next to files and folders",
"settings_colored_icons": "Colored Icons",
"settings_colored_icons_desc": "Use colorful icons instead of monochrome",
"settings_show_thumbnails": "Show Thumbnails",
"settings_show_thumbnails_desc": "Display image previews instead of icons for image files",
"settings_behavior": "Behavior",
"settings_show_hidden": "Show Hidden Files",
"settings_show_hidden_desc": "Display files and folders that start with a dot",
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
"settings_folder_layout_sidebar": "Sidebar"
}
}
+258 -10
View File
@@ -61,6 +61,7 @@
"contacts": "Contactos",
"calendar": "Calendario",
"settings": "Configuración",
"files": "Archivos",
"loading_mailboxes": "Cargando buzones...",
"push_connected": "Actualizaciones en tiempo real activas",
"push_disconnected": "Actualizaciones en tiempo real inactivas",
@@ -129,7 +130,15 @@
"permanent_delete": "Eliminar permanentemente",
"permanent_delete_confirm_title": "Eliminar permanentemente",
"permanent_delete_confirm_message": "Este correo se eliminará permanentemente. Esta acción no se puede deshacer.",
"permanent_delete_confirm_batch_message": "Estos {count, plural, one {1 correo} other {# correos}} se eliminarán permanentemente. Esta acción no se puede deshacer."
"permanent_delete_confirm_batch_message": "Estos {count, plural, one {1 correo} other {# correos}} se eliminarán permanentemente. Esta acción no se puede deshacer.",
"empty_folder": {
"button": "Vaciar carpeta",
"confirm_title": "Vaciar carpeta",
"confirm_message": "Todos los correos de esta carpeta se eliminarán permanentemente. Esta acción no se puede deshacer.",
"confirm_button": "Vaciar carpeta",
"junk_hint": "Puede vaciar la carpeta de Spam para eliminar permanentemente todos los mensajes.",
"trash_hint": "Puede vaciar la Papelera para eliminar permanentemente todos los mensajes."
}
},
"email_viewer": {
"no_email_selected": "Ningún correo seleccionado",
@@ -175,6 +184,7 @@
"set_color": "Establecer etiqueta",
"tag": "Etiqueta",
"more_actions": "Más acciones",
"move_to": "Mover a...",
"remove_color": "Eliminar etiqueta",
"more_count": "+{count} más",
"characters_count": "{count} caracteres",
@@ -250,7 +260,9 @@
"delete": "Eliminar",
"star": "Destacar (s)",
"unstar": "Quitar estrella (s)",
"compose": "Redactar (c)"
"compose": "Redactar (c)",
"previous": "Correo anterior",
"next": "Correo siguiente"
},
"spam": {
"button_title": "Reportar spam",
@@ -290,7 +302,10 @@
"no_calendar": "Calendario no disponible",
"select_calendar": "Seleccionar calendario",
"already_in_calendar": "Ya está en tu calendario"
}
},
"previous": "Anterior",
"next": "Siguiente",
"send": "Enviar"
},
"email_composer": {
"new_message": "Nuevo Mensaje",
@@ -352,7 +367,9 @@
"continue_draft": "Continuar borrador",
"close_draft_title": "¿Guardar o descartar borrador?",
"close_draft_message": "Tiene cambios sin guardar. ¿Desea guardar esto como borrador o descartarlo?",
"save_draft": "Guardar borrador"
"save_draft": "Guardar borrador",
"drop_files": "Suelta archivos para adjuntar",
"show_less": "Mostrar menos"
},
"confirm_dialog": {
"confirm": "Confirmar",
@@ -375,7 +392,8 @@
"yes": "Sí",
"no": "No",
"unknown": "Desconocido",
"app_title": "Correo Web"
"app_title": "Correo Web",
"reconnecting": "Conexión perdida. Intentando reconectar…"
},
"notifications": {
"email_sent": "Correo enviado exitosamente",
@@ -481,12 +499,15 @@
"templates": "Plantillas",
"folders": "Carpetas",
"keywords": "Palabras clave",
"security": "Seguridad"
"security": "Seguridad",
"files": "Archivos"
},
"tab_groups": {
"general": "General",
"account": "Cuenta",
"organization": "Organización"
"account": "Cuenta e identidad",
"organization": "Organización del correo",
"apps": "Aplicaciones",
"system": "Sistema"
},
"appearance": {
"title": "Apariencia",
@@ -592,6 +613,10 @@
"permanent": "Eliminar Permanentemente",
"warning": "Los correos se eliminarán permanentemente y no se podrán recuperar. Esta acción es irreversible."
},
"permanently_delete_junk": {
"label": "Eliminar spam permanentemente",
"description": "Eliminar permanentemente los correos de la carpeta Spam en lugar de moverlos a la Papelera"
},
"show_preview": {
"label": "Mostrar Vista Previa",
"description": "Mostrar vista previa del correo en la lista"
@@ -1041,6 +1066,58 @@
"empty": "El nombre de la plantilla es obligatorio",
"too_long": "El nombre de la plantilla no debe superar los 200 caracteres"
}
},
"files": {
"display": {
"title": "Visualización",
"description": "Configura cómo se muestran los archivos y carpetas"
},
"default_view": {
"label": "Vista predeterminada",
"description": "Elige entre diseño de cuadrícula y lista",
"list": "Lista",
"grid": "Cuadrícula"
},
"default_sort": {
"label": "Orden predeterminado",
"description": "Elige el orden predeterminado para los archivos",
"name": "Nombre",
"size": "Tamaño",
"modified": "Modificado"
},
"sort_direction": {
"label": "Dirección de orden",
"description": "Elige orden ascendente o descendente",
"ascending": "Ascendente",
"descending": "Descendente"
},
"icons": {
"title": "Iconos",
"description": "Configura la apariencia de los iconos de archivos"
},
"show_icons": {
"label": "Mostrar iconos de archivos",
"description": "Mostrar iconos junto a archivos y carpetas"
},
"colored_icons": {
"label": "Iconos de colores",
"description": "Usar iconos de colores en lugar de monocromáticos"
},
"show_thumbnails": {
"label": "Mostrar miniaturas",
"description": "Mostrar vistas previas de imágenes en lugar de iconos"
},
"behavior": {
"title": "Comportamiento",
"description": "Configura el comportamiento del explorador de archivos"
},
"show_hidden": {
"label": "Mostrar archivos ocultos",
"description": "Mostrar archivos y carpetas que comienzan con un punto"
},
"preview": {
"label": "Vista previa"
}
}
},
"errors": {
@@ -1194,7 +1271,9 @@
"identity_name": "Enviado usando identidad: {name}",
"identity_short": "vía {name}",
"subaddress_tag": "+{tag}"
}
},
"delete_button": "Eliminar",
"delete_confirm_title": "Eliminar identidad"
},
"templates": {
"picker_title": "Elegir una plantilla",
@@ -1230,6 +1309,7 @@
"empty_search_hint": "Prueba con otro término de búsqueda",
"clear_search": "Borrar búsqueda",
"import_vcard": "Importar vCard",
"delete_confirm_title": "Eliminar contacto",
"delete_confirm": "¿Estás seguro de que quieres eliminar este contacto?",
"local_mode": "Los contactos se almacenan localmente (el servidor no soporta JMAP Contacts)",
"back_to_mail": "Volver al correo",
@@ -1370,12 +1450,14 @@
"name_required": "Se requiere al menos un nombre o apellido",
"email_invalid": "Introduce una dirección de correo válida",
"email_error_inline": "Formato de correo inválido",
"save_failed": "Error al guardar el contacto"
"save_failed": "Error al guardar el contacto",
"delete": "Eliminar"
},
"groups": {
"create": "Nuevo grupo",
"edit": "Editar grupo",
"empty": "No hay grupos",
"delete_confirm_title": "Eliminar grupo",
"delete_confirm": "¿Estás seguro de que quieres eliminar este grupo?",
"name_label": "Nombre del grupo",
"name_placeholder": "ej. Equipo, Familia",
@@ -1412,6 +1494,7 @@
"selected": "{count, plural, one {1 seleccionado} other {# seleccionados}}",
"select_all": "Seleccionar todo",
"delete": "Eliminar",
"delete_confirm_title": "Eliminar contactos",
"delete_confirm": "¿Eliminar {count, plural, one {1 contacto} other {# contactos}}?",
"deleted": "{count, plural, one {1 contacto eliminado} other {# contactos eliminados}}",
"add_to_group": "Agregar al grupo",
@@ -1598,9 +1681,17 @@
"nav_next": "Siguiente",
"import": {
"title": "Importar calendario",
"tab_file": "Archivo",
"tab_url": "URL",
"select_file": "Seleccionar archivo .ics",
"drop_file": "o arrastra el archivo aquí",
"supported_formats": "Archivos iCalendar (.ics) compatibles",
"url_description": "Introduce la URL de un feed iCalendar (.ics) externo para importar eventos.",
"url_placeholder": "https://example.com/calendar.ics",
"url_hint": "Compatible con URLs CalDAV e iCalendar (.ics)",
"fetch": "Obtener",
"invalid_url": "Introduce una URL válida",
"url_fetch_failed": "No se pudo obtener el calendario desde la URL",
"parsing": "Analizando archivo de calendario...",
"parsed_events": "{count} eventos encontrados",
"no_events": "No se encontraron eventos en el archivo",
@@ -1613,6 +1704,65 @@
"error": "Error al importar el calendario",
"file_too_large": "El archivo supera el límite de 5 MB",
"invalid_format": "Formato de archivo de calendario no válido"
},
"management": {
"title": "Gestión de calendarios",
"description": "Crea, renombra y personaliza tus calendarios. Haz clic derecho en un calendario en la barra lateral para cambiar su color rápidamente.",
"name": "Nombre",
"name_placeholder": "Nombre del calendario",
"color": "Color",
"change_color": "Cambiar color",
"add_calendar": "Añadir calendario",
"edit": "Editar",
"delete": "Eliminar",
"save": "Guardar",
"create": "Crear",
"cancel": "Cancelar",
"default": "Predeterminado",
"confirm_delete": "¿Eliminar \"{name}\"? Se eliminarán todos los eventos de este calendario.",
"calendar_created": "Calendario creado",
"calendar_updated": "Calendario actualizado",
"calendar_deleted": "Calendario eliminado",
"color_updated": "Color del calendario actualizado",
"error_create": "Error al crear el calendario",
"error_update": "Error al actualizar el calendario",
"error_delete": "Error al eliminar el calendario",
"caldav_url": "URL de CalDAV",
"copy_url": "Copiar URL de CalDAV",
"url_copied": "URL de CalDAV copiada al portapapeles",
"confirm_clear": "¿Borrar todos los eventos de \"{name}\"? Esta acción no se puede deshacer.",
"clear_events": "Borrar eventos",
"events_cleared": "{count} eventos borrados",
"error_clear": "No se pudieron borrar los eventos del calendario"
},
"subscription": {
"title": "Suscripción iCal",
"section_title": "Suscripciones iCal",
"description": "Suscríbete a un feed externo de iCalendar. Los eventos se sincronizarán automáticamente en su propio calendario. Compatible con URLs https:// y webcal://.",
"url_label": "URL del calendario",
"url_placeholder": "https://example.com/calendar.ics o webcal://...",
"name_label": "Nombre del calendario",
"name_placeholder": "p. ej., Días festivos",
"color_label": "Color",
"refresh_interval": "Intervalo de actualización",
"interval_15": "Cada 15 minutos",
"interval_30": "Cada 30 minutos",
"interval_60": "Cada hora",
"interval_360": "Cada 6 horas",
"interval_1440": "Cada día",
"subscribe": "Suscribirse",
"subscribing": "Suscribiendo...",
"invalid_url": "Por favor, introduce una URL válida",
"success": "Suscrito a \"{name}\"",
"error": "No se pudo añadir la suscripción",
"refresh": "Actualizar ahora",
"refresh_success": "Suscripción actualizada",
"refresh_error": "No se pudo actualizar la suscripción",
"unsubscribe": "Cancelar suscripción",
"confirm_delete": "¿Cancelar la suscripción a \"{name}\"? El calendario y todos sus eventos serán eliminados.",
"deleted": "Suscripción eliminada",
"delete_error": "No se pudo eliminar la suscripción",
"last_refreshed": "Última actualización: {time}"
}
},
"advanced_search": {
@@ -1654,5 +1804,103 @@
"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 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",
"settings_title": "Configuración de archivos",
"settings_display": "Visualización",
"settings_default_view": "Vista predeterminada",
"settings_default_view_desc": "Elige entre diseño de cuadrícula y lista",
"settings_default_sort": "Orden predeterminado",
"settings_default_sort_desc": "Elige el orden predeterminado para los archivos",
"settings_sort_direction": "Dirección de orden",
"settings_sort_direction_desc": "Elige orden ascendente o descendente",
"settings_ascending": "Ascendente",
"settings_descending": "Descendente",
"settings_icons": "Iconos",
"settings_show_icons": "Mostrar iconos de archivos",
"settings_show_icons_desc": "Mostrar iconos junto a archivos y carpetas",
"settings_colored_icons": "Iconos de colores",
"settings_colored_icons_desc": "Usar iconos de colores en lugar de monocromáticos",
"settings_show_thumbnails": "Mostrar miniaturas",
"settings_show_thumbnails_desc": "Mostrar vistas previas de imágenes en lugar de iconos",
"settings_behavior": "Comportamiento",
"settings_show_hidden": "Mostrar archivos ocultos",
"settings_show_hidden_desc": "Mostrar archivos y carpetas que comienzan con un punto"
}
}
+258 -10
View File
@@ -61,6 +61,7 @@
"contacts": "Contacts",
"calendar": "Calendrier",
"settings": "Paramètres",
"files": "Fichiers",
"loading_mailboxes": "Chargement des boîtes mail...",
"push_connected": "Mises à jour en temps réel actives",
"push_disconnected": "Mises à jour en temps réel inactives",
@@ -129,7 +130,15 @@
"permanent_delete": "Supprimer définitivement",
"permanent_delete_confirm_title": "Supprimer définitivement",
"permanent_delete_confirm_message": "Cet e-mail sera définitivement supprimé. Cette action est irréversible.",
"permanent_delete_confirm_batch_message": "Ces {count, plural, one {1 e-mail} other {# e-mails}} seront définitivement supprimés. Cette action est irréversible."
"permanent_delete_confirm_batch_message": "Ces {count, plural, one {1 e-mail} other {# e-mails}} seront définitivement supprimés. Cette action est irréversible.",
"empty_folder": {
"button": "Vider le dossier",
"confirm_title": "Vider le dossier",
"confirm_message": "Tous les e-mails de ce dossier seront définitivement supprimés. Cette action est irréversible.",
"confirm_button": "Vider le dossier",
"junk_hint": "Vous pouvez vider le dossier Indésirables pour supprimer définitivement tous les messages.",
"trash_hint": "Vous pouvez vider la corbeille pour supprimer définitivement tous les messages."
}
},
"email_viewer": {
"no_email_selected": "Aucun email sélectionné",
@@ -175,6 +184,7 @@
"set_color": "Définir l'étiquette",
"tag": "Étiquette",
"more_actions": "Plus d'actions",
"move_to": "Déplacer vers...",
"remove_color": "Retirer l'étiquette",
"more_count": "+{count} de plus",
"characters_count": "{count} caractères",
@@ -250,7 +260,9 @@
"delete": "Supprimer",
"star": "Suivre (s)",
"unstar": "Ne plus suivre (s)",
"compose": "Rédiger (c)"
"compose": "Rédiger (c)",
"previous": "E-mail précédent",
"next": "E-mail suivant"
},
"spam": {
"button_title": "Signaler comme spam",
@@ -290,7 +302,10 @@
"no_calendar": "Calendrier non disponible",
"select_calendar": "Choisir un calendrier",
"already_in_calendar": "Déjà dans votre calendrier"
}
},
"previous": "Précédent",
"next": "Suivant",
"send": "Envoyer"
},
"email_composer": {
"new_message": "Nouveau message",
@@ -352,7 +367,9 @@
"continue_draft": "Continuer le brouillon",
"close_draft_title": "Enregistrer ou supprimer le brouillon ?",
"close_draft_message": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer comme brouillon ou supprimer ?",
"save_draft": "Enregistrer le brouillon"
"save_draft": "Enregistrer le brouillon",
"drop_files": "Déposez les fichiers à joindre",
"show_less": "Afficher moins"
},
"confirm_dialog": {
"confirm": "Confirmer",
@@ -375,7 +392,8 @@
"yes": "Oui",
"no": "Non",
"unknown": "Inconnu",
"app_title": "Webmail"
"app_title": "Webmail",
"reconnecting": "Connexion perdue. Tentative de reconnexion…"
},
"notifications": {
"email_sent": "Email envoyé avec succès",
@@ -481,12 +499,15 @@
"templates": "Modèles",
"folders": "Dossiers",
"keywords": "Mots-clés",
"security": "Sécurité"
"security": "Sécurité",
"files": "Fichiers"
},
"tab_groups": {
"general": "Général",
"account": "Compte",
"organization": "Organisation"
"account": "Compte & Identité",
"organization": "Organisation des e-mails",
"apps": "Applications",
"system": "Système"
},
"appearance": {
"title": "Apparence",
@@ -592,6 +613,10 @@
"permanent": "Supprimer définitivement",
"warning": "Les emails seront supprimés définitivement et ne pourront pas être récupérés. Cette action est irréversible."
},
"permanently_delete_junk": {
"label": "Supprimer définitivement les indésirables",
"description": "Supprimer définitivement les e-mails du dossier Indésirables au lieu de les déplacer vers la corbeille"
},
"show_preview": {
"label": "Afficher l'aperçu",
"description": "Afficher l'aperçu de l'email dans la liste"
@@ -1041,6 +1066,58 @@
"empty": "Le nom du modèle est requis",
"too_long": "Le nom du modèle ne doit pas dépasser 200 caractères"
}
},
"files": {
"display": {
"title": "Affichage",
"description": "Configurez l'affichage des fichiers et dossiers"
},
"default_view": {
"label": "Vue par défaut",
"description": "Choisissez entre la disposition en grille et en liste",
"list": "Liste",
"grid": "Grille"
},
"default_sort": {
"label": "Tri par défaut",
"description": "Choisissez le tri par défaut pour les fichiers",
"name": "Nom",
"size": "Taille",
"modified": "Modifié"
},
"sort_direction": {
"label": "Sens du tri",
"description": "Choisissez l'ordre croissant ou décroissant",
"ascending": "Croissant",
"descending": "Décroissant"
},
"icons": {
"title": "Icônes",
"description": "Configurez l'apparence des icônes de fichiers"
},
"show_icons": {
"label": "Afficher les icônes",
"description": "Afficher les icônes à côté des fichiers et dossiers"
},
"colored_icons": {
"label": "Icônes colorées",
"description": "Utiliser des icônes colorées au lieu de monochromes"
},
"show_thumbnails": {
"label": "Afficher les miniatures",
"description": "Afficher les aperçus d'images au lieu des icônes"
},
"behavior": {
"title": "Comportement",
"description": "Configurez le comportement du gestionnaire de fichiers"
},
"show_hidden": {
"label": "Afficher les fichiers cachés",
"description": "Afficher les fichiers et dossiers commençant par un point"
},
"preview": {
"label": "Aperçu"
}
}
},
"errors": {
@@ -1194,7 +1271,9 @@
"identity_name": "Envoyé avec l'identité: {name}",
"identity_short": "via {name}",
"subaddress_tag": "+{tag}"
}
},
"delete_button": "Supprimer",
"delete_confirm_title": "Supprimer l'identité"
},
"templates": {
"picker_title": "Choisir un modèle",
@@ -1230,6 +1309,7 @@
"empty_search_hint": "Essayez un autre terme de recherche",
"clear_search": "Effacer la recherche",
"import_vcard": "Importer vCard",
"delete_confirm_title": "Supprimer le contact",
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ce contact ?",
"local_mode": "Les contacts sont stockés localement (le serveur ne prend pas en charge JMAP Contacts)",
"back_to_mail": "Retour aux e-mails",
@@ -1370,12 +1450,14 @@
"name_required": "Un prénom ou un nom est requis",
"email_invalid": "Veuillez saisir une adresse e-mail valide",
"email_error_inline": "Format d'e-mail invalide",
"save_failed": "Échec de l'enregistrement du contact"
"save_failed": "Échec de l'enregistrement du contact",
"delete": "Supprimer"
},
"groups": {
"create": "Nouveau groupe",
"edit": "Modifier le groupe",
"empty": "Aucun groupe",
"delete_confirm_title": "Supprimer le groupe",
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ce groupe ?",
"name_label": "Nom du groupe",
"name_placeholder": "ex. Équipe, Famille",
@@ -1412,6 +1494,7 @@
"selected": "{count, plural, one {1 sélectionné} other {# sélectionnés}}",
"select_all": "Tout sélectionner",
"delete": "Supprimer",
"delete_confirm_title": "Supprimer les contacts",
"delete_confirm": "Supprimer {count, plural, one {1 contact} other {# contacts}} ?",
"deleted": "{count, plural, one {1 contact supprimé} other {# contacts supprimés}}",
"add_to_group": "Ajouter au groupe",
@@ -1598,9 +1681,17 @@
"nav_next": "Suivant",
"import": {
"title": "Importer un calendrier",
"tab_file": "Fichier",
"tab_url": "URL",
"select_file": "Sélectionner un fichier .ics",
"drop_file": "ou déposez le fichier ici",
"supported_formats": "Fichiers iCalendar (.ics) supportés",
"url_description": "Entrez l'URL d'un flux iCalendar (.ics) externe pour importer des événements.",
"url_placeholder": "https://example.com/calendar.ics",
"url_hint": "Prend en charge les URLs CalDAV et iCalendar (.ics)",
"fetch": "Récupérer",
"invalid_url": "Veuillez entrer une URL valide",
"url_fetch_failed": "Impossible de récupérer le calendrier depuis l'URL",
"parsing": "Analyse du fichier en cours...",
"parsed_events": "{count} événements trouvés",
"no_events": "Aucun événement trouvé dans le fichier",
@@ -1613,6 +1704,65 @@
"error": "Échec de l'importation du calendrier",
"file_too_large": "Le fichier dépasse la limite de 5 Mo",
"invalid_format": "Format de fichier calendrier invalide"
},
"management": {
"title": "Gestion des calendriers",
"description": "Créez, renommez et personnalisez vos calendriers. Clic droit sur un calendrier dans la barre latérale pour changer rapidement sa couleur.",
"name": "Nom",
"name_placeholder": "Nom du calendrier",
"color": "Couleur",
"change_color": "Changer la couleur",
"add_calendar": "Ajouter un calendrier",
"edit": "Modifier",
"delete": "Supprimer",
"save": "Enregistrer",
"create": "Créer",
"cancel": "Annuler",
"default": "Par défaut",
"confirm_delete": "Supprimer \"{name}\" ? Tous les événements de ce calendrier seront supprimés.",
"calendar_created": "Calendrier créé",
"calendar_updated": "Calendrier mis à jour",
"calendar_deleted": "Calendrier supprimé",
"color_updated": "Couleur du calendrier mise à jour",
"error_create": "Échec de la création du calendrier",
"error_update": "Échec de la mise à jour du calendrier",
"error_delete": "Échec de la suppression du calendrier",
"caldav_url": "URL CalDAV",
"copy_url": "Copier l'URL CalDAV",
"url_copied": "URL CalDAV copiée dans le presse-papiers",
"confirm_clear": "Supprimer tous les événements de \"{name}\" ? Cette action est irréversible.",
"clear_events": "Supprimer les événements",
"events_cleared": "{count} événements supprimés",
"error_clear": "Impossible de supprimer les événements du calendrier"
},
"subscription": {
"title": "Abonnement iCal",
"section_title": "Abonnements iCal",
"description": "Abonnez-vous à un flux iCalendar externe. Les événements seront synchronisés automatiquement dans leur propre calendrier. Prend en charge les URL https:// et webcal://.",
"url_label": "URL du calendrier",
"url_placeholder": "https://example.com/calendar.ics ou webcal://...",
"name_label": "Nom du calendrier",
"name_placeholder": "ex. Jours fériés",
"color_label": "Couleur",
"refresh_interval": "Intervalle de rafraîchissement",
"interval_15": "Toutes les 15 minutes",
"interval_30": "Toutes les 30 minutes",
"interval_60": "Toutes les heures",
"interval_360": "Toutes les 6 heures",
"interval_1440": "Tous les jours",
"subscribe": "S'abonner",
"subscribing": "Abonnement en cours...",
"invalid_url": "Veuillez entrer une URL valide",
"success": "Abonné à \"{name}\"",
"error": "Impossible d'ajouter l'abonnement",
"refresh": "Rafraîchir maintenant",
"refresh_success": "Abonnement rafraîchi",
"refresh_error": "Impossible de rafraîchir l'abonnement",
"unsubscribe": "Se désabonner",
"confirm_delete": "Se désabonner de \"{name}\" ? Le calendrier et tous ses événements seront supprimés.",
"deleted": "Abonnement supprimé",
"delete_error": "Impossible de supprimer l'abonnement",
"last_refreshed": "Dernière mise à jour : {time}"
}
},
"advanced_search": {
@@ -1654,5 +1804,103 @@
"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 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",
"settings_title": "Paramètres des fichiers",
"settings_display": "Affichage",
"settings_default_view": "Vue par défaut",
"settings_default_view_desc": "Choisissez entre la disposition en grille et en liste",
"settings_default_sort": "Tri par défaut",
"settings_default_sort_desc": "Choisissez le tri par défaut pour les fichiers",
"settings_sort_direction": "Sens du tri",
"settings_sort_direction_desc": "Choisissez l'ordre croissant ou décroissant",
"settings_ascending": "Croissant",
"settings_descending": "Décroissant",
"settings_icons": "Icônes",
"settings_show_icons": "Afficher les icônes",
"settings_show_icons_desc": "Afficher les icônes à côté des fichiers et dossiers",
"settings_colored_icons": "Icônes colorées",
"settings_colored_icons_desc": "Utiliser des icônes colorées au lieu de monochromes",
"settings_show_thumbnails": "Afficher les miniatures",
"settings_show_thumbnails_desc": "Afficher les aperçus d'images au lieu des icônes",
"settings_behavior": "Comportement",
"settings_show_hidden": "Afficher les fichiers cachés",
"settings_show_hidden_desc": "Afficher les fichiers et dossiers commençant par un point"
}
}
+258 -10
View File
@@ -61,6 +61,7 @@
"contacts": "Contatti",
"calendar": "Calendario",
"settings": "Impostazioni",
"files": "File",
"loading_mailboxes": "Caricamento caselle di posta...",
"push_connected": "Aggiornamenti in tempo reale attivi",
"push_disconnected": "Aggiornamenti in tempo reale non attivi",
@@ -129,7 +130,15 @@
"permanent_delete": "Elimina definitivamente",
"permanent_delete_confirm_title": "Elimina definitivamente",
"permanent_delete_confirm_message": "Questa e-mail verrà eliminata definitivamente. Questa azione non può essere annullata.",
"permanent_delete_confirm_batch_message": "Queste {count, plural, one {1 e-mail} other {# e-mail}} verranno eliminate definitivamente. Questa azione non può essere annullata."
"permanent_delete_confirm_batch_message": "Queste {count, plural, one {1 e-mail} other {# e-mail}} verranno eliminate definitivamente. Questa azione non può essere annullata.",
"empty_folder": {
"button": "Svuota cartella",
"confirm_title": "Svuota cartella",
"confirm_message": "Tutti i messaggi in questa cartella verranno eliminati definitivamente. Questa azione non può essere annullata.",
"confirm_button": "Svuota cartella",
"junk_hint": "Puoi svuotare la cartella Spam per rimuovere definitivamente tutti i messaggi.",
"trash_hint": "Puoi svuotare il Cestino per rimuovere definitivamente tutti i messaggi."
}
},
"email_viewer": {
"no_email_selected": "Nessun messaggio selezionato",
@@ -175,6 +184,7 @@
"set_color": "Imposta etichetta",
"tag": "Etichetta",
"more_actions": "Altre azioni",
"move_to": "Sposta in...",
"remove_color": "Rimuovi etichetta",
"more_count": "+{count} altri",
"characters_count": "{count} caratteri",
@@ -250,7 +260,9 @@
"delete": "Elimina",
"star": "Aggiungi stella (s)",
"unstar": "Rimuovi stella (s)",
"compose": "Scrivi (c)"
"compose": "Scrivi (c)",
"previous": "Email precedente",
"next": "Email successiva"
},
"spam": {
"button_title": "Segnala come spam",
@@ -290,7 +302,10 @@
"no_calendar": "Calendario non disponibile",
"select_calendar": "Seleziona calendario",
"already_in_calendar": "Già nel tuo calendario"
}
},
"previous": "Precedente",
"next": "Successivo",
"send": "Invia"
},
"email_composer": {
"new_message": "Nuovo messaggio",
@@ -352,7 +367,9 @@
"continue_draft": "Continua bozza",
"close_draft_title": "Salvare o eliminare la bozza?",
"close_draft_message": "Hai modifiche non salvate. Vuoi salvare come bozza o eliminare?",
"save_draft": "Salva bozza"
"save_draft": "Salva bozza",
"drop_files": "Trascina i file per allegarli",
"show_less": "Mostra meno"
},
"confirm_dialog": {
"confirm": "Conferma",
@@ -375,7 +392,8 @@
"yes": "Sì",
"no": "No",
"unknown": "Sconosciuto",
"app_title": "Webmail"
"app_title": "Webmail",
"reconnecting": "Connessione persa. Tentativo di riconnessione…"
},
"notifications": {
"email_sent": "Messaggio inviato con successo",
@@ -481,12 +499,15 @@
"templates": "Modelli",
"folders": "Cartelle",
"keywords": "Parole chiave",
"security": "Sicurezza"
"security": "Sicurezza",
"files": "File"
},
"tab_groups": {
"general": "Generale",
"account": "Account",
"organization": "Organizzazione"
"account": "Account e identità",
"organization": "Organizzazione e-mail",
"apps": "Applicazioni",
"system": "Sistema"
},
"appearance": {
"title": "Aspetto",
@@ -592,6 +613,10 @@
"permanent": "Elimina definitivamente",
"warning": "I messaggi verranno eliminati definitivamente e non potranno essere recuperati. Questa azione è irreversibile."
},
"permanently_delete_junk": {
"label": "Elimina spam definitivamente",
"description": "Elimina definitivamente i messaggi dalla cartella Spam invece di spostarli nel cestino"
},
"show_preview": {
"label": "Mostra anteprima testo",
"description": "Visualizza l'anteprima del messaggio nell'elenco"
@@ -1041,6 +1066,58 @@
"empty": "Il nome del modello è obbligatorio",
"too_long": "Il nome del modello non deve superare i 200 caratteri"
}
},
"files": {
"display": {
"title": "Visualizzazione",
"description": "Configura come vengono visualizzati file e cartelle"
},
"default_view": {
"label": "Vista predefinita",
"description": "Scegli tra layout a griglia e a lista",
"list": "Lista",
"grid": "Griglia"
},
"default_sort": {
"label": "Ordinamento predefinito",
"description": "Scegli l'ordinamento predefinito per i file",
"name": "Nome",
"size": "Dimensione",
"modified": "Modificato"
},
"sort_direction": {
"label": "Direzione ordinamento",
"description": "Scegli ordine crescente o decrescente",
"ascending": "Crescente",
"descending": "Decrescente"
},
"icons": {
"title": "Icone",
"description": "Configura l'aspetto delle icone dei file"
},
"show_icons": {
"label": "Mostra icone file",
"description": "Visualizza le icone accanto a file e cartelle"
},
"colored_icons": {
"label": "Icone colorate",
"description": "Usa icone colorate invece che monocromatiche"
},
"show_thumbnails": {
"label": "Mostra miniature",
"description": "Mostra anteprime delle immagini al posto delle icone"
},
"behavior": {
"title": "Comportamento",
"description": "Configura il comportamento del gestore file"
},
"show_hidden": {
"label": "Mostra file nascosti",
"description": "Visualizza file e cartelle che iniziano con un punto"
},
"preview": {
"label": "Anteprima"
}
}
},
"errors": {
@@ -1194,7 +1271,9 @@
"identity_name": "Inviato usando identità: {name}",
"identity_short": "tramite {name}",
"subaddress_tag": "+{tag}"
}
},
"delete_button": "Elimina",
"delete_confirm_title": "Elimina identità"
},
"templates": {
"picker_title": "Scegli un modello",
@@ -1230,6 +1309,7 @@
"empty_search_hint": "Prova con un altro termine di ricerca",
"clear_search": "Cancella ricerca",
"import_vcard": "Importa vCard",
"delete_confirm_title": "Elimina contatto",
"delete_confirm": "Sei sicuro di voler eliminare questo contatto?",
"local_mode": "I contatti sono salvati localmente (il server non supporta JMAP Contacts)",
"back_to_mail": "Torna alla posta",
@@ -1370,12 +1450,14 @@
"name_required": "È richiesto almeno un nome o cognome",
"email_invalid": "Inserisci un indirizzo email valido",
"email_error_inline": "Formato email non valido",
"save_failed": "Impossibile salvare il contatto"
"save_failed": "Impossibile salvare il contatto",
"delete": "Elimina"
},
"groups": {
"create": "Nuovo gruppo",
"edit": "Modifica gruppo",
"empty": "Nessun gruppo",
"delete_confirm_title": "Elimina gruppo",
"delete_confirm": "Sei sicuro di voler eliminare questo gruppo?",
"name_label": "Nome del gruppo",
"name_placeholder": "es. Team, Famiglia",
@@ -1412,6 +1494,7 @@
"selected": "{count, plural, one {1 selezionato} other {# selezionati}}",
"select_all": "Seleziona tutto",
"delete": "Elimina",
"delete_confirm_title": "Elimina contatti",
"delete_confirm": "Eliminare {count, plural, one {1 contatto} other {# contatti}}?",
"deleted": "{count, plural, one {1 contatto eliminato} other {# contatti eliminati}}",
"add_to_group": "Aggiungi al gruppo",
@@ -1598,9 +1681,17 @@
"nav_next": "Successivo",
"import": {
"title": "Importa calendario",
"tab_file": "File",
"tab_url": "URL",
"select_file": "Seleziona file .ics",
"drop_file": "o trascina il file qui",
"supported_formats": "File iCalendar (.ics) supportati",
"url_description": "Inserisci l'URL di un feed iCalendar (.ics) esterno per importare eventi.",
"url_placeholder": "https://example.com/calendar.ics",
"url_hint": "Supporta URL CalDAV e iCalendar (.ics)",
"fetch": "Recupera",
"invalid_url": "Inserisci un URL valido",
"url_fetch_failed": "Impossibile recuperare il calendario dall'URL",
"parsing": "Analisi del file in corso...",
"parsed_events": "{count} eventi trovati",
"no_events": "Nessun evento trovato nel file",
@@ -1613,6 +1704,65 @@
"error": "Importazione del calendario fallita",
"file_too_large": "Il file supera il limite di 5 MB",
"invalid_format": "Formato del file calendario non valido"
},
"management": {
"title": "Gestione calendari",
"description": "Crea, rinomina e personalizza i tuoi calendari. Fai clic destro su un calendario nella barra laterale per cambiarne rapidamente il colore.",
"name": "Nome",
"name_placeholder": "Nome del calendario",
"color": "Colore",
"change_color": "Cambia colore",
"add_calendar": "Aggiungi calendario",
"edit": "Modifica",
"delete": "Elimina",
"save": "Salva",
"create": "Crea",
"cancel": "Annulla",
"default": "Predefinito",
"confirm_delete": "Eliminare \"{name}\"? Tutti gli eventi in questo calendario verranno rimossi.",
"calendar_created": "Calendario creato",
"calendar_updated": "Calendario aggiornato",
"calendar_deleted": "Calendario eliminato",
"color_updated": "Colore del calendario aggiornato",
"error_create": "Impossibile creare il calendario",
"error_update": "Impossibile aggiornare il calendario",
"error_delete": "Impossibile eliminare il calendario",
"caldav_url": "URL CalDAV",
"copy_url": "Copia URL CalDAV",
"url_copied": "URL CalDAV copiato negli appunti",
"confirm_clear": "Cancellare tutti gli eventi da \"{name}\"? Questa azione non può essere annullata.",
"clear_events": "Cancella eventi",
"events_cleared": "{count} eventi cancellati",
"error_clear": "Impossibile cancellare gli eventi del calendario"
},
"subscription": {
"title": "Abbonamento iCal",
"section_title": "Abbonamenti iCal",
"description": "Abbonati a un feed iCalendar esterno. Gli eventi verranno sincronizzati automaticamente nel proprio calendario. Supporta URL https:// e webcal://.",
"url_label": "URL del calendario",
"url_placeholder": "https://example.com/calendar.ics o webcal://...",
"name_label": "Nome del calendario",
"name_placeholder": "es. Festività",
"color_label": "Colore",
"refresh_interval": "Intervallo di aggiornamento",
"interval_15": "Ogni 15 minuti",
"interval_30": "Ogni 30 minuti",
"interval_60": "Ogni ora",
"interval_360": "Ogni 6 ore",
"interval_1440": "Ogni giorno",
"subscribe": "Abbonati",
"subscribing": "Abbonamento in corso...",
"invalid_url": "Inserisci un URL valido",
"success": "Abbonato a \"{name}\"",
"error": "Impossibile aggiungere l'abbonamento",
"refresh": "Aggiorna ora",
"refresh_success": "Abbonamento aggiornato",
"refresh_error": "Impossibile aggiornare l'abbonamento",
"unsubscribe": "Annulla abbonamento",
"confirm_delete": "Annullare l'abbonamento a \"{name}\"? Il calendario e tutti i suoi eventi verranno rimossi.",
"deleted": "Abbonamento rimosso",
"delete_error": "Impossibile rimuovere l'abbonamento",
"last_refreshed": "Ultimo aggiornamento: {time}"
}
},
"advanced_search": {
@@ -1654,5 +1804,103 @@
"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 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",
"settings_title": "Impostazioni file",
"settings_display": "Visualizzazione",
"settings_default_view": "Vista predefinita",
"settings_default_view_desc": "Scegli tra layout a griglia e a lista",
"settings_default_sort": "Ordinamento predefinito",
"settings_default_sort_desc": "Scegli l'ordinamento predefinito per i file",
"settings_sort_direction": "Direzione ordinamento",
"settings_sort_direction_desc": "Scegli ordine crescente o decrescente",
"settings_ascending": "Crescente",
"settings_descending": "Decrescente",
"settings_icons": "Icone",
"settings_show_icons": "Mostra icone file",
"settings_show_icons_desc": "Visualizza le icone accanto a file e cartelle",
"settings_colored_icons": "Icone colorate",
"settings_colored_icons_desc": "Usa icone colorate invece che monocromatiche",
"settings_show_thumbnails": "Mostra miniature",
"settings_show_thumbnails_desc": "Mostra anteprime delle immagini al posto delle icone",
"settings_behavior": "Comportamento",
"settings_show_hidden": "Mostra file nascosti",
"settings_show_hidden_desc": "Visualizza file e cartelle che iniziano con un punto"
}
}
+258 -10
View File
@@ -61,6 +61,7 @@
"contacts": "連絡先",
"calendar": "カレンダー",
"settings": "設定",
"files": "ファイル",
"loading_mailboxes": "メールボックスを読み込み中...",
"push_connected": "リアルタイム更新が有効",
"push_disconnected": "リアルタイム更新が無効",
@@ -129,7 +130,15 @@
"permanent_delete": "完全に削除",
"permanent_delete_confirm_title": "完全に削除",
"permanent_delete_confirm_message": "このメールは完全に削除されます。この操作は取り消せません。",
"permanent_delete_confirm_batch_message": "{count, plural, one {1件} other {#件}}のメールが完全に削除されます。この操作は取り消せません。"
"permanent_delete_confirm_batch_message": "{count, plural, one {1件} other {#件}}のメールが完全に削除されます。この操作は取り消せません。",
"empty_folder": {
"button": "フォルダを空にする",
"confirm_title": "フォルダを空にする",
"confirm_message": "このフォルダ内のすべてのメールが完全に削除されます。この操作は取り消せません。",
"confirm_button": "フォルダを空にする",
"junk_hint": "迷惑メールフォルダを空にして、すべてのメールを完全に削除できます。",
"trash_hint": "ゴミ箱を空にして、すべてのメールを完全に削除できます。"
}
},
"email_viewer": {
"no_email_selected": "メールが選択されていません",
@@ -175,6 +184,7 @@
"set_color": "ラベルを設定",
"tag": "ラベル",
"more_actions": "その他の操作",
"move_to": "移動...",
"remove_color": "ラベルを削除",
"more_count": "他{count}件",
"characters_count": "{count}文字",
@@ -250,7 +260,9 @@
"delete": "削除",
"star": "スター (s)",
"unstar": "スター解除 (s)",
"compose": "新規作成 (c)"
"compose": "新規作成 (c)",
"previous": "前のメール",
"next": "次のメール"
},
"spam": {
"button_title": "迷惑メールを報告",
@@ -290,7 +302,10 @@
"no_calendar": "カレンダーが利用できません",
"select_calendar": "カレンダーを選択",
"already_in_calendar": "カレンダーに登録済み"
}
},
"previous": "前へ",
"next": "次へ",
"send": "送信"
},
"email_composer": {
"new_message": "新規メッセージ",
@@ -352,7 +367,9 @@
"continue_draft": "下書きを続ける",
"close_draft_title": "下書きを保存または破棄しますか?",
"close_draft_message": "未保存の変更があります。下書きとして保存しますか、それとも破棄しますか?",
"save_draft": "下書きを保存"
"save_draft": "下書きを保存",
"drop_files": "ファイルをドロップして添付",
"show_less": "折りたたむ"
},
"confirm_dialog": {
"confirm": "確認",
@@ -375,7 +392,8 @@
"yes": "はい",
"no": "いいえ",
"unknown": "不明",
"app_title": "ウェブメール"
"app_title": "ウェブメール",
"reconnecting": "接続が切れました。再接続を試みています…"
},
"notifications": {
"email_sent": "メールを送信しました",
@@ -481,12 +499,15 @@
"templates": "テンプレート",
"folders": "フォルダー",
"keywords": "キーワード",
"security": "セキュリティ"
"security": "セキュリティ",
"files": "ファイル"
},
"tab_groups": {
"general": "一般",
"account": "アカウント",
"organization": "整理"
"account": "アカウントと身元",
"organization": "メール整理",
"apps": "アプリ",
"system": "システム"
},
"appearance": {
"title": "外観",
@@ -592,6 +613,10 @@
"permanent": "完全に削除",
"warning": "メールは完全に削除され、復元できません。この操作は元に戻せません。"
},
"permanently_delete_junk": {
"label": "迷惑メールを完全に削除",
"description": "迷惑メールフォルダのメールをゴミ箱に移動せずに完全に削除する"
},
"show_preview": {
"label": "プレビューテキストを表示",
"description": "リストにメールのプレビューを表示"
@@ -1041,6 +1066,58 @@
"empty": "テンプレート名は必須です",
"too_long": "テンプレート名は200文字以内にしてください"
}
},
"files": {
"display": {
"title": "表示",
"description": "ファイルとフォルダーの表示方法を設定します"
},
"default_view": {
"label": "デフォルトビュー",
"description": "グリッドまたはリストレイアウトを選択します",
"list": "リスト",
"grid": "グリッド"
},
"default_sort": {
"label": "デフォルトの並べ替え",
"description": "ファイルのデフォルトの並べ替えを選択します",
"name": "名前",
"size": "サイズ",
"modified": "更新日"
},
"sort_direction": {
"label": "並べ替え方向",
"description": "昇順または降順を選択します",
"ascending": "昇順",
"descending": "降順"
},
"icons": {
"title": "アイコン",
"description": "ファイルアイコンの外観を設定します"
},
"show_icons": {
"label": "ファイルアイコンを表示",
"description": "ファイルとフォルダーの横にアイコンを表示します"
},
"colored_icons": {
"label": "カラーアイコン",
"description": "モノクロの代わりにカラフルなアイコンを使用します"
},
"show_thumbnails": {
"label": "サムネイルを表示",
"description": "画像ファイルのアイコンの代わりにプレビューを表示します"
},
"behavior": {
"title": "動作",
"description": "ファイルブラウザーの動作を設定します"
},
"show_hidden": {
"label": "隠しファイルを表示",
"description": "ドットで始まるファイルとフォルダーを表示します"
},
"preview": {
"label": "プレビュー"
}
}
},
"errors": {
@@ -1194,7 +1271,9 @@
"identity_name": "送信者情報を使用して送信: {name}",
"identity_short": "{name}経由",
"subaddress_tag": "+{tag}"
}
},
"delete_button": "削除",
"delete_confirm_title": "IDの削除"
},
"templates": {
"picker_title": "テンプレートを選択",
@@ -1230,6 +1309,7 @@
"empty_search_hint": "別の検索語をお試しください",
"clear_search": "検索をクリア",
"import_vcard": "vCardをインポート",
"delete_confirm_title": "連絡先を削除",
"delete_confirm": "この連絡先を削除してもよろしいですか?",
"local_mode": "連絡先はローカルに保存されています(サーバーがJMAPコンタクトをサポートしていません)",
"back_to_mail": "メールに戻る",
@@ -1370,12 +1450,14 @@
"name_required": "名前は必須です",
"email_invalid": "有効なメールアドレスを入力してください",
"email_error_inline": "メールアドレスの形式が正しくありません",
"save_failed": "連絡先の保存に失敗しました"
"save_failed": "連絡先の保存に失敗しました",
"delete": "削除"
},
"groups": {
"create": "新しいグループ",
"edit": "グループを編集",
"empty": "グループがありません",
"delete_confirm_title": "グループを削除",
"delete_confirm": "このグループを削除してもよろしいですか?",
"name_label": "グループ名",
"name_placeholder": "例:チーム、家族",
@@ -1412,6 +1494,7 @@
"selected": "{count, plural, other {#件選択中}}",
"select_all": "すべて選択",
"delete": "削除",
"delete_confirm_title": "連絡先を削除",
"delete_confirm": "{count, plural, other {#件の連絡先}}を削除しますか?",
"deleted": "{count, plural, other {#件の連絡先を削除しました}}",
"add_to_group": "グループに追加",
@@ -1598,9 +1681,17 @@
"nav_next": "次へ",
"import": {
"title": "カレンダーをインポート",
"tab_file": "ファイル",
"tab_url": "URL",
"select_file": ".icsファイルを選択",
"drop_file": "またはファイルをここにドロップ",
"supported_formats": "iCalendar (.ics) ファイルに対応",
"url_description": "外部のiCalendar (.ics) フィードのURLを入力してイベントをインポートします。",
"url_placeholder": "https://example.com/calendar.ics",
"url_hint": "CalDAVおよびiCalendar (.ics) URLに対応",
"fetch": "取得",
"invalid_url": "有効なURLを入力してください",
"url_fetch_failed": "URLからカレンダーを取得できませんでした",
"parsing": "カレンダーファイルを解析中...",
"parsed_events": "{count}件のイベントが見つかりました",
"no_events": "ファイルにイベントが見つかりません",
@@ -1613,6 +1704,65 @@
"error": "カレンダーのインポートに失敗しました",
"file_too_large": "ファイルサイズが5MBを超えています",
"invalid_format": "無効なカレンダーファイル形式"
},
"management": {
"title": "カレンダー管理",
"description": "カレンダーの作成、名前変更、カスタマイズができます。サイドバーのカレンダーを右クリックして色を素早く変更できます。",
"name": "名前",
"name_placeholder": "カレンダー名",
"color": "色",
"change_color": "色を変更",
"add_calendar": "カレンダーを追加",
"edit": "編集",
"delete": "削除",
"save": "保存",
"create": "作成",
"cancel": "キャンセル",
"default": "デフォルト",
"confirm_delete": "\"{name}\"を削除しますか?このカレンダーのすべてのイベントが削除されます。",
"calendar_created": "カレンダーを作成しました",
"calendar_updated": "カレンダーを更新しました",
"calendar_deleted": "カレンダーを削除しました",
"color_updated": "カレンダーの色を更新しました",
"error_create": "カレンダーの作成に失敗しました",
"error_update": "カレンダーの更新に失敗しました",
"error_delete": "カレンダーの削除に失敗しました",
"caldav_url": "CalDAV URL",
"copy_url": "CalDAV URLをコピー",
"url_copied": "CalDAV URLをクリップボードにコピーしました",
"confirm_clear": "\"{name}\"のすべてのイベントを削除しますか?この操作は元に戻せません。",
"clear_events": "イベントを削除",
"events_cleared": "{count}件のイベントを削除しました",
"error_clear": "カレンダーイベントの削除に失敗しました"
},
"subscription": {
"title": "iCal購読",
"section_title": "iCal購読",
"description": "外部のiCalendarフィードを購読します。イベントは自動的に専用のカレンダーに同期されます。https://およびwebcal://のURLに対応しています。",
"url_label": "カレンダーURL",
"url_placeholder": "https://example.com/calendar.ics または webcal://...",
"name_label": "カレンダー名",
"name_placeholder": "例:祝日",
"color_label": "色",
"refresh_interval": "更新間隔",
"interval_15": "15分ごと",
"interval_30": "30分ごと",
"interval_60": "1時間ごと",
"interval_360": "6時間ごと",
"interval_1440": "毎日",
"subscribe": "購読する",
"subscribing": "購読中...",
"invalid_url": "有効なURLを入力してください",
"success": "\"{name}\"を購読しました",
"error": "購読の追加に失敗しました",
"refresh": "今すぐ更新",
"refresh_success": "購読を更新しました",
"refresh_error": "購読の更新に失敗しました",
"unsubscribe": "購読解除",
"confirm_delete": "\"{name}\"の購読を解除しますか?カレンダーとすべてのイベントが削除されます。",
"deleted": "購読を解除しました",
"delete_error": "購読の解除に失敗しました",
"last_refreshed": "最終更新: {time}"
}
},
"advanced_search": {
@@ -1654,5 +1804,103 @@
"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": "ファイルストレージはこのサーバーで利用できません",
"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": "操作",
"settings_title": "ファイル設定",
"settings_display": "表示",
"settings_default_view": "デフォルトビュー",
"settings_default_view_desc": "グリッドまたはリストレイアウトを選択します",
"settings_default_sort": "デフォルトの並べ替え",
"settings_default_sort_desc": "ファイルのデフォルトの並べ替えを選択します",
"settings_sort_direction": "並べ替え方向",
"settings_sort_direction_desc": "昇順または降順を選択します",
"settings_ascending": "昇順",
"settings_descending": "降順",
"settings_icons": "アイコン",
"settings_show_icons": "ファイルアイコンを表示",
"settings_show_icons_desc": "ファイルとフォルダーの横にアイコンを表示します",
"settings_colored_icons": "カラーアイコン",
"settings_colored_icons_desc": "モノクロの代わりにカラフルなアイコンを使用します",
"settings_show_thumbnails": "サムネイルを表示",
"settings_show_thumbnails_desc": "画像ファイルのアイコンの代わりにプレビューを表示します",
"settings_behavior": "動作",
"settings_show_hidden": "隠しファイルを表示",
"settings_show_hidden_desc": "ドットで始まるファイルとフォルダーを表示します"
}
}
+258 -10
View File
@@ -61,6 +61,7 @@
"contacts": "Contacten",
"calendar": "Agenda",
"settings": "Instellingen",
"files": "Bestanden",
"loading_mailboxes": "Mappen laden...",
"push_connected": "Real-time updates actief",
"push_disconnected": "Real-time updates inactief",
@@ -129,7 +130,15 @@
"permanent_delete": "Definitief verwijderen",
"permanent_delete_confirm_title": "Definitief verwijderen",
"permanent_delete_confirm_message": "Deze e-mail wordt definitief verwijderd. Deze actie kan niet ongedaan worden gemaakt.",
"permanent_delete_confirm_batch_message": "Deze {count, plural, one {1 e-mail} other {# e-mails}} worden definitief verwijderd. Deze actie kan niet ongedaan worden gemaakt."
"permanent_delete_confirm_batch_message": "Deze {count, plural, one {1 e-mail} other {# e-mails}} worden definitief verwijderd. Deze actie kan niet ongedaan worden gemaakt.",
"empty_folder": {
"button": "Map legen",
"confirm_title": "Map legen",
"confirm_message": "Alle e-mails in deze map worden permanent verwijderd. Deze actie kan niet ongedaan worden gemaakt.",
"confirm_button": "Map legen",
"junk_hint": "U kunt de map Spam legen om alle berichten permanent te verwijderen.",
"trash_hint": "U kunt de prullenbak legen om alle berichten permanent te verwijderen."
}
},
"email_viewer": {
"no_email_selected": "Geen e-mail geselecteerd",
@@ -175,6 +184,7 @@
"set_color": "Label instellen",
"tag": "Label",
"more_actions": "Meer acties",
"move_to": "Verplaatsen naar...",
"remove_color": "Label verwijderen",
"more_count": "+{count} meer",
"characters_count": "{count} tekens",
@@ -250,7 +260,9 @@
"delete": "Verwijderen",
"star": "Ster toevoegen (s)",
"unstar": "Ster verwijderen (s)",
"compose": "Opstellen (c)"
"compose": "Opstellen (c)",
"previous": "Vorige e-mail",
"next": "Volgende e-mail"
},
"spam": {
"button_title": "Spam melden",
@@ -290,7 +302,10 @@
"no_calendar": "Agenda niet beschikbaar",
"select_calendar": "Agenda selecteren",
"already_in_calendar": "Staat al in je agenda"
}
},
"previous": "Vorige",
"next": "Volgende",
"send": "Verzenden"
},
"email_composer": {
"new_message": "Nieuw bericht",
@@ -352,7 +367,9 @@
"continue_draft": "Concept voortzetten",
"close_draft_title": "Concept opslaan of verwijderen?",
"close_draft_message": "U heeft niet-opgeslagen wijzigingen. Wilt u dit als concept opslaan of verwijderen?",
"save_draft": "Concept opslaan"
"save_draft": "Concept opslaan",
"drop_files": "Sleep bestanden om bij te voegen",
"show_less": "Minder tonen"
},
"confirm_dialog": {
"confirm": "Bevestigen",
@@ -375,7 +392,8 @@
"yes": "Ja",
"no": "Nee",
"unknown": "Onbekend",
"app_title": "Webmail"
"app_title": "Webmail",
"reconnecting": "Verbinding verloren. Opnieuw verbinden…"
},
"notifications": {
"email_sent": "E-mail succesvol verzonden",
@@ -481,12 +499,15 @@
"templates": "Sjablonen",
"folders": "Mappen",
"keywords": "Sleutelwoorden",
"security": "Beveiliging"
"security": "Beveiliging",
"files": "Bestanden"
},
"tab_groups": {
"general": "Algemeen",
"account": "Account",
"organization": "Organisatie"
"account": "Account & identiteit",
"organization": "E-mailorganisatie",
"apps": "Apps",
"system": "Systeem"
},
"appearance": {
"title": "Uiterlijk",
@@ -592,6 +613,10 @@
"permanent": "Permanent verwijderen",
"warning": "E-mails worden permanent verwijderd en kunnen niet worden hersteld. Deze actie is onomkeerbaar."
},
"permanently_delete_junk": {
"label": "Spam permanent verwijderen",
"description": "E-mails uit de map Spam permanent verwijderen in plaats van naar de prullenbak te verplaatsen"
},
"show_preview": {
"label": "Voorbeeldtekst tonen",
"description": "E-mailvoorbeeld weergeven in de lijst"
@@ -1041,6 +1066,58 @@
"empty": "Sjabloonnaam is verplicht",
"too_long": "Sjabloonnaam mag maximaal 200 tekens zijn"
}
},
"files": {
"display": {
"title": "Weergave",
"description": "Configureer hoe bestanden en mappen worden weergegeven"
},
"default_view": {
"label": "Standaardweergave",
"description": "Kies tussen raster- en lijstweergave",
"list": "Lijst",
"grid": "Raster"
},
"default_sort": {
"label": "Standaardsortering",
"description": "Kies de standaardsortering voor bestanden",
"name": "Naam",
"size": "Grootte",
"modified": "Gewijzigd"
},
"sort_direction": {
"label": "Sorteerrichting",
"description": "Kies oplopende of aflopende volgorde",
"ascending": "Oplopend",
"descending": "Aflopend"
},
"icons": {
"title": "Pictogrammen",
"description": "Configureer de weergave van bestandspictogrammen"
},
"show_icons": {
"label": "Bestandspictogrammen tonen",
"description": "Pictogrammen naast bestanden en mappen weergeven"
},
"colored_icons": {
"label": "Gekleurde pictogrammen",
"description": "Gebruik gekleurde pictogrammen in plaats van monochroom"
},
"show_thumbnails": {
"label": "Miniaturen weergeven",
"description": "Toon afbeeldingsvoorbeelden in plaats van pictogrammen"
},
"behavior": {
"title": "Gedrag",
"description": "Configureer het gedrag van de bestandsbrowser"
},
"show_hidden": {
"label": "Verborgen bestanden tonen",
"description": "Bestanden en mappen weergeven die beginnen met een punt"
},
"preview": {
"label": "Voorbeeld"
}
}
},
"errors": {
@@ -1194,7 +1271,9 @@
"identity_name": "Verzonden met identiteit: {name}",
"identity_short": "via {name}",
"subaddress_tag": "+{tag}"
}
},
"delete_button": "Verwijderen",
"delete_confirm_title": "Identiteit verwijderen"
},
"templates": {
"picker_title": "Kies een sjabloon",
@@ -1230,6 +1309,7 @@
"empty_search_hint": "Probeer een andere zoekterm",
"clear_search": "Zoekopdracht wissen",
"import_vcard": "vCard importeren",
"delete_confirm_title": "Contact verwijderen",
"delete_confirm": "Weet u zeker dat u dit contact wilt verwijderen?",
"local_mode": "Contacten worden lokaal opgeslagen (server ondersteunt geen JMAP Contacts)",
"back_to_mail": "Terug naar e-mail",
@@ -1370,12 +1450,14 @@
"name_required": "Ten minste een voor- of achternaam is vereist",
"email_invalid": "Voer een geldig e-mailadres in",
"email_error_inline": "Ongeldig e-mailformaat",
"save_failed": "Kon contact niet opslaan"
"save_failed": "Kon contact niet opslaan",
"delete": "Verwijderen"
},
"groups": {
"create": "Nieuwe groep",
"edit": "Groep bewerken",
"empty": "Geen groepen",
"delete_confirm_title": "Groep verwijderen",
"delete_confirm": "Weet u zeker dat u deze groep wilt verwijderen?",
"name_label": "Groepsnaam",
"name_placeholder": "bijv. Team, Familie",
@@ -1412,6 +1494,7 @@
"selected": "{count, plural, one {1 geselecteerd} other {# geselecteerd}}",
"select_all": "Alles selecteren",
"delete": "Verwijderen",
"delete_confirm_title": "Contacten verwijderen",
"delete_confirm": "{count, plural, one {1 contact} other {# contacten}} verwijderen?",
"deleted": "{count, plural, one {1 contact verwijderd} other {# contacten verwijderd}}",
"add_to_group": "Aan groep toevoegen",
@@ -1598,9 +1681,17 @@
"nav_next": "Volgende",
"import": {
"title": "Agenda importeren",
"tab_file": "Bestand",
"tab_url": "URL",
"select_file": "Selecteer .ics-bestand",
"drop_file": "of sleep het bestand hierheen",
"supported_formats": "iCalendar (.ics) bestanden worden ondersteund",
"url_description": "Voer de URL in van een externe iCalendar (.ics) feed om evenementen te importeren.",
"url_placeholder": "https://example.com/calendar.ics",
"url_hint": "Ondersteunt CalDAV en iCalendar (.ics) URLs",
"fetch": "Ophalen",
"invalid_url": "Voer een geldige URL in",
"url_fetch_failed": "Kan agenda niet ophalen van URL",
"parsing": "Agendabestand wordt verwerkt...",
"parsed_events": "{count} evenementen gevonden",
"no_events": "Geen evenementen gevonden in bestand",
@@ -1613,6 +1704,65 @@
"error": "Agenda importeren mislukt",
"file_too_large": "Bestand overschrijdt de limiet van 5 MB",
"invalid_format": "Ongeldig agendabestandsformaat"
},
"management": {
"title": "Agendabeheer",
"description": "Maak, hernoem en pas uw agenda's aan. Klik met de rechtermuisknop op een agenda in de zijbalk om snel de kleur te wijzigen.",
"name": "Naam",
"name_placeholder": "Agendanaam",
"color": "Kleur",
"change_color": "Kleur wijzigen",
"add_calendar": "Agenda toevoegen",
"edit": "Bewerken",
"delete": "Verwijderen",
"save": "Opslaan",
"create": "Aanmaken",
"cancel": "Annuleren",
"default": "Standaard",
"confirm_delete": "\"{name}\" verwijderen? Alle afspraken in deze agenda worden verwijderd.",
"calendar_created": "Agenda aangemaakt",
"calendar_updated": "Agenda bijgewerkt",
"calendar_deleted": "Agenda verwijderd",
"color_updated": "Agendakleur bijgewerkt",
"error_create": "Agenda aanmaken mislukt",
"error_update": "Agenda bijwerken mislukt",
"error_delete": "Agenda verwijderen mislukt",
"caldav_url": "CalDAV-URL",
"copy_url": "CalDAV-URL kopiëren",
"url_copied": "CalDAV-URL gekopieerd naar klembord",
"confirm_clear": "Alle afspraken uit \"{name}\" verwijderen? Dit kan niet ongedaan worden gemaakt.",
"clear_events": "Afspraken verwijderen",
"events_cleared": "{count} afspraken verwijderd",
"error_clear": "Kan agendagebeurtenissen niet verwijderen"
},
"subscription": {
"title": "iCal-abonnement",
"section_title": "iCal-abonnementen",
"description": "Abonneer op een externe iCalendar-feed. Afspraken worden automatisch gesynchroniseerd in een eigen agenda. Ondersteunt https://- en webcal://-URL's.",
"url_label": "Agenda-URL",
"url_placeholder": "https://example.com/calendar.ics of webcal://...",
"name_label": "Agendanaam",
"name_placeholder": "bijv. Feestdagen",
"color_label": "Kleur",
"refresh_interval": "Verversingsinterval",
"interval_15": "Elke 15 minuten",
"interval_30": "Elke 30 minuten",
"interval_60": "Elk uur",
"interval_360": "Elke 6 uur",
"interval_1440": "Elke dag",
"subscribe": "Abonneren",
"subscribing": "Bezig met abonneren...",
"invalid_url": "Voer een geldige URL in",
"success": "Geabonneerd op \"{name}\"",
"error": "Kan abonnement niet toevoegen",
"refresh": "Nu vernieuwen",
"refresh_success": "Abonnement vernieuwd",
"refresh_error": "Kan abonnement niet vernieuwen",
"unsubscribe": "Afmelden",
"confirm_delete": "Afmelden van \"{name}\"? De agenda en alle afspraken worden verwijderd.",
"deleted": "Abonnement verwijderd",
"delete_error": "Kan abonnement niet verwijderen",
"last_refreshed": "Laatst bijgewerkt: {time}"
}
},
"advanced_search": {
@@ -1654,5 +1804,103 @@
"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": "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",
"settings_title": "Bestandsinstellingen",
"settings_display": "Weergave",
"settings_default_view": "Standaardweergave",
"settings_default_view_desc": "Kies tussen raster- en lijstweergave",
"settings_default_sort": "Standaardsortering",
"settings_default_sort_desc": "Kies de standaardsortering voor bestanden",
"settings_sort_direction": "Sorteerrichting",
"settings_sort_direction_desc": "Kies oplopende of aflopende volgorde",
"settings_ascending": "Oplopend",
"settings_descending": "Aflopend",
"settings_icons": "Pictogrammen",
"settings_show_icons": "Bestandspictogrammen tonen",
"settings_show_icons_desc": "Pictogrammen naast bestanden en mappen weergeven",
"settings_colored_icons": "Gekleurde pictogrammen",
"settings_colored_icons_desc": "Gebruik gekleurde pictogrammen in plaats van monochroom",
"settings_show_thumbnails": "Miniaturen weergeven",
"settings_show_thumbnails_desc": "Toon afbeeldingsvoorbeelden in plaats van pictogrammen",
"settings_behavior": "Gedrag",
"settings_show_hidden": "Verborgen bestanden tonen",
"settings_show_hidden_desc": "Bestanden en mappen weergeven die beginnen met een punt"
}
}
+258 -10
View File
@@ -61,6 +61,7 @@
"contacts": "Contatos",
"calendar": "Calendário",
"settings": "Configurações",
"files": "Ficheiros",
"loading_mailboxes": "Carregando caixas de entrada...",
"push_connected": "Atualizações em tempo real ativas",
"push_disconnected": "Atualizações em tempo real inativas",
@@ -129,7 +130,15 @@
"permanent_delete": "Excluir permanentemente",
"permanent_delete_confirm_title": "Excluir permanentemente",
"permanent_delete_confirm_message": "Este e-mail será permanentemente excluído. Esta ação não pode ser desfeita.",
"permanent_delete_confirm_batch_message": "Estes {count, plural, one {1 e-mail} other {# e-mails}} serão permanentemente excluídos. Esta ação não pode ser desfeita."
"permanent_delete_confirm_batch_message": "Estes {count, plural, one {1 e-mail} other {# e-mails}} serão permanentemente excluídos. Esta ação não pode ser desfeita.",
"empty_folder": {
"button": "Esvaziar pasta",
"confirm_title": "Esvaziar pasta",
"confirm_message": "Todos os e-mails nesta pasta serão permanentemente excluídos. Esta ação não pode ser desfeita.",
"confirm_button": "Esvaziar pasta",
"junk_hint": "Você pode esvaziar a pasta de Spam para remover permanentemente todas as mensagens.",
"trash_hint": "Você pode esvaziar a Lixeira para remover permanentemente todas as mensagens."
}
},
"email_viewer": {
"no_email_selected": "Nenhum e-mail selecionado",
@@ -175,6 +184,7 @@
"set_color": "Definir etiqueta",
"tag": "Etiqueta",
"more_actions": "Mais ações",
"move_to": "Mover para...",
"remove_color": "Remover etiqueta",
"more_count": "+{count} mais",
"characters_count": "{count} caracteres",
@@ -250,7 +260,9 @@
"delete": "Excluir",
"star": "Favoritar (s)",
"unstar": "Remover favorito (s)",
"compose": "Compor (c)"
"compose": "Compor (c)",
"previous": "E-mail anterior",
"next": "Próximo e-mail"
},
"spam": {
"button_title": "Reportar spam",
@@ -290,7 +302,10 @@
"no_calendar": "Calendário não disponível",
"select_calendar": "Selecionar calendário",
"already_in_calendar": "Já está no seu calendário"
}
},
"previous": "Anterior",
"next": "Próximo",
"send": "Enviar"
},
"email_composer": {
"new_message": "Nova Mensagem",
@@ -352,7 +367,9 @@
"continue_draft": "Continuar rascunho",
"close_draft_title": "Salvar ou descartar rascunho?",
"close_draft_message": "Você tem alterações não salvas. Deseja salvar como rascunho ou descartar?",
"save_draft": "Salvar rascunho"
"save_draft": "Salvar rascunho",
"drop_files": "Solte arquivos para anexar",
"show_less": "Mostrar menos"
},
"confirm_dialog": {
"confirm": "Confirmar",
@@ -375,7 +392,8 @@
"yes": "Sim",
"no": "Não",
"unknown": "Desconhecido",
"app_title": "Webmail"
"app_title": "Webmail",
"reconnecting": "Conexão perdida. Tentando reconectar…"
},
"notifications": {
"email_sent": "E-mail enviado com sucesso",
@@ -481,12 +499,15 @@
"templates": "Modelos",
"folders": "Pastas",
"keywords": "Palavras-chave",
"security": "Segurança"
"security": "Segurança",
"files": "Arquivos"
},
"tab_groups": {
"general": "Geral",
"account": "Conta",
"organization": "Organização"
"account": "Conta e identidade",
"organization": "Organização de e-mail",
"apps": "Aplicativos",
"system": "Sistema"
},
"appearance": {
"title": "Aparência",
@@ -592,6 +613,10 @@
"permanent": "Excluir Permanentemente",
"warning": "Os e-mails serão excluídos permanentemente e não poderão ser recuperados. Esta ação é irreversível."
},
"permanently_delete_junk": {
"label": "Excluir spam permanentemente",
"description": "Excluir permanentemente e-mails da pasta Spam em vez de movê-los para a Lixeira"
},
"show_preview": {
"label": "Mostrar Texto de Visualização",
"description": "Exibir visualização do e-mail na lista"
@@ -1041,6 +1066,58 @@
"empty": "O nome do modelo é obrigatório",
"too_long": "O nome do modelo não pode exceder 200 caracteres"
}
},
"files": {
"display": {
"title": "Exibição",
"description": "Configure como arquivos e pastas são exibidos"
},
"default_view": {
"label": "Visualização padrão",
"description": "Escolha entre layout em grade e lista",
"list": "Lista",
"grid": "Grade"
},
"default_sort": {
"label": "Ordenação padrão",
"description": "Escolha a ordenação padrão para os arquivos",
"name": "Nome",
"size": "Tamanho",
"modified": "Modificado"
},
"sort_direction": {
"label": "Direção da ordenação",
"description": "Escolha ordem crescente ou decrescente",
"ascending": "Crescente",
"descending": "Decrescente"
},
"icons": {
"title": "Ícones",
"description": "Configure a aparência dos ícones de arquivos"
},
"show_icons": {
"label": "Mostrar ícones de arquivos",
"description": "Exibir ícones ao lado de arquivos e pastas"
},
"colored_icons": {
"label": "Ícones coloridos",
"description": "Usar ícones coloridos em vez de monocromáticos"
},
"show_thumbnails": {
"label": "Mostrar miniaturas",
"description": "Exibir pré-visualizações de imagens em vez de ícones"
},
"behavior": {
"title": "Comportamento",
"description": "Configure o comportamento do gerenciador de arquivos"
},
"show_hidden": {
"label": "Mostrar arquivos ocultos",
"description": "Exibir arquivos e pastas que começam com um ponto"
},
"preview": {
"label": "Visualização"
}
}
},
"errors": {
@@ -1194,7 +1271,9 @@
"identity_name": "Enviado usando identidade: {name}",
"identity_short": "via {name}",
"subaddress_tag": "+{tag}"
}
},
"delete_button": "Excluir",
"delete_confirm_title": "Excluir identidade"
},
"templates": {
"picker_title": "Escolher um modelo",
@@ -1230,6 +1309,7 @@
"empty_search_hint": "Tente outro termo de pesquisa",
"clear_search": "Limpar pesquisa",
"import_vcard": "Importar vCard",
"delete_confirm_title": "Excluir contato",
"delete_confirm": "Tem certeza de que deseja excluir este contato?",
"local_mode": "Os contatos são armazenados localmente (o servidor não suporta JMAP Contacts)",
"back_to_mail": "Voltar ao e-mail",
@@ -1370,12 +1450,14 @@
"name_required": "É necessário pelo menos um nome ou sobrenome",
"email_invalid": "Por favor, insira um endereço de e-mail válido",
"email_error_inline": "Formato de e-mail inválido",
"save_failed": "Falha ao salvar contato"
"save_failed": "Falha ao salvar contato",
"delete": "Excluir"
},
"groups": {
"create": "Novo grupo",
"edit": "Editar grupo",
"empty": "Nenhum grupo",
"delete_confirm_title": "Excluir grupo",
"delete_confirm": "Tem certeza de que deseja excluir este grupo?",
"name_label": "Nome do grupo",
"name_placeholder": "ex. Equipe, Família",
@@ -1412,6 +1494,7 @@
"selected": "{count, plural, one {1 selecionado} other {# selecionados}}",
"select_all": "Selecionar tudo",
"delete": "Excluir",
"delete_confirm_title": "Excluir contatos",
"delete_confirm": "Excluir {count, plural, one {1 contato} other {# contatos}}?",
"deleted": "{count, plural, one {1 contato excluído} other {# contatos excluídos}}",
"add_to_group": "Adicionar ao grupo",
@@ -1598,9 +1681,17 @@
"nav_next": "Próximo",
"import": {
"title": "Importar calendário",
"tab_file": "Arquivo",
"tab_url": "URL",
"select_file": "Selecionar arquivo .ics",
"drop_file": "ou arraste o arquivo aqui",
"supported_formats": "Arquivos iCalendar (.ics) suportados",
"url_description": "Insira a URL de um feed iCalendar (.ics) externo para importar eventos.",
"url_placeholder": "https://example.com/calendar.ics",
"url_hint": "Suporta URLs CalDAV e iCalendar (.ics)",
"fetch": "Buscar",
"invalid_url": "Insira uma URL válida",
"url_fetch_failed": "Não foi possível buscar o calendário da URL",
"parsing": "Analisando arquivo de calendário...",
"parsed_events": "{count} eventos encontrados",
"no_events": "Nenhum evento encontrado no arquivo",
@@ -1613,6 +1704,65 @@
"error": "Falha ao importar calendário",
"file_too_large": "Arquivo excede o limite de 5 MB",
"invalid_format": "Formato de arquivo de calendário inválido"
},
"management": {
"title": "Gerenciamento de calendários",
"description": "Crie, renomeie e personalize seus calendários. Clique com o botão direito em um calendário na barra lateral para alterar rapidamente sua cor.",
"name": "Nome",
"name_placeholder": "Nome do calendário",
"color": "Cor",
"change_color": "Alterar cor",
"add_calendar": "Adicionar calendário",
"edit": "Editar",
"delete": "Excluir",
"save": "Salvar",
"create": "Criar",
"cancel": "Cancelar",
"default": "Padrão",
"confirm_delete": "Excluir \"{name}\"? Todos os eventos neste calendário serão removidos.",
"calendar_created": "Calendário criado",
"calendar_updated": "Calendário atualizado",
"calendar_deleted": "Calendário excluído",
"color_updated": "Cor do calendário atualizada",
"error_create": "Falha ao criar calendário",
"error_update": "Falha ao atualizar calendário",
"error_delete": "Falha ao excluir calendário",
"caldav_url": "URL CalDAV",
"copy_url": "Copiar URL CalDAV",
"url_copied": "URL CalDAV copiada para a área de transferência",
"confirm_clear": "Limpar todos os eventos de \"{name}\"? Esta ação não pode ser desfeita.",
"clear_events": "Limpar eventos",
"events_cleared": "{count} eventos removidos",
"error_clear": "Falha ao limpar os eventos do calendário"
},
"subscription": {
"title": "Assinatura iCal",
"section_title": "Assinaturas iCal",
"description": "Assine um feed externo do iCalendar. Os eventos serão sincronizados automaticamente em seu próprio calendário. Suporta URLs https:// e webcal://.",
"url_label": "URL do calendário",
"url_placeholder": "https://example.com/calendar.ics ou webcal://...",
"name_label": "Nome do calendário",
"name_placeholder": "ex. Feriados",
"color_label": "Cor",
"refresh_interval": "Intervalo de atualização",
"interval_15": "A cada 15 minutos",
"interval_30": "A cada 30 minutos",
"interval_60": "A cada hora",
"interval_360": "A cada 6 horas",
"interval_1440": "Diariamente",
"subscribe": "Assinar",
"subscribing": "Assinando...",
"invalid_url": "Por favor, insira uma URL válida",
"success": "Assinado \"{name}\"",
"error": "Falha ao adicionar assinatura",
"refresh": "Atualizar agora",
"refresh_success": "Assinatura atualizada",
"refresh_error": "Falha ao atualizar assinatura",
"unsubscribe": "Cancelar assinatura",
"confirm_delete": "Cancelar assinatura de \"{name}\"? O calendário e todos os seus eventos serão removidos.",
"deleted": "Assinatura removida",
"delete_error": "Falha ao remover assinatura",
"last_refreshed": "Última atualização: {time}"
}
},
"advanced_search": {
@@ -1654,5 +1804,103 @@
"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 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",
"settings_title": "Configurações de arquivos",
"settings_display": "Exibição",
"settings_default_view": "Visualização padrão",
"settings_default_view_desc": "Escolha entre layout em grade e lista",
"settings_default_sort": "Ordenação padrão",
"settings_default_sort_desc": "Escolha a ordenação padrão para os arquivos",
"settings_sort_direction": "Direção da ordenação",
"settings_sort_direction_desc": "Escolha ordem crescente ou decrescente",
"settings_ascending": "Crescente",
"settings_descending": "Decrescente",
"settings_icons": "Ícones",
"settings_show_icons": "Mostrar ícones de arquivos",
"settings_show_icons_desc": "Exibir ícones ao lado de arquivos e pastas",
"settings_colored_icons": "Ícones coloridos",
"settings_colored_icons_desc": "Usar ícones coloridos em vez de monocromáticos",
"settings_show_thumbnails": "Mostrar miniaturas",
"settings_show_thumbnails_desc": "Exibir pré-visualizações de imagens em vez de ícones",
"settings_behavior": "Comportamento",
"settings_show_hidden": "Mostrar arquivos ocultos",
"settings_show_hidden_desc": "Exibir arquivos e pastas que começam com um ponto"
}
}
+112
View File
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useAuthStore } from '../auth-store';
import { useIdentityStore } from '../identity-store';
import type { Identity } from '@/lib/jmap/types';
const makeIdentity = (overrides: Partial<Identity> = {}): Identity => ({
id: 'id-1',
name: 'Test User',
email: 'test@example.com',
mayDelete: true,
...overrides,
});
describe('auth-store syncIdentities', () => {
beforeEach(() => {
useIdentityStore.setState({
identities: [],
selectedIdentityId: null,
isLoading: false,
error: null,
subAddress: { recentTags: [], tagSuggestions: {} },
});
useAuthStore.setState({
identities: [],
primaryIdentity: null,
});
});
it('should copy identities from identity store to auth store', () => {
const identities = [
makeIdentity({ id: 'id-1', name: 'Alice' }),
makeIdentity({ id: 'id-2', name: 'Bob', email: 'bob@example.com' }),
];
useIdentityStore.getState().setIdentities(identities);
useAuthStore.getState().syncIdentities();
expect(useAuthStore.getState().identities).toHaveLength(2);
expect(useAuthStore.getState().identities[0].name).toBe('Alice');
expect(useAuthStore.getState().identities[1].name).toBe('Bob');
});
it('should set primaryIdentity to first identity', () => {
const identities = [
makeIdentity({ id: 'id-1', name: 'Primary' }),
makeIdentity({ id: 'id-2', name: 'Secondary' }),
];
useIdentityStore.getState().setIdentities(identities);
useAuthStore.getState().syncIdentities();
expect(useAuthStore.getState().primaryIdentity?.id).toBe('id-1');
expect(useAuthStore.getState().primaryIdentity?.name).toBe('Primary');
});
it('should set primaryIdentity to null when no identities', () => {
// Auth store starts with an identity
useAuthStore.setState({
identities: [makeIdentity()],
primaryIdentity: makeIdentity(),
});
useIdentityStore.getState().setIdentities([]);
useAuthStore.getState().syncIdentities();
expect(useAuthStore.getState().identities).toEqual([]);
expect(useAuthStore.getState().primaryIdentity).toBeNull();
});
it('should reflect identity store changes after addIdentity', () => {
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1' })]);
useAuthStore.getState().syncIdentities();
expect(useAuthStore.getState().identities).toHaveLength(1);
useIdentityStore.getState().addIdentity(makeIdentity({ id: 'id-2', email: 'new@example.com' }));
useAuthStore.getState().syncIdentities();
expect(useAuthStore.getState().identities).toHaveLength(2);
});
it('should reflect identity store changes after removeIdentity', () => {
useIdentityStore.getState().setIdentities([
makeIdentity({ id: 'id-1' }),
makeIdentity({ id: 'id-2' }),
]);
useAuthStore.getState().syncIdentities();
expect(useAuthStore.getState().identities).toHaveLength(2);
useIdentityStore.getState().removeIdentity('id-1');
useAuthStore.getState().syncIdentities();
expect(useAuthStore.getState().identities).toHaveLength(1);
expect(useAuthStore.getState().identities[0].id).toBe('id-2');
expect(useAuthStore.getState().primaryIdentity?.id).toBe('id-2');
});
it('should reflect identity store changes after updateIdentityLocal', () => {
useIdentityStore.getState().setIdentities([
makeIdentity({ id: 'id-1', name: 'Old Name', textSignature: '' }),
]);
useAuthStore.getState().syncIdentities();
useIdentityStore.getState().updateIdentityLocal('id-1', {
name: 'New Name',
textSignature: 'Regards, Me',
});
useAuthStore.getState().syncIdentities();
expect(useAuthStore.getState().identities[0].name).toBe('New Name');
expect(useAuthStore.getState().identities[0].textSignature).toBe('Regards, Me');
});
});
+37 -16
View File
@@ -8,6 +8,7 @@ import { useVacationStore } from './vacation-store';
import { useCalendarStore } from './calendar-store';
import { useFilterStore } from './filter-store';
import { useSettingsStore } from './settings-store';
import { fetchConfig } from '@/hooks/use-config';
import { debug } from '@/lib/debug';
import type { Identity } from '@/lib/jmap/types';
@@ -32,6 +33,7 @@ interface AuthState {
logout: () => void;
checkAuth: () => Promise<void>;
clearError: () => void;
syncIdentities: () => void;
}
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
@@ -163,10 +165,13 @@ export const useAuthStore = create<AuthState>()(
error: null,
});
// Sync settings from server
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
useSettingsStore.getState().enableSync(username, serverUrl);
});
// Sync settings from server (only if enabled)
fetchConfig().then(config => {
if (!config.settingsSyncEnabled) return;
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
useSettingsStore.getState().enableSync(username, serverUrl);
});
}).catch(() => {});
if (rememberMe) {
try {
@@ -242,10 +247,13 @@ export const useAuthStore = create<AuthState>()(
scheduleRefresh(expires_in, get().refreshAccessToken);
// Sync settings from server
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
useSettingsStore.getState().enableSync(username, serverUrl);
});
// Sync settings from server (only if enabled)
fetchConfig().then(config => {
if (!config.settingsSyncEnabled) return;
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
useSettingsStore.getState().enableSync(username, serverUrl);
});
}).catch(() => {});
return true;
} catch (error) {
@@ -393,10 +401,13 @@ export const useAuthStore = create<AuthState>()(
accessToken: token,
});
// Sync settings from server
useSettingsStore.getState().loadFromServer(state.username || '', state.serverUrl).finally(() => {
useSettingsStore.getState().enableSync(state.username || '', state.serverUrl!);
});
// Sync settings from server (only if enabled)
fetchConfig().then(config => {
if (!config.settingsSyncEnabled) return;
useSettingsStore.getState().loadFromServer(state.username || '', state.serverUrl!).finally(() => {
useSettingsStore.getState().enableSync(state.username || '', state.serverUrl!);
});
}).catch(() => {});
return;
}
} catch (error) {
@@ -436,10 +447,13 @@ export const useAuthStore = create<AuthState>()(
authMode: 'basic',
});
// Sync settings from server
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
useSettingsStore.getState().enableSync(username, serverUrl);
});
// Sync settings from server (only if enabled)
fetchConfig().then(config => {
if (!config.settingsSyncEnabled) return;
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
useSettingsStore.getState().enableSync(username, serverUrl);
});
}).catch(() => {});
return;
}
} catch (error) {
@@ -466,6 +480,13 @@ export const useAuthStore = create<AuthState>()(
},
clearError: () => set({ error: null }),
syncIdentities: () => {
const identityState = useIdentityStore.getState();
const identities = identityState.identities;
const primaryIdentity = identities[0] ?? null;
set({ identities, primaryIdentity });
},
}),
{
name: 'auth-storage',
+275
View File
@@ -6,6 +6,16 @@ import { debug } from '@/lib/debug';
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda';
export interface ICalSubscription {
id: string;
url: string;
calendarId: string;
name: string;
color: string;
refreshInterval: number; // minutes
lastRefreshed: string | null;
}
interface CalendarStore {
calendars: Calendar[];
events: CalendarEvent[];
@@ -27,11 +37,23 @@ interface CalendarStore {
deleteEvent: (client: JMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
rsvpEvent: (client: JMAPClient, eventId: string, participantId: string, status: string) => Promise<void>;
importEvents: (client: JMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
updateCalendar: (client: JMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
createCalendar: (client: JMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
removeCalendar: (client: JMAPClient, calendarId: string) => Promise<void>;
clearCalendarEvents: (client: JMAPClient, calendarId: string) => Promise<number>;
setSelectedDate: (date: Date) => void;
setViewMode: (mode: CalendarViewMode) => void;
toggleCalendarVisibility: (calendarId: string) => void;
setSelectedEventId: (id: string | null) => void;
clearState: () => void;
// iCal subscriptions
icalSubscriptions: ICalSubscription[];
addICalSubscription: (client: JMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>;
removeICalSubscription: (client: JMAPClient, subscriptionId: string) => Promise<void>;
refreshICalSubscription: (client: JMAPClient, subscriptionId: string) => Promise<void>;
refreshAllSubscriptions: (client: JMAPClient) => Promise<void>;
isSubscriptionCalendar: (calendarId: string) => boolean;
}
const initialState = {
@@ -45,6 +67,7 @@ const initialState = {
supportsCalendar: false,
error: null as string | null,
dateRange: null as { start: string; end: string } | null,
icalSubscriptions: [] as ICalSubscription[],
};
export const useCalendarStore = create<CalendarStore>()(
@@ -265,6 +288,92 @@ export const useCalendarStore = create<CalendarStore>()(
setSelectedDate: (date) => set({ selectedDate: date }),
setViewMode: (mode) => set({ viewMode: mode }),
updateCalendar: async (client, calendarId, updates) => {
set({ error: null });
try {
await client.updateCalendar(calendarId, updates);
set((state) => ({
calendars: state.calendars.map(c =>
c.id === calendarId ? { ...c, ...updates } : c
),
}));
} catch (error) {
debug.error('Failed to update calendar:', error);
set({ error: 'Failed to update calendar' });
throw error;
}
},
createCalendar: async (client, calendar) => {
set({ error: null });
try {
const created = await client.createCalendar(calendar);
set((state) => ({
calendars: [...state.calendars, created],
selectedCalendarIds: [...state.selectedCalendarIds, created.id],
}));
return created;
} catch (error) {
debug.error('Failed to create calendar:', error);
set({ error: 'Failed to create calendar' });
return null;
}
},
removeCalendar: async (client, calendarId) => {
set({ error: null });
try {
await client.deleteCalendar(calendarId);
set((state) => ({
calendars: state.calendars.filter(c => c.id !== calendarId),
selectedCalendarIds: state.selectedCalendarIds.filter(id => id !== calendarId),
events: state.events.filter(e => !e.calendarIds?.[calendarId]),
}));
} catch (error) {
debug.error('Failed to delete calendar:', error);
set({ error: 'Failed to delete calendar' });
throw error;
}
},
clearCalendarEvents: async (client, calendarId) => {
set({ error: null });
try {
let totalDeleted = 0;
// Loop to handle pagination (getCalendarEvents has a 1000 limit)
let hasMore = true;
while (hasMore) {
// Query all events and filter client-side by calendarId
// to avoid relying on server-side inCalendars filter support
const allEvents = await client.getCalendarEvents();
const calendarEvents = allEvents.filter(e => e.calendarIds?.[calendarId]);
if (calendarEvents.length === 0) break;
const ids = calendarEvents.map(e => e.id);
const { destroyed } = await client.batchDeleteCalendarEvents(ids);
totalDeleted += destroyed.length;
// If we couldn't destroy any events, stop to avoid infinite loop
if (destroyed.length === 0) {
debug.warn('Could not delete any events, stopping clear loop. Not destroyed:', ids.length);
break;
}
// If we got fewer than the limit, we've fetched everything
if (allEvents.length < 1000) hasMore = false;
}
set((state) => ({
events: state.events.filter(e => !e.calendarIds?.[calendarId]),
}));
return totalDeleted;
} catch (error) {
debug.error('Failed to clear calendar events:', error);
set({ error: 'Failed to clear calendar events' });
throw error;
}
},
toggleCalendarVisibility: (calendarId) => set((state) => {
const ids = state.selectedCalendarIds;
return {
@@ -276,6 +385,171 @@ export const useCalendarStore = create<CalendarStore>()(
setSelectedEventId: (id) => set({ selectedEventId: id }),
// iCal subscriptions
isSubscriptionCalendar: (calendarId) => {
return get().icalSubscriptions.some(s => s.calendarId === calendarId);
},
addICalSubscription: async (client, url, name, color, refreshInterval = 60) => {
try {
// Create a new calendar for this subscription
const calendar = await client.createCalendar({
name,
color,
isVisible: true,
isSubscribed: true,
});
if (!calendar) throw new Error('Failed to create calendar');
const subscription: ICalSubscription = {
id: crypto.randomUUID(),
url,
calendarId: calendar.id,
name,
color,
refreshInterval,
lastRefreshed: null,
};
set((state) => ({
calendars: [...state.calendars, calendar],
selectedCalendarIds: [...state.selectedCalendarIds, calendar.id],
icalSubscriptions: [...state.icalSubscriptions, subscription],
}));
// Do initial fetch
try {
await get().refreshICalSubscription(client, subscription.id);
} catch {
// Subscription created, initial fetch failed - user can retry
debug.warn('Initial subscription fetch failed for:', name);
}
return subscription;
} catch (error) {
debug.error('Failed to add iCal subscription:', error);
return null;
}
},
removeICalSubscription: async (client, subscriptionId) => {
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
if (!sub) return;
try {
await client.deleteCalendar(sub.calendarId);
} catch (error) {
debug.error('Failed to delete subscription calendar:', error);
// Continue removing subscription record even if calendar delete fails
}
set((state) => ({
icalSubscriptions: state.icalSubscriptions.filter(s => s.id !== subscriptionId),
calendars: state.calendars.filter(c => c.id !== sub.calendarId),
selectedCalendarIds: state.selectedCalendarIds.filter(id => id !== sub.calendarId),
events: state.events.filter(e => !e.calendarIds?.[sub.calendarId]),
}));
},
refreshICalSubscription: async (client, subscriptionId) => {
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
if (!sub) return;
try {
const response = await fetch('/api/fetch-ical', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: sub.url }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Failed to fetch calendar');
}
const blob = await response.blob();
const file = new File([blob], 'subscription.ics', { type: 'text/calendar' });
const uploaded = await client.uploadBlob(file);
const accountId = client.getCalendarsAccountId();
const parsedEvents = await client.parseCalendarEvents(accountId, uploaded.blobId);
// Fetch ALL server-side events and filter client-side for this calendar
// (avoids relying on server-side inCalendars filter support)
const allServerEvents = await client.getCalendarEvents();
const serverEvents = allServerEvents.filter(e => e.calendarIds?.[sub.calendarId]);
// Build a map of incoming UIDs for diffing
const incomingUids = new Set(parsedEvents.map(e => e.uid).filter(Boolean));
// Build a map of existing UIDs on server
const existingByUid = new Map<string, CalendarEvent[]>();
for (const e of serverEvents) {
if (e.uid) {
const list = existingByUid.get(e.uid) || [];
list.push(e);
existingByUid.set(e.uid, list);
}
}
// Delete events that are no longer in the feed
const idsToDelete = serverEvents
.filter(e => !e.uid || !incomingUids.has(e.uid))
.map(e => e.id);
if (idsToDelete.length > 0) {
await client.batchDeleteCalendarEvents(idsToDelete);
}
// Import only events that don't already exist on server
const eventsToImport = parsedEvents.filter(e => !e.uid || !existingByUid.has(e.uid));
// Remove stale local events for this calendar
set((state) => ({
events: state.events.filter(e => !e.calendarIds?.[sub.calendarId]),
}));
// Import new events
if (eventsToImport.length > 0) {
await get().importEvents(client, eventsToImport, sub.calendarId);
}
// Re-fetch ALL events from server and filter for this calendar
const allUpdatedEvents = await client.getCalendarEvents();
const updatedEvents = allUpdatedEvents.filter(e => e.calendarIds?.[sub.calendarId]);
set((state) => {
const otherEvents = state.events.filter(e => !e.calendarIds?.[sub.calendarId]);
return { events: [...otherEvents, ...updatedEvents] };
});
// Update last refreshed timestamp
set((state) => ({
icalSubscriptions: state.icalSubscriptions.map(s =>
s.id === subscriptionId ? { ...s, lastRefreshed: new Date().toISOString() } : s
),
}));
} catch (error) {
debug.error('Failed to refresh iCal subscription:', sub.name, error);
throw error;
}
},
refreshAllSubscriptions: async (client) => {
const { icalSubscriptions } = get();
const now = Date.now();
for (const sub of icalSubscriptions) {
const lastRefreshed = sub.lastRefreshed ? new Date(sub.lastRefreshed).getTime() : 0;
const intervalMs = sub.refreshInterval * 60 * 1000;
if (now - lastRefreshed >= intervalMs) {
try {
await get().refreshICalSubscription(client, sub.id);
} catch {
debug.warn('Failed to refresh subscription:', sub.name);
}
}
}
},
clearState: () => {
set({
...initialState,
@@ -291,6 +565,7 @@ export const useCalendarStore = create<CalendarStore>()(
partialize: (state) => ({
selectedCalendarIds: state.selectedCalendarIds,
viewMode: state.viewMode,
icalSubscriptions: state.icalSubscriptions,
}),
}
)
+44 -5
View File
@@ -247,13 +247,27 @@ export const useContactStore = create<ContactStore>()(
const { contacts } = get();
const group = contacts.find(c => c.id === groupId);
if (!group?.members) return [];
const memberIds = Object.keys(group.members).filter(k => group.members![k]);
return contacts.filter(c => memberIds.includes(c.id) || memberIds.includes(c.uid || ''));
const memberKeys = Object.keys(group.members).filter(k => group.members![k]);
// Normalize: strip urn:uuid: prefix for matching
const normalizedKeys = memberKeys.map(k => k.startsWith('urn:uuid:') ? k.slice(9) : k);
return contacts.filter(c => {
if (memberKeys.includes(c.id) || normalizedKeys.includes(c.id)) return true;
if (c.uid) {
const bareUid = c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid;
return memberKeys.includes(c.uid) || normalizedKeys.includes(bareUid);
}
return false;
});
},
createGroup: async (client, name, memberIds) => {
const { contacts } = get();
const members: Record<string, boolean> = {};
memberIds.forEach(id => { members[id] = true; });
memberIds.forEach(id => {
const contact = contacts.find(c => c.id === id);
const key = contact?.uid || id;
members[key] = true;
});
const groupData: Partial<ContactCard> = {
kind: 'group',
@@ -294,7 +308,11 @@ export const useContactStore = create<ContactStore>()(
if (!group) return;
const newMembers = { ...group.members };
memberIds.forEach(id => { newMembers[id] = true; });
memberIds.forEach(id => {
const contact = contacts.find(c => c.id === id);
const key = contact?.uid || id;
newMembers[key] = true;
});
const updates: Partial<ContactCard> = { members: newMembers };
if (client && get().supportsSync) {
@@ -313,7 +331,28 @@ export const useContactStore = create<ContactStore>()(
if (!group?.members) return;
const newMembers = { ...group.members };
memberIds.forEach(id => { delete newMembers[id]; });
memberIds.forEach(id => {
// Try direct id match first
if (newMembers[id] !== undefined) {
delete newMembers[id];
return;
}
// Try uid-based match
const contact = contacts.find(c => c.id === id);
if (contact?.uid && newMembers[contact.uid] !== undefined) {
delete newMembers[contact.uid];
} else {
// Try stripping urn:uuid: prefix matching
for (const key of Object.keys(newMembers)) {
const bareKey = key.startsWith('urn:uuid:') ? key.slice(9) : key;
const bareUid = contact?.uid?.startsWith('urn:uuid:') ? contact.uid.slice(9) : contact?.uid;
if (bareKey === id || bareKey === bareUid) {
delete newMembers[key];
break;
}
}
}
});
const updates: Partial<ContactCard> = { members: newMembers };
if (client && get().supportsSync) {
+40 -3
View File
@@ -61,7 +61,7 @@ interface EmailStore {
loadMoreEmails: (client: JMAPClient) => Promise<void>;
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: JMAPClient) => Promise<void>;
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string) => Promise<void>;
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string) => Promise<void>;
deleteEmail: (client: JMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>;
@@ -102,6 +102,7 @@ interface EmailStore {
renameMailbox: (client: JMAPClient, mailboxId: string, name: string) => Promise<void>;
deleteMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
setMailboxRole: (client: JMAPClient, mailboxId: string, role: string | null) => Promise<void>;
emptyMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
// Mock data for demo
loadMockData: () => void;
@@ -386,10 +387,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName) => {
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody) => {
set({ isLoading: true, error: null });
try {
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName);
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody);
// Refresh handled by UI layer for immediate feedback
set({ isLoading: false });
} catch (error) {
@@ -411,6 +412,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Get delete action preference from settings
const deleteAction = useSettingsStore.getState().deleteAction;
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
// Determine accountId for shared folders
const selectedMailboxId = get().selectedMailbox;
@@ -418,6 +420,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
// If in junk folder and setting is enabled, permanently delete
const isInJunk = currentMailbox?.role === 'junk';
if (isInJunk && permanentlyDeleteJunk) {
forceDelete = true;
}
// If deleteAction is 'trash' and not forced permanent delete, try to move to trash mailbox
if (deleteAction === 'trash' && !forceDelete) {
// Find trash mailbox for the correct account
@@ -1287,6 +1295,35 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
emptyMailbox: async (client, mailboxId) => {
try {
set({ isLoading: true, error: null });
await client.emptyMailbox(mailboxId);
// Clear emails from local state if we're viewing this mailbox
const currentMailbox = get().selectedMailbox;
if (currentMailbox === mailboxId) {
set({ emails: [], selectedEmail: null });
}
// Update mailbox counters
set({
mailboxes: get().mailboxes.map(mb =>
mb.id === mailboxId
? { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 }
: mb
),
isLoading: false,
});
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to empty folder',
isLoading: false,
});
throw error;
}
},
loadMockData: () => {
const mockEmails: Email[] = [
{
+757
View File
@@ -0,0 +1,757 @@
import { create } from 'zustand';
import type { JMAPClient } from '@/lib/jmap/client';
import type { FileNode } from '@/lib/jmap/types';
export interface FileResource {
id: string;
name: string;
serverName: string;
isDirectory: boolean;
contentType: string;
contentLength: number;
lastModified: string;
blobId: string | null;
parentId: string | null;
}
interface UploadProgress {
name: string;
loaded: number;
total: number;
current: number;
totalFiles: number;
}
interface ClipboardState {
mode: 'cut' | 'copy';
ids: string[];
names: string[];
serverNames: string[];
sourceParentId: string | null;
sourcePath: string;
}
interface UndoAction {
type: 'rename' | 'move';
entries: { id: string; from: Partial<Pick<FileNode, 'name' | 'parentId'>>; to: Partial<Pick<FileNode, 'name' | 'parentId'>> }[];
sourceParentId: string | null;
}
interface FileState {
currentParentId: string | null;
currentPath: string;
pathStack: { id: string | null; name: string }[];
resources: FileResource[];
isLoading: boolean;
error: string | null;
supportsFiles: boolean | null;
selectedResources: Set<string>;
uploadProgress: UploadProgress | null;
client: JMAPClient | null;
clipboard: ClipboardState | null;
uploadAbortController: AbortController | null;
favorites: string[];
recentFiles: { name: string; id: string; timestamp: number }[];
lastAction: UndoAction | null;
// Actions
initClient: (client: JMAPClient) => void;
checkSupport: () => Promise<boolean>;
navigate: (parentId: string | null, name?: string) => Promise<void>;
navigateByPath: (path: string) => Promise<void>;
navigateUp: () => Promise<void>;
refresh: () => Promise<void>;
createDirectory: (name: string) => Promise<void>;
uploadFile: (file: File) => Promise<void>;
uploadFiles: (files: File[]) => Promise<void>;
uploadFolder: (files: File[]) => Promise<void>;
cancelUpload: () => void;
deleteResource: (name: string) => Promise<void>;
deleteResources: (names: string[]) => Promise<void>;
renameResource: (oldName: string, newName: string) => Promise<void>;
downloadResource: (name: string) => Promise<void>;
downloadResources: (names: string[]) => Promise<void>;
getImageUrl: (name: string) => Promise<string>;
getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>;
createTextFile: (name: string) => Promise<void>;
duplicateResource: (name: string) => Promise<void>;
moveToFolder: (names: string[], targetFolder: string) => Promise<void>;
moveToParent: (names: string[]) => Promise<void>;
cutResources: (names: string[]) => void;
copyResources: (names: string[]) => void;
pasteResources: () => Promise<void>;
selectResource: (name: string | null) => void;
toggleSelect: (name: string) => void;
selectAll: () => void;
clearSelection: () => void;
setSelection: (names: Set<string>) => void;
listPath: (path: string) => Promise<FileResource[]>;
listByParentId: (parentId: string | null) => Promise<FileResource[]>;
toggleFavorite: (path: string) => void;
addRecentFile: (name: string, id: string) => void;
undoLastAction: () => Promise<void>;
}
const DIRECTORY_TYPES = new Set(['d', 'application/x-directory', 'text/directory', 'httpd/unix-directory', 'inode/directory']);
// Stalwart rejects "/" in file names, so we use Unicode DIVISION SLASH as the
// path separator when encoding folder hierarchy into flat file names.
const PATH_SEP = '\u2215'; //
function isDirectoryType(type: string | undefined): boolean {
if (!type) return false;
return DIRECTORY_TYPES.has(type) || type.includes('directory');
}
// Convert currentPath to a server-side name prefix for filtering
// "/" -> "", "/test" -> "test", "/test/sub" -> "testsub"
function getPathPrefix(currentPath: string): string {
if (currentPath === '/') return '';
return currentPath.slice(1).replace(/\//g, PATH_SEP) + PATH_SEP;
}
// Filter nodes to only direct children of a path prefix
function filterNodesByPrefix(nodes: FileNode[], prefix: string): FileNode[] {
if (prefix === '') {
// Root: nodes whose names have no PATH_SEP
return nodes.filter(n => !n.name.includes(PATH_SEP));
}
// Subfolder: nodes starting with prefix, with no additional PATH_SEP after the prefix
return nodes.filter(n => {
if (!n.name.startsWith(prefix)) return false;
const remaining = n.name.slice(prefix.length);
return remaining.length > 0 && !remaining.includes(PATH_SEP);
});
}
function nodeToResource(node: FileNode, pathPrefix: string = ''): FileResource {
const displayName = pathPrefix && node.name.startsWith(pathPrefix)
? node.name.slice(pathPrefix.length)
: node.name;
const isDir = isDirectoryType(node.type);
return {
id: node.id,
name: displayName,
serverName: node.name,
isDirectory: isDir,
contentType: isDir ? '' : node.type,
contentLength: node.size,
lastModified: node.updated || node.created,
blobId: node.blobId,
parentId: node.parentId,
};
}
function getUniqueName(name: string, existingNames: Set<string>): string {
if (!existingNames.has(name)) return name;
const dotIndex = name.lastIndexOf('.');
const base = dotIndex > 0 ? name.substring(0, dotIndex) : name;
const ext = dotIndex > 0 ? name.substring(dotIndex) : '';
let counter = 1;
while (existingNames.has(`${base} (${counter})${ext}`)) counter++;
return `${base} (${counter})${ext}`;
}
function buildPathFromStack(stack: { id: string | null; name: string }[]): string {
if (stack.length <= 1) return '/';
return '/' + stack.slice(1).map(s => s.name).join('/');
}
export const useFileStore = create<FileState>((set, get) => ({
currentParentId: null,
currentPath: '/',
pathStack: [{ id: null, name: '' }],
resources: [],
isLoading: false,
error: null,
supportsFiles: null,
selectedResources: new Set<string>(),
uploadProgress: null,
client: null,
clipboard: null,
uploadAbortController: null,
lastAction: null,
favorites: (() => {
try { return JSON.parse(localStorage.getItem('files-favorites') || '[]'); } catch { return []; }
})(),
recentFiles: (() => {
try { return JSON.parse(localStorage.getItem('files-recent-files') || '[]'); } catch { return []; }
})(),
initClient: (client: JMAPClient) => {
set({ client });
},
checkSupport: async () => {
const { client } = get();
if (!client) {
set({ supportsFiles: false });
return false;
}
// First check capability, then probe with a real request
const supported = await client.probeFileNodeSupport();
if (!supported) {
console.warn('[Files] JMAP FileNode not supported. Available capabilities:', Object.keys(client.getCapabilities()));
}
set({ supportsFiles: supported });
return supported;
},
navigate: async (parentId: string | null, name?: string) => {
const { client, pathStack } = get();
if (!client) return;
set({ isLoading: true, error: null, currentParentId: parentId, selectedResources: new Set() });
// Update path stack
let newStack: { id: string | null; name: string }[];
if (parentId === null) {
newStack = [{ id: null, name: '' }];
} else {
// Check if navigating to a parent in the stack
const existingIdx = pathStack.findIndex(s => s.id === parentId);
if (existingIdx >= 0) {
newStack = pathStack.slice(0, existingIdx + 1);
} else {
newStack = [...pathStack, { id: parentId, name: name || parentId }];
}
}
const newPath = buildPathFromStack(newStack);
set({ pathStack: newStack, currentPath: newPath });
try { localStorage.setItem('files-last-parent-id', parentId || ''); } catch { /* ignore */ }
try { localStorage.setItem('files-path-stack', JSON.stringify(newStack)); } catch { /* ignore */ }
try {
// Always fetch all nodes from root — Stalwart doesn't support parentId nesting
const allNodes = await client.listFileNodes(null);
const prefix = getPathPrefix(newPath);
const filteredNodes = filterNodesByPrefix(allNodes, prefix);
const resources = filteredNodes.map(n => nodeToResource(n, prefix));
// Sort: directories first, then alphabetically
resources.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
set({ resources, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to list directory',
isLoading: false,
resources: [],
});
}
},
navigateByPath: async (path: string) => {
const { pathStack, navigate } = get();
if (path === '/') {
await navigate(null);
return;
}
// Try to match the path against the current pathStack
const segments = path.split('/').filter(Boolean);
const targetDepth = segments.length;
// pathStack[0] is root (id: null, name: ''), subsequent entries match path segments
if (targetDepth < pathStack.length) {
const entry = pathStack[targetDepth];
// Verify the names match
const stackPath = pathStack.slice(1, targetDepth + 1).map(s => s.name).join('/');
if (stackPath === segments.join('/')) {
await navigate(entry.id, entry.name);
return;
}
}
// Fallback: if we can't resolve, stay at current location
},
navigateUp: async () => {
const { pathStack, navigate } = get();
if (pathStack.length <= 1) return;
const parent = pathStack[pathStack.length - 2];
await navigate(parent.id, parent.name);
},
refresh: async () => {
const { currentParentId, navigate, pathStack } = get();
const currentEntry = pathStack[pathStack.length - 1];
await navigate(currentParentId, currentEntry?.name);
},
createDirectory: async (name: string) => {
const { client, currentPath, refresh } = get();
if (!client) return;
const prefix = getPathPrefix(currentPath);
const fullName = prefix + name;
await client.createFileDirectory(fullName, null);
await refresh();
},
uploadFile: async (file: File) => {
const { client, currentPath } = get();
if (!client) return;
const prefix = getPathPrefix(currentPath);
const fullName = prefix + file.name;
const abortController = new AbortController();
set({ uploadAbortController: abortController });
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: 1, totalFiles: 1 } });
try {
if (abortController.signal.aborted) return;
const { blobId, type } = await client.uploadBlob(file);
if (abortController.signal.aborted) return;
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: 1, totalFiles: 1 } });
await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
} finally {
set({ uploadProgress: null, uploadAbortController: null });
}
},
uploadFiles: async (files: File[]) => {
const { client, currentPath, resources } = get();
if (!client) return;
const prefix = getPathPrefix(currentPath);
const abortController = new AbortController();
set({ uploadAbortController: abortController });
const totalFiles = files.length;
const existingNames = new Set(resources.map(r => r.name));
for (let i = 0; i < files.length; i++) {
if (abortController.signal.aborted) break;
const file = files[i];
const uniqueName = getUniqueName(file.name, existingNames);
existingNames.add(uniqueName);
const fullName = prefix + uniqueName;
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: i + 1, totalFiles } });
try {
const { blobId, type } = await client.uploadBlob(file);
if (abortController.signal.aborted) break;
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') break;
set({ uploadProgress: null, uploadAbortController: null });
throw err;
}
}
set({ uploadProgress: null, uploadAbortController: null });
await get().refresh();
},
cancelUpload: () => {
const { uploadAbortController } = get();
if (uploadAbortController) {
uploadAbortController.abort();
set({ uploadProgress: null, uploadAbortController: null });
}
},
uploadFolder: async (files: File[]) => {
const { client, currentPath } = get();
if (!client || files.length === 0) return;
const prefix = getPathPrefix(currentPath);
const abortController = new AbortController();
set({ uploadAbortController: abortController });
const totalFiles = files.length;
// Collect unique directory paths from the uploaded folder structure
const dirs = new Set<string>();
for (const file of files) {
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
const parts = relativePath.split('/');
for (let i = 1; i < parts.length; i++) {
dirs.add(parts.slice(0, i).join('/'));
}
}
// Create directories as flat entries with prefixed names (no parentId nesting)
const sortedDirs = [...dirs].sort((a, b) => a.split('/').length - b.split('/').length);
for (const dir of sortedDirs) {
if (abortController.signal.aborted) break;
const fullDirName = prefix + dir;
try {
await client.createFileDirectory(fullDirName, null);
} catch {
// Directory may already exist — ignore
}
}
// Upload files with full prefixed paths
for (let i = 0; i < files.length; i++) {
if (abortController.signal.aborted) break;
const file = files[i];
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
const fullName = prefix + relativePath;
set({ uploadProgress: { name: relativePath, loaded: 0, total: file.size, current: i + 1, totalFiles } });
try {
const { blobId, type } = await client.uploadBlob(file);
if (abortController.signal.aborted) break;
set({ uploadProgress: { name: relativePath, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') break;
set({ uploadProgress: null, uploadAbortController: null });
throw err;
}
}
set({ uploadProgress: null, uploadAbortController: null });
await get().refresh();
},
deleteResource: async (name: string) => {
const { client, resources, refresh } = get();
if (!client) return;
const resource = resources.find(r => r.name === name);
if (!resource) return;
const idsToDelete = [resource.id];
// If deleting a folder, also delete all files inside it
if (resource.isDirectory) {
const allNodes = await client.listFileNodes(null);
const folderPrefix = resource.serverName + PATH_SEP;
for (const node of allNodes) {
if (node.name.startsWith(folderPrefix)) {
idsToDelete.push(node.id);
}
}
}
await client.destroyFileNodes(idsToDelete);
await refresh();
},
deleteResources: async (names: string[]) => {
const { client, resources, refresh } = get();
if (!client) return;
const idsToDelete: string[] = [];
let allNodes: FileNode[] | null = null;
for (const name of names) {
const resource = resources.find(r => r.name === name);
if (!resource) continue;
idsToDelete.push(resource.id);
if (resource.isDirectory) {
if (!allNodes) allNodes = await client.listFileNodes(null);
const folderPrefix = resource.serverName + PATH_SEP;
for (const node of allNodes) {
if (node.name.startsWith(folderPrefix)) {
idsToDelete.push(node.id);
}
}
}
}
if (idsToDelete.length === 0) return;
await client.destroyFileNodes(idsToDelete);
set({ selectedResources: new Set() });
await refresh();
},
renameResource: async (oldName: string, newName: string) => {
const { client, resources, currentPath, refresh } = get();
if (!client) return;
const resource = resources.find(r => r.name === oldName);
if (!resource) return;
const prefix = getPathPrefix(currentPath);
const oldServerName = resource.serverName;
const newServerName = prefix + newName;
await client.updateFileNode(resource.id, { name: newServerName });
// If renaming a folder, also rename all files inside it
if (resource.isDirectory) {
const allNodes = await client.listFileNodes(null);
const oldFolderPrefix = oldServerName + PATH_SEP;
const newFolderPrefix = newServerName + PATH_SEP;
for (const node of allNodes) {
if (node.name.startsWith(oldFolderPrefix)) {
const newNodeName = newFolderPrefix + node.name.slice(oldFolderPrefix.length);
await client.updateFileNode(node.id, { name: newNodeName });
}
}
}
set({
lastAction: {
type: 'rename',
entries: [{ id: resource.id, from: { name: oldServerName }, to: { name: newServerName } }],
sourceParentId: null,
},
});
await refresh();
},
downloadResource: async (name: string) => {
const { client, resources } = get();
if (!client) return;
const resource = resources.find(r => r.name === name);
if (!resource?.blobId) return;
await client.downloadBlob(resource.blobId, resource.name, resource.contentType);
},
downloadResources: async (names: string[]) => {
const { downloadResource } = get();
for (const name of names) {
await downloadResource(name);
}
},
getImageUrl: async (name: string) => {
const { client, resources } = get();
if (!client) throw new Error('No client');
const resource = resources.find(r => r.name === name);
if (!resource?.blobId) throw new Error('No blob');
return client.fetchBlobAsObjectUrl(resource.blobId, resource.name, resource.contentType);
},
getFileContent: async (name: string) => {
const { client, resources } = get();
if (!client) throw new Error('No client');
const resource = resources.find(r => r.name === name);
if (!resource?.blobId) throw new Error('No blob');
const url = client.getBlobDownloadUrl(resource.blobId, resource.name, resource.contentType);
const response = await fetch(url, {
headers: { 'Authorization': client.getAuthHeader() },
});
if (!response.ok) throw new Error(`Failed to fetch file: ${response.status}`);
const blob = await response.blob();
return { blob, contentType: resource.contentType || 'application/octet-stream' };
},
createTextFile: async (name: string) => {
const { client, currentPath, refresh } = get();
if (!client) return;
const prefix = getPathPrefix(currentPath);
const fullName = prefix + name;
const emptyBlob = new File([''], name, { type: 'text/plain' });
const { blobId } = await client.uploadBlob(emptyBlob);
await client.createFileNode(fullName, blobId, 'text/plain', 0, null);
await refresh();
},
duplicateResource: async (name: string) => {
const { client, resources, currentPath, refresh } = get();
if (!client) return;
const resource = resources.find(r => r.name === name);
if (!resource) return;
const prefix = getPathPrefix(currentPath);
const dotIdx = name.lastIndexOf('.');
const copyName = dotIdx > 0
? `${name.substring(0, dotIdx)} (copy)${name.substring(dotIdx)}`
: `${name} (copy)`;
const fullCopyName = prefix + copyName;
await client.copyFileNode(resource.id, fullCopyName, null);
await refresh();
},
moveToFolder: async (names: string[], targetFolder: string) => {
const { client, resources, refresh } = get();
if (!client) return;
const targetResource = resources.find(r => r.name === targetFolder && r.isDirectory);
if (!targetResource) return;
const entries: UndoAction['entries'] = [];
for (const name of names) {
const resource = resources.find(r => r.name === name);
if (!resource) continue;
const newServerName = targetResource.serverName + PATH_SEP + resource.name;
await client.updateFileNode(resource.id, { name: newServerName });
entries.push({ id: resource.id, from: { name: resource.serverName }, to: { name: newServerName } });
}
set({
selectedResources: new Set(),
lastAction: { type: 'move', entries, sourceParentId: null },
});
await refresh();
},
moveToParent: async (names: string[]) => {
const { client, resources, currentPath, refresh } = get();
if (!client || currentPath === '/') return;
const prefix = getPathPrefix(currentPath);
// Parent prefix: strip the last segment from the current prefix
// e.g. "foldersub" → "folder", "folder" → ""
const parentPrefix = prefix.slice(0, prefix.lastIndexOf(PATH_SEP, prefix.length - 2) + 1);
const entries: UndoAction['entries'] = [];
for (const name of names) {
const resource = resources.find(r => r.name === name);
if (!resource) continue;
const newServerName = parentPrefix + resource.name;
await client.updateFileNode(resource.id, { name: newServerName });
entries.push({ id: resource.id, from: { name: resource.serverName }, to: { name: newServerName } });
}
set({
selectedResources: new Set(),
lastAction: { type: 'move', entries, sourceParentId: null },
});
await refresh();
},
cutResources: (names: string[]) => {
const { currentPath, resources } = get();
const ids = names.map(n => resources.find(r => r.name === n)?.id).filter(Boolean) as string[];
const serverNames = names.map(n => resources.find(r => r.name === n)?.serverName).filter(Boolean) as string[];
set({ clipboard: { mode: 'cut', ids, names, serverNames, sourceParentId: null, sourcePath: currentPath } });
},
copyResources: (names: string[]) => {
const { currentPath, resources } = get();
const ids = names.map(n => resources.find(r => r.name === n)?.id).filter(Boolean) as string[];
const serverNames = names.map(n => resources.find(r => r.name === n)?.serverName).filter(Boolean) as string[];
set({ clipboard: { mode: 'copy', ids, names, serverNames, sourceParentId: null, sourcePath: currentPath } });
},
pasteResources: async () => {
const { client, currentPath, clipboard, refresh } = get();
if (!client || !clipboard) return;
const prefix = getPathPrefix(currentPath);
const entries: UndoAction['entries'] = [];
for (let i = 0; i < clipboard.ids.length; i++) {
const id = clipboard.ids[i];
const displayName = clipboard.names[i];
const oldServerName = clipboard.serverNames?.[i];
if (clipboard.mode === 'cut') {
const newServerName = prefix + displayName;
await client.updateFileNode(id, { name: newServerName });
entries.push({ id, from: { name: oldServerName }, to: { name: newServerName } });
} else {
const fullName = prefix + displayName;
await client.copyFileNode(id, fullName, null);
}
}
if (clipboard.mode === 'cut') {
set({
clipboard: null,
lastAction: { type: 'move', entries, sourceParentId: null },
});
}
await refresh();
},
selectResource: (name: string | null) => {
set({ selectedResources: name ? new Set([name]) : new Set() });
},
toggleSelect: (name: string) => {
const { selectedResources } = get();
const next = new Set(selectedResources);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
set({ selectedResources: next });
},
selectAll: () => {
const { resources } = get();
set({ selectedResources: new Set(resources.map(r => r.name)) });
},
clearSelection: () => {
set({ selectedResources: new Set() });
},
setSelection: (names: Set<string>) => {
set({ selectedResources: new Set(names) });
},
listPath: async (path: string) => {
const { client } = get();
if (!client) return [];
try {
const allNodes = await client.listFileNodes(null);
const prefix = getPathPrefix(path);
const filtered = filterNodesByPrefix(allNodes, prefix);
return filtered.map(n => nodeToResource(n, prefix));
} catch {
return [];
}
},
listByParentId: async (parentId: string | null) => {
const { client } = get();
if (!client) return [];
try {
const allNodes = await client.listFileNodes(null);
if (parentId === null) {
// Root level: nodes with simple names (no "/")
const rootNodes = allNodes.filter(n => !n.name.includes('/'));
return rootNodes.map(n => nodeToResource(n));
}
// Find the folder node to get its server name
const folder = allNodes.find(n => n.id === parentId);
if (!folder) return [];
const prefix = folder.name + PATH_SEP;
const filtered = filterNodesByPrefix(allNodes, prefix);
return filtered.map(n => nodeToResource(n, prefix));
} catch {
return [];
}
},
toggleFavorite: (path: string) => {
const { favorites } = get();
const next = favorites.includes(path)
? favorites.filter(f => f !== path)
: [...favorites, path];
set({ favorites: next });
try { localStorage.setItem('files-favorites', JSON.stringify(next)); } catch { /* ignore */ }
},
addRecentFile: (name: string, id: string) => {
const { recentFiles } = get();
const entry = { name, id, timestamp: Date.now() };
const filtered = recentFiles.filter(r => r.id !== id);
const next = [entry, ...filtered].slice(0, 20);
set({ recentFiles: next });
try { localStorage.setItem('files-recent-files', JSON.stringify(next)); } catch { /* ignore */ }
},
undoLastAction: async () => {
const { client, lastAction, refresh } = get();
if (!client || !lastAction) return;
for (const entry of lastAction.entries) {
await client.updateFileNode(entry.id, entry.from);
}
set({ lastAction: null });
await refresh();
},
}));
+2
View File
@@ -77,6 +77,7 @@ interface SettingsState {
// Email Behavior
markAsReadDelay: number; // milliseconds (0 = instant, -1 = never)
deleteAction: DeleteAction;
permanentlyDeleteJunk: boolean; // Permanently delete emails from junk/spam instead of moving to trash
showPreview: boolean;
emailsPerPage: number;
externalContentPolicy: ExternalContentPolicy;
@@ -154,6 +155,7 @@ const DEFAULT_SETTINGS = {
// Email Behavior
markAsReadDelay: 0, // Instant
deleteAction: 'trash' as DeleteAction,
permanentlyDeleteJunk: false,
showPreview: true,
emailsPerPage: 50,
externalContentPolicy: 'ask' as ExternalContentPolicy,
+480
View File
@@ -0,0 +1,480 @@
import { create } from 'zustand';
import { WebDAVClient, type WebDAVResource } from '@/lib/webdav/client';
interface UploadProgress {
name: string;
loaded: number;
total: number;
current: number;
totalFiles: number;
}
interface ClipboardState {
mode: 'cut' | 'copy';
paths: string[];
names: string[];
sourcePath: string;
}
interface UndoAction {
type: 'rename' | 'move';
// For rename: from/to paths
// For move: array of {from, to} pairs
entries: { from: string; to: string }[];
sourcePath: string;
}
interface WebDAVState {
currentPath: string;
resources: WebDAVResource[];
isLoading: boolean;
error: string | null;
supportsWebDAV: boolean | null;
selectedResources: Set<string>;
uploadProgress: UploadProgress | null;
webdavClient: WebDAVClient | null;
clipboard: ClipboardState | null;
uploadAbortController: AbortController | null;
favorites: string[];
recentFiles: { name: string; path: string; timestamp: number }[];
lastAction: UndoAction | null;
// Actions
initClient: () => void;
checkSupport: () => Promise<boolean>;
navigate: (path: string) => Promise<void>;
refresh: () => Promise<void>;
createDirectory: (name: string) => Promise<void>;
uploadFile: (file: File) => Promise<void>;
uploadFiles: (files: File[]) => Promise<void>;
uploadFolder: (files: File[]) => Promise<void>;
cancelUpload: () => void;
deleteResource: (name: string) => Promise<void>;
deleteResources: (names: string[]) => Promise<void>;
renameResource: (oldName: string, newName: string) => Promise<void>;
downloadResource: (name: string) => Promise<void>;
downloadResources: (names: string[]) => Promise<void>;
getImageUrl: (name: string) => Promise<string>;
getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>;
createTextFile: (name: string) => Promise<void>;
duplicateResource: (name: string) => Promise<void>;
moveToFolder: (names: string[], targetFolder: string) => Promise<void>;
cutResources: (names: string[]) => void;
copyResources: (names: string[]) => void;
pasteResources: () => Promise<void>;
selectResource: (name: string | null) => void;
toggleSelect: (name: string) => void;
selectAll: () => void;
clearSelection: () => void;
setSelection: (names: Set<string>) => void;
listPath: (path: string) => Promise<WebDAVResource[]>;
toggleFavorite: (path: string) => void;
addRecentFile: (name: string, path: string) => void;
undoLastAction: () => Promise<void>;
}
function getUniqueName(name: string, existingNames: Set<string>): string {
if (!existingNames.has(name)) return name;
const dotIndex = name.lastIndexOf('.');
const base = dotIndex > 0 ? name.substring(0, dotIndex) : name;
const ext = dotIndex > 0 ? name.substring(dotIndex) : '';
let counter = 1;
while (existingNames.has(`${base} (${counter})${ext}`)) counter++;
return `${base} (${counter})${ext}`;
}
export const useWebDAVStore = create<WebDAVState>((set, get) => ({
currentPath: '/',
resources: [],
isLoading: false,
error: null,
supportsWebDAV: null,
selectedResources: new Set<string>(),
uploadProgress: null,
webdavClient: null,
clipboard: null,
uploadAbortController: null,
lastAction: null,
favorites: (() => {
try { return JSON.parse(localStorage.getItem('webdav-favorites') || '[]'); } catch { return []; }
})(),
recentFiles: (() => {
try { return JSON.parse(localStorage.getItem('webdav-recent-files') || '[]'); } catch { return []; }
})(),
initClient: () => {
const client = new WebDAVClient();
set({ webdavClient: client });
},
checkSupport: async () => {
const { webdavClient } = get();
if (!webdavClient) return false;
try {
const supported = await webdavClient.checkSupport();
set({ supportsWebDAV: supported });
return supported;
} catch {
set({ supportsWebDAV: false });
return false;
}
},
navigate: async (path: string) => {
const { webdavClient } = get();
if (!webdavClient) return;
set({ isLoading: true, error: null, currentPath: path, selectedResources: new Set() });
// Remember last directory
try { localStorage.setItem('webdav-last-path', path); } catch { /* ignore */ }
try {
const resources = await webdavClient.list(path);
set({ resources, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to list directory',
isLoading: false,
resources: [],
});
}
},
refresh: async () => {
const { currentPath, navigate } = get();
await navigate(currentPath);
},
createDirectory: async (name: string) => {
const { webdavClient, currentPath, refresh } = get();
if (!webdavClient) return;
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
await webdavClient.createDirectory(fullPath);
await refresh();
},
uploadFile: async (file: File) => {
const { webdavClient, currentPath } = get();
if (!webdavClient) return;
const abortController = new AbortController();
set({ uploadAbortController: abortController });
const fullPath = currentPath === '/' ? `/${file.name}` : `${currentPath}/${file.name}`;
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: 1, totalFiles: 1 } });
try {
await webdavClient.uploadFile(fullPath, file, undefined, (loaded: number, total: number) => {
set({ uploadProgress: { name: file.name, loaded, total, current: 1, totalFiles: 1 } });
}, abortController.signal);
} finally {
set({ uploadProgress: null, uploadAbortController: null });
}
},
uploadFiles: async (files: File[]) => {
const { webdavClient, currentPath, resources } = get();
if (!webdavClient) return;
const abortController = new AbortController();
set({ uploadAbortController: abortController });
const totalFiles = files.length;
const existingNames = new Set(resources.map(r => r.name));
for (let i = 0; i < files.length; i++) {
if (abortController.signal.aborted) break;
const file = files[i];
const uniqueName = getUniqueName(file.name, existingNames);
existingNames.add(uniqueName);
const fullPath = currentPath === '/' ? `/${uniqueName}` : `${currentPath}/${uniqueName}`;
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: i + 1, totalFiles } });
try {
await webdavClient.uploadFile(fullPath, file, undefined, (loaded: number, total: number) => {
set({ uploadProgress: { name: file.name, loaded, total, current: i + 1, totalFiles } });
}, abortController.signal);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') break;
set({ uploadProgress: null, uploadAbortController: null });
throw err;
}
}
set({ uploadProgress: null, uploadAbortController: null });
await get().refresh();
},
cancelUpload: () => {
const { uploadAbortController } = get();
if (uploadAbortController) {
uploadAbortController.abort();
set({ uploadProgress: null, uploadAbortController: null });
}
},
uploadFolder: async (files: File[]) => {
const { webdavClient, currentPath } = get();
if (!webdavClient || files.length === 0) return;
const abortController = new AbortController();
set({ uploadAbortController: abortController });
const totalFiles = files.length;
// Collect all unique directory paths to create
const dirs = new Set<string>();
for (const file of files) {
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
const parts = relativePath.split('/');
for (let i = 1; i < parts.length; i++) {
dirs.add(parts.slice(0, i).join('/'));
}
}
// Create directories first (sorted by depth)
const sortedDirs = [...dirs].sort((a, b) => a.split('/').length - b.split('/').length);
for (const dir of sortedDirs) {
if (abortController.signal.aborted) break;
const fullPath = currentPath === '/' ? `/${dir}` : `${currentPath}/${dir}`;
try {
await webdavClient.createDirectory(fullPath);
} catch {
// Directory may already exist
}
}
// Upload files
for (let i = 0; i < files.length; i++) {
if (abortController.signal.aborted) break;
const file = files[i];
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
const fullPath = currentPath === '/' ? `/${relativePath}` : `${currentPath}/${relativePath}`;
set({ uploadProgress: { name: relativePath, loaded: 0, total: file.size, current: i + 1, totalFiles } });
try {
await webdavClient.uploadFile(fullPath, file, undefined, (loaded: number, total: number) => {
set({ uploadProgress: { name: relativePath, loaded, total, current: i + 1, totalFiles } });
}, abortController.signal);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') break;
set({ uploadProgress: null, uploadAbortController: null });
throw err;
}
}
set({ uploadProgress: null, uploadAbortController: null });
await get().refresh();
},
deleteResource: async (name: string) => {
const { webdavClient, currentPath, refresh } = get();
if (!webdavClient) return;
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
await webdavClient.delete(fullPath);
await refresh();
},
deleteResources: async (names: string[]) => {
const { webdavClient, currentPath, refresh } = get();
if (!webdavClient) return;
for (const name of names) {
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
await webdavClient.delete(fullPath);
}
set({ selectedResources: new Set() });
await refresh();
},
renameResource: async (oldName: string, newName: string) => {
const { webdavClient, currentPath, refresh } = get();
if (!webdavClient) return;
const oldPath = currentPath === '/' ? `/${oldName}` : `${currentPath}/${oldName}`;
const newPath = currentPath === '/' ? `/${newName}` : `${currentPath}/${newName}`;
await webdavClient.move(oldPath, newPath);
set({ lastAction: { type: 'rename', entries: [{ from: oldPath, to: newPath }], sourcePath: currentPath } });
await refresh();
},
downloadResource: async (name: string) => {
const { webdavClient, currentPath } = get();
if (!webdavClient) return;
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
const { blob, filename } = await webdavClient.downloadFile(fullPath);
// Trigger browser download
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
downloadResources: async (names: string[]) => {
const { downloadResource } = get();
for (const name of names) {
await downloadResource(name);
}
},
getImageUrl: async (name: string) => {
const { webdavClient, currentPath } = get();
if (!webdavClient) throw new Error('No client');
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
const { blob } = await webdavClient.downloadFile(fullPath);
return URL.createObjectURL(blob);
},
getFileContent: async (name: string) => {
const { webdavClient, currentPath } = get();
if (!webdavClient) throw new Error('No client');
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
const { blob, contentType } = await webdavClient.downloadFile(fullPath);
return { blob, contentType };
},
createTextFile: async (name: string) => {
const { webdavClient, currentPath, refresh } = get();
if (!webdavClient) return;
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
await webdavClient.uploadFile(fullPath, new Blob([''], { type: 'text/plain' }), 'text/plain');
await refresh();
},
duplicateResource: async (name: string) => {
const { webdavClient, currentPath, refresh } = get();
if (!webdavClient) return;
const srcPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
const dotIdx = name.lastIndexOf('.');
const copyName = dotIdx > 0
? `${name.substring(0, dotIdx)} (copy)${name.substring(dotIdx)}`
: `${name} (copy)`;
const destPath = currentPath === '/' ? `/${copyName}` : `${currentPath}/${copyName}`;
await webdavClient.copy(srcPath, destPath);
await refresh();
},
moveToFolder: async (names: string[], targetFolder: string) => {
const { webdavClient, currentPath, refresh } = get();
if (!webdavClient) return;
const targetBase = currentPath === '/' ? `/${targetFolder}` : `${currentPath}/${targetFolder}`;
const entries: { from: string; to: string }[] = [];
for (const name of names) {
const oldPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
const newPath = `${targetBase}/${name}`;
await webdavClient.move(oldPath, newPath);
entries.push({ from: oldPath, to: newPath });
}
set({ selectedResources: new Set(), lastAction: { type: 'move', entries, sourcePath: currentPath } });
await refresh();
},
cutResources: (names: string[]) => {
const { currentPath } = get();
const paths = names.map(n => currentPath === '/' ? `/${n}` : `${currentPath}/${n}`);
set({ clipboard: { mode: 'cut', paths, names, sourcePath: currentPath } });
},
copyResources: (names: string[]) => {
const { currentPath } = get();
const paths = names.map(n => currentPath === '/' ? `/${n}` : `${currentPath}/${n}`);
set({ clipboard: { mode: 'copy', paths, names, sourcePath: currentPath } });
},
pasteResources: async () => {
const { webdavClient, currentPath, clipboard, refresh } = get();
if (!webdavClient || !clipboard) return;
const entries: { from: string; to: string }[] = [];
for (let i = 0; i < clipboard.paths.length; i++) {
const srcPath = clipboard.paths[i];
const destPath = currentPath === '/' ? `/${clipboard.names[i]}` : `${currentPath}/${clipboard.names[i]}`;
if (clipboard.mode === 'cut') {
await webdavClient.move(srcPath, destPath);
entries.push({ from: srcPath, to: destPath });
} else {
await webdavClient.copy(srcPath, destPath);
}
}
if (clipboard.mode === 'cut') {
set({ clipboard: null, lastAction: { type: 'move', entries, sourcePath: currentPath } });
}
await refresh();
},
selectResource: (name: string | null) => {
set({ selectedResources: name ? new Set([name]) : new Set() });
},
toggleSelect: (name: string) => {
const { selectedResources } = get();
const next = new Set(selectedResources);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
set({ selectedResources: next });
},
selectAll: () => {
const { resources } = get();
set({ selectedResources: new Set(resources.map(r => r.name)) });
},
clearSelection: () => {
set({ selectedResources: new Set() });
},
setSelection: (names: Set<string>) => {
set({ selectedResources: new Set(names) });
},
listPath: async (path: string) => {
const { webdavClient } = get();
if (!webdavClient) return [];
return webdavClient.list(path);
},
toggleFavorite: (path: string) => {
const { favorites } = get();
const next = favorites.includes(path)
? favorites.filter(f => f !== path)
: [...favorites, path];
set({ favorites: next });
try { localStorage.setItem('webdav-favorites', JSON.stringify(next)); } catch { /* ignore */ }
},
addRecentFile: (name: string, path: string) => {
const { recentFiles } = get();
const entry = { name, path, timestamp: Date.now() };
const filtered = recentFiles.filter(r => r.path !== path);
const next = [entry, ...filtered].slice(0, 20);
set({ recentFiles: next });
try { localStorage.setItem('webdav-recent-files', JSON.stringify(next)); } catch { /* ignore */ }
},
undoLastAction: async () => {
const { webdavClient, lastAction, refresh } = get();
if (!webdavClient || !lastAction) return;
// Reverse all entries
for (const entry of lastAction.entries) {
await webdavClient.move(entry.to, entry.from);
}
set({ lastAction: null });
await refresh();
},
}));