Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
const signatureHtml = currentIdentity?.textSignature
|
||||
? `<br><br>-- <br>${currentIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').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
|
||||
|
||||
@@ -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
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Download, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
onDownload: (name: string) => Promise<void>;
|
||||
getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>;
|
||||
}
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
"txt", "md", "markdown", "json", "xml", "html", "htm", "css", "js", "ts",
|
||||
"jsx", "tsx", "py", "rb", "java", "c", "cpp", "h", "hpp", "go", "rs",
|
||||
"sh", "bash", "zsh", "yaml", "yml", "toml", "ini", "cfg", "conf", "env",
|
||||
"log", "csv", "sql", "graphql", "vue", "svelte", "astro", "php", "pl",
|
||||
"swift", "kt", "scala", "r", "lua", "vim",
|
||||
]);
|
||||
|
||||
function getFileType(name: string): "text" | "pdf" | "audio" | "video" | "markdown" | "unknown" {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
const baseName = name.toLowerCase();
|
||||
|
||||
if (ext === "md" || ext === "markdown") return "markdown";
|
||||
if (ext === "pdf") return "pdf";
|
||||
if (["mp3", "wav", "ogg", "flac", "aac", "m4a", "wma", "opus"].includes(ext)) return "audio";
|
||||
if (["mp4", "webm", "ogv", "mov", "avi", "mkv", "m4v"].includes(ext)) return "video";
|
||||
if (TEXT_EXTENSIONS.has(ext) || ["dockerfile", "makefile", "readme", "license", "changelog"].includes(baseName)) return "text";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function SimpleMarkdown({ content }: { content: string }) {
|
||||
const lines = content.split("\n");
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Headers
|
||||
if (line.startsWith("### ")) {
|
||||
elements.push(<h3 key={i} className="text-lg font-semibold mt-4 mb-2">{processInline(line.slice(4))}</h3>);
|
||||
} else if (line.startsWith("## ")) {
|
||||
elements.push(<h2 key={i} className="text-xl font-semibold mt-5 mb-2">{processInline(line.slice(3))}</h2>);
|
||||
} else if (line.startsWith("# ")) {
|
||||
elements.push(<h1 key={i} className="text-2xl font-bold mt-6 mb-3">{processInline(line.slice(2))}</h1>);
|
||||
} else if (line.startsWith("---") || line.startsWith("***")) {
|
||||
elements.push(<hr key={i} className="my-4 border-border" />);
|
||||
} else if (line.startsWith("- ") || line.startsWith("* ")) {
|
||||
elements.push(<li key={i} className="ml-4 list-disc">{processInline(line.slice(2))}</li>);
|
||||
} else if (/^\d+\. /.test(line)) {
|
||||
elements.push(<li key={i} className="ml-4 list-decimal">{processInline(line.replace(/^\d+\. /, ""))}</li>);
|
||||
} else if (line.startsWith("> ")) {
|
||||
elements.push(<blockquote key={i} className="border-l-4 border-border pl-4 italic text-muted-foreground my-2">{processInline(line.slice(2))}</blockquote>);
|
||||
} else if (line.startsWith("```")) {
|
||||
// Code block - collect until closing ```
|
||||
const codeLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].startsWith("```")) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
elements.push(
|
||||
<pre key={i} className="bg-muted rounded p-3 my-2 overflow-x-auto text-sm font-mono">
|
||||
<code>{codeLines.join("\n")}</code>
|
||||
</pre>
|
||||
);
|
||||
} else if (line.trim() === "") {
|
||||
elements.push(<div key={i} className="h-2" />);
|
||||
} else {
|
||||
elements.push(<p key={i} className="my-1">{processInline(line)}</p>);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="prose prose-sm dark:prose-invert max-w-none">{elements}</div>;
|
||||
}
|
||||
|
||||
function processInline(text: string): React.ReactNode {
|
||||
// Process bold, italic, code inline
|
||||
const parts: React.ReactNode[] = [];
|
||||
let remaining = text;
|
||||
let key = 0;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
// Bold
|
||||
const boldMatch = remaining.match(/\*\*(.+?)\*\*/);
|
||||
// Inline code
|
||||
const codeMatch = remaining.match(/`([^`]+)`/);
|
||||
// Italic
|
||||
const italicMatch = remaining.match(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/);
|
||||
|
||||
const matches = [
|
||||
boldMatch && { type: "bold", match: boldMatch },
|
||||
codeMatch && { type: "code", match: codeMatch },
|
||||
italicMatch && { type: "italic", match: italicMatch },
|
||||
].filter(Boolean).sort((a, b) => (a!.match.index ?? 0) - (b!.match.index ?? 0));
|
||||
|
||||
if (matches.length === 0) {
|
||||
parts.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
const first = matches[0]!;
|
||||
const idx = first.match.index ?? 0;
|
||||
|
||||
if (idx > 0) {
|
||||
parts.push(remaining.slice(0, idx));
|
||||
}
|
||||
|
||||
if (first.type === "bold") {
|
||||
parts.push(<strong key={key++}>{first.match[1]}</strong>);
|
||||
} else if (first.type === "code") {
|
||||
parts.push(<code key={key++} className="bg-muted px-1 py-0.5 rounded text-sm font-mono">{first.match[1]}</code>);
|
||||
} else {
|
||||
parts.push(<em key={key++}>{first.match[1]}</em>);
|
||||
}
|
||||
|
||||
remaining = remaining.slice(idx + first.match[0].length);
|
||||
}
|
||||
|
||||
return parts.length === 1 ? parts[0] : <>{parts}</>;
|
||||
}
|
||||
|
||||
export function FilePreviewModal({ name, onClose, onDownload, getFileContent }: FilePreviewModalProps) {
|
||||
const t = useTranslations("files");
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const fileType = getFileType(name);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const { blob, contentType } = await getFileContent(name);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (fileType === "text" || fileType === "markdown") {
|
||||
const text = await blob.text();
|
||||
if (!cancelled) setContent(text);
|
||||
} else {
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (!cancelled) setObjectUrl(url);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError(true);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-label={name} className="fixed inset-0 z-50 flex flex-col bg-black/80" onClick={onClose}>
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-medium truncate">{name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onDownload(name)}>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center overflow-auto p-4" onClick={(e) => e.stopPropagation()}>
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{t("preview_error")}</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && (fileType === "text") && content !== null && (
|
||||
<pre className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm font-mono whitespace-pre-wrap break-words">
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "markdown" && content !== null && (
|
||||
<div className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm">
|
||||
<SimpleMarkdown content={content} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "pdf" && objectUrl && (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
||||
title={name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "audio" && objectUrl && (
|
||||
<div className="bg-background rounded-lg p-8 max-w-lg w-full">
|
||||
<p className="text-sm font-medium mb-4 text-center">{name}</p>
|
||||
<audio controls className="w-full" src={objectUrl} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "video" && objectUrl && (
|
||||
<video controls className="max-w-4xl max-h-full rounded-lg" src={objectUrl} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, FolderPlus, FilePlus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface FileUploadAreaProps {
|
||||
onUpload: (files: File[]) => Promise<void>;
|
||||
onCreateFolder: () => void;
|
||||
onCreateTextFile?: () => void;
|
||||
}
|
||||
|
||||
export function FileUploadArea({ onUpload, onCreateFolder, onCreateTextFile }: FileUploadAreaProps) {
|
||||
const t = useTranslations("files");
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
await onUpload(files);
|
||||
}
|
||||
}, [onUpload]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<div
|
||||
className={`flex flex-col items-center gap-4 p-12 rounded-xl border-2 border-dashed transition-colors max-w-md w-full ${
|
||||
isDragging ? "border-primary bg-primary/5" : "border-border"
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<Upload className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-base font-medium">{t("empty_state_title")}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t("empty_state_description")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">{t("drop_files_here")}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCreateFolder}
|
||||
>
|
||||
<FolderPlus className="w-4 h-4 mr-2" />
|
||||
{t("new_folder")}
|
||||
</Button>
|
||||
{onCreateTextFile && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCreateTextFile}
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
{t("new_text_file")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "@/components/settings/settings-section";
|
||||
|
||||
export type FolderLayout = "inline" | "sidebar";
|
||||
|
||||
export interface FilesSettings {
|
||||
defaultViewMode: "list" | "grid";
|
||||
showIcons: boolean;
|
||||
coloredIcons: boolean;
|
||||
defaultSortKey: "name" | "size" | "modified";
|
||||
defaultSortDir: "asc" | "desc";
|
||||
showHiddenFiles: boolean;
|
||||
showThumbnails: boolean;
|
||||
folderLayout: FolderLayout;
|
||||
}
|
||||
|
||||
export const DEFAULT_FILES_SETTINGS: FilesSettings = {
|
||||
defaultViewMode: "list",
|
||||
showIcons: true,
|
||||
coloredIcons: true,
|
||||
defaultSortKey: "name",
|
||||
defaultSortDir: "asc",
|
||||
showHiddenFiles: false,
|
||||
showThumbnails: true,
|
||||
folderLayout: "inline",
|
||||
};
|
||||
|
||||
export function loadFilesSettings(): FilesSettings {
|
||||
if (typeof window === "undefined") return DEFAULT_FILES_SETTINGS;
|
||||
try {
|
||||
const raw = localStorage.getItem("files-settings");
|
||||
if (raw) return { ...DEFAULT_FILES_SETTINGS, ...JSON.parse(raw) };
|
||||
} catch { /* ignore */ }
|
||||
return DEFAULT_FILES_SETTINGS;
|
||||
}
|
||||
|
||||
export function saveFilesSettings(settings: FilesSettings) {
|
||||
localStorage.setItem("files-settings", JSON.stringify(settings));
|
||||
// Dispatch custom event for same-tab listeners (StorageEvent only fires cross-tab)
|
||||
window.dispatchEvent(new CustomEvent("files-settings-changed"));
|
||||
}
|
||||
|
||||
interface FilesSettingsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
settings: FilesSettings;
|
||||
onSettingsChange: (settings: FilesSettings) => void;
|
||||
}
|
||||
|
||||
export function FilesSettingsDialog({ isOpen, onClose, settings, onSettingsChange }: FilesSettingsDialogProps) {
|
||||
const t = useTranslations("files");
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
if (isOpen) window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
if (isOpen) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const update = (patch: Partial<FilesSettings>) => {
|
||||
const next = { ...settings, ...patch };
|
||||
onSettingsChange(next);
|
||||
saveFilesSettings(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("settings_title")}
|
||||
className="bg-background border border-border rounded-lg shadow-lg w-full max-w-md mx-4 max-h-[80vh] flex flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">{t("settings_title")}</h2>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4 space-y-6">
|
||||
<SettingsSection title={t("settings_display")}>
|
||||
<SettingItem label={t("settings_folder_layout")} description={t("settings_folder_layout_desc")}>
|
||||
<RadioGroup
|
||||
value={settings.folderLayout}
|
||||
onChange={(v) => update({ folderLayout: v as FolderLayout })}
|
||||
options={[
|
||||
{ value: "inline", label: t("settings_folder_layout_inline") },
|
||||
{ value: "sidebar", label: t("settings_folder_layout_sidebar") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("settings_default_view")} description={t("settings_default_view_desc")}>
|
||||
<RadioGroup
|
||||
value={settings.defaultViewMode}
|
||||
onChange={(v) => update({ defaultViewMode: v as "list" | "grid" })}
|
||||
options={[
|
||||
{ value: "list", label: t("list_view") },
|
||||
{ value: "grid", label: t("grid_view") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("settings_default_sort")} description={t("settings_default_sort_desc")}>
|
||||
<RadioGroup
|
||||
value={settings.defaultSortKey}
|
||||
onChange={(v) => update({ defaultSortKey: v as "name" | "size" | "modified" })}
|
||||
options={[
|
||||
{ value: "name", label: t("name") },
|
||||
{ value: "size", label: t("size") },
|
||||
{ value: "modified", label: t("modified") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("settings_sort_direction")} description={t("settings_sort_direction_desc")}>
|
||||
<RadioGroup
|
||||
value={settings.defaultSortDir}
|
||||
onChange={(v) => update({ defaultSortDir: v as "asc" | "desc" })}
|
||||
options={[
|
||||
{ value: "asc", label: t("settings_ascending") },
|
||||
{ value: "desc", label: t("settings_descending") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings_icons")}>
|
||||
<SettingItem label={t("settings_show_icons")} description={t("settings_show_icons_desc")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.showIcons}
|
||||
onChange={(v) => update({ showIcons: v })}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("settings_colored_icons")} description={t("settings_colored_icons_desc")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.coloredIcons}
|
||||
onChange={(v) => update({ coloredIcons: v })}
|
||||
disabled={!settings.showIcons}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("settings_show_thumbnails")} description={t("settings_show_thumbnails_desc")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.showThumbnails}
|
||||
onChange={(v) => update({ showThumbnails: v })}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings_behavior")}>
|
||||
<SettingItem label={t("settings_show_hidden")} description={t("settings_show_hidden_desc")}>
|
||||
<ToggleSwitch
|
||||
checked={settings.showHiddenFiles}
|
||||
onChange={(v) => update({ showHiddenFiles: v })}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Home,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFileStore, type FileResource } from "@/stores/file-store";
|
||||
|
||||
interface FolderNode {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface FolderTreeSidebarProps {
|
||||
currentPath: string;
|
||||
onNavigate: (path: string, resourceId?: string | null) => void;
|
||||
listByParentId: (parentId: string | null) => Promise<FileResource[]>;
|
||||
width?: number;
|
||||
isResizing?: boolean;
|
||||
}
|
||||
|
||||
export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { X, Download, ZoomIn, ZoomOut, RotateCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface ImagePreviewModalProps {
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
onDownload: (name: string) => Promise<void>;
|
||||
getImageUrl: (name: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: ImagePreviewModalProps) {
|
||||
const t = useTranslations("files");
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let revoke: string | null = null;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
getImageUrl(name)
|
||||
.then((url) => {
|
||||
revoke = url;
|
||||
setImageUrl(url);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (revoke) URL.revokeObjectURL(revoke);
|
||||
};
|
||||
}, [name, getImageUrl]);
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "+" || e.key === "=") setZoom((z) => Math.min(z + 0.25, 5));
|
||||
if (e.key === "-") setZoom((z) => Math.max(z - 0.25, 0.25));
|
||||
if (e.key === "r") setRotation((r) => r + 90);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={name}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 flex items-center justify-between px-4 py-3 bg-gradient-to-b from-black/60 to-transparent z-10">
|
||||
<span className="text-white text-sm font-medium truncate max-w-[50%]">{name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setZoom((z) => Math.min(z + 0.25, 5)); }}>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setZoom((z) => Math.max(z - 0.25, 0.25)); }}>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setRotation((r) => r + 90); }}>
|
||||
<RotateCw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); onDownload(name); }}>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div className="flex items-center justify-center w-full h-full p-16" onClick={(e) => e.stopPropagation()}>
|
||||
{loading && (
|
||||
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-white/70 text-sm">{t("preview_error")}</p>
|
||||
)}
|
||||
{imageUrl && !error && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={name}
|
||||
className="max-w-full max-h-full object-contain transition-transform duration-200"
|
||||
style={{ transform: `scale(${zoom}) rotate(${rotation}deg)` }}
|
||||
onLoad={() => setLoading(false)}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface NewFolderDialogProps {
|
||||
onConfirm: (name: string) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function NewFolderDialog({ onConfirm, onCancel }: NewFolderDialogProps) {
|
||||
const t = useTranslations("files");
|
||||
const [name, setName] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onConfirm(trimmed);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||
<div
|
||||
className="bg-background border border-border rounded-lg shadow-lg p-6 w-full max-w-sm mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold mb-4">{t("new_folder")}</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("new_folder_name")}
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || isSubmitting}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface RenameDialogProps {
|
||||
currentName: string;
|
||||
title?: string;
|
||||
label?: string;
|
||||
onConfirm: (newName: string) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function RenameDialog({ currentName, title, label, onConfirm, onCancel }: RenameDialogProps) {
|
||||
const t = useTranslations("files");
|
||||
const [name, setName] = useState(currentName);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onConfirm(trimmed);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||
<div
|
||||
className="bg-background border border-border rounded-lg shadow-lg p-6 w-full max-w-sm mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold mb-4">{title || t("rename_title")}</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={label || t("new_name")}
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || isSubmitting}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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, {});
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
import { create } from 'zustand';
|
||||
import type { JMAPClient } from '@/lib/jmap/client';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
|
||||
export interface FileResource {
|
||||
id: string;
|
||||
name: string;
|
||||
serverName: string;
|
||||
isDirectory: boolean;
|
||||
contentType: string;
|
||||
contentLength: number;
|
||||
lastModified: string;
|
||||
blobId: string | null;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
interface UploadProgress {
|
||||
name: string;
|
||||
loaded: number;
|
||||
total: number;
|
||||
current: number;
|
||||
totalFiles: number;
|
||||
}
|
||||
|
||||
interface ClipboardState {
|
||||
mode: 'cut' | 'copy';
|
||||
ids: string[];
|
||||
names: string[];
|
||||
serverNames: string[];
|
||||
sourceParentId: string | null;
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
interface UndoAction {
|
||||
type: 'rename' | 'move';
|
||||
entries: { id: string; from: Partial<Pick<FileNode, 'name' | 'parentId'>>; to: Partial<Pick<FileNode, 'name' | 'parentId'>> }[];
|
||||
sourceParentId: string | null;
|
||||
}
|
||||
|
||||
interface FileState {
|
||||
currentParentId: string | null;
|
||||
currentPath: string;
|
||||
pathStack: { id: string | null; name: string }[];
|
||||
resources: FileResource[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
supportsFiles: boolean | null;
|
||||
selectedResources: Set<string>;
|
||||
uploadProgress: UploadProgress | null;
|
||||
client: JMAPClient | null;
|
||||
clipboard: ClipboardState | null;
|
||||
uploadAbortController: AbortController | null;
|
||||
favorites: string[];
|
||||
recentFiles: { name: string; id: string; timestamp: number }[];
|
||||
lastAction: UndoAction | null;
|
||||
|
||||
// Actions
|
||||
initClient: (client: JMAPClient) => void;
|
||||
checkSupport: () => Promise<boolean>;
|
||||
navigate: (parentId: string | null, name?: string) => Promise<void>;
|
||||
navigateByPath: (path: string) => Promise<void>;
|
||||
navigateUp: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
createDirectory: (name: string) => Promise<void>;
|
||||
uploadFile: (file: File) => Promise<void>;
|
||||
uploadFiles: (files: File[]) => Promise<void>;
|
||||
uploadFolder: (files: File[]) => Promise<void>;
|
||||
cancelUpload: () => void;
|
||||
deleteResource: (name: string) => Promise<void>;
|
||||
deleteResources: (names: string[]) => Promise<void>;
|
||||
renameResource: (oldName: string, newName: string) => Promise<void>;
|
||||
downloadResource: (name: string) => Promise<void>;
|
||||
downloadResources: (names: string[]) => Promise<void>;
|
||||
getImageUrl: (name: string) => Promise<string>;
|
||||
getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>;
|
||||
createTextFile: (name: string) => Promise<void>;
|
||||
duplicateResource: (name: string) => Promise<void>;
|
||||
moveToFolder: (names: string[], targetFolder: string) => Promise<void>;
|
||||
moveToParent: (names: string[]) => Promise<void>;
|
||||
cutResources: (names: string[]) => void;
|
||||
copyResources: (names: string[]) => void;
|
||||
pasteResources: () => Promise<void>;
|
||||
selectResource: (name: string | null) => void;
|
||||
toggleSelect: (name: string) => void;
|
||||
selectAll: () => void;
|
||||
clearSelection: () => void;
|
||||
setSelection: (names: Set<string>) => void;
|
||||
listPath: (path: string) => Promise<FileResource[]>;
|
||||
listByParentId: (parentId: string | null) => Promise<FileResource[]>;
|
||||
toggleFavorite: (path: string) => void;
|
||||
addRecentFile: (name: string, id: string) => void;
|
||||
undoLastAction: () => Promise<void>;
|
||||
}
|
||||
|
||||
const DIRECTORY_TYPES = new Set(['d', 'application/x-directory', 'text/directory', 'httpd/unix-directory', 'inode/directory']);
|
||||
|
||||
// Stalwart rejects "/" in file names, so we use Unicode DIVISION SLASH as the
|
||||
// path separator when encoding folder hierarchy into flat file names.
|
||||
const PATH_SEP = '\u2215'; // ∕
|
||||
|
||||
function isDirectoryType(type: string | undefined): boolean {
|
||||
if (!type) return false;
|
||||
return DIRECTORY_TYPES.has(type) || type.includes('directory');
|
||||
}
|
||||
|
||||
// Convert currentPath to a server-side name prefix for filtering
|
||||
// "/" -> "", "/test" -> "test∕", "/test/sub" -> "test∕sub∕"
|
||||
function getPathPrefix(currentPath: string): string {
|
||||
if (currentPath === '/') return '';
|
||||
return currentPath.slice(1).replace(/\//g, PATH_SEP) + PATH_SEP;
|
||||
}
|
||||
|
||||
// Filter nodes to only direct children of a path prefix
|
||||
function filterNodesByPrefix(nodes: FileNode[], prefix: string): FileNode[] {
|
||||
if (prefix === '') {
|
||||
// Root: nodes whose names have no PATH_SEP
|
||||
return nodes.filter(n => !n.name.includes(PATH_SEP));
|
||||
}
|
||||
// Subfolder: nodes starting with prefix, with no additional PATH_SEP after the prefix
|
||||
return nodes.filter(n => {
|
||||
if (!n.name.startsWith(prefix)) return false;
|
||||
const remaining = n.name.slice(prefix.length);
|
||||
return remaining.length > 0 && !remaining.includes(PATH_SEP);
|
||||
});
|
||||
}
|
||||
|
||||
function nodeToResource(node: FileNode, pathPrefix: string = ''): FileResource {
|
||||
const displayName = pathPrefix && node.name.startsWith(pathPrefix)
|
||||
? node.name.slice(pathPrefix.length)
|
||||
: node.name;
|
||||
const isDir = isDirectoryType(node.type);
|
||||
return {
|
||||
id: node.id,
|
||||
name: displayName,
|
||||
serverName: node.name,
|
||||
isDirectory: isDir,
|
||||
contentType: isDir ? '' : node.type,
|
||||
contentLength: node.size,
|
||||
lastModified: node.updated || node.created,
|
||||
blobId: node.blobId,
|
||||
parentId: node.parentId,
|
||||
};
|
||||
}
|
||||
|
||||
function getUniqueName(name: string, existingNames: Set<string>): string {
|
||||
if (!existingNames.has(name)) return name;
|
||||
const dotIndex = name.lastIndexOf('.');
|
||||
const base = dotIndex > 0 ? name.substring(0, dotIndex) : name;
|
||||
const ext = dotIndex > 0 ? name.substring(dotIndex) : '';
|
||||
let counter = 1;
|
||||
while (existingNames.has(`${base} (${counter})${ext}`)) counter++;
|
||||
return `${base} (${counter})${ext}`;
|
||||
}
|
||||
|
||||
function buildPathFromStack(stack: { id: string | null; name: string }[]): string {
|
||||
if (stack.length <= 1) return '/';
|
||||
return '/' + stack.slice(1).map(s => s.name).join('/');
|
||||
}
|
||||
|
||||
export const useFileStore = create<FileState>((set, get) => ({
|
||||
currentParentId: null,
|
||||
currentPath: '/',
|
||||
pathStack: [{ id: null, name: '' }],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
supportsFiles: null,
|
||||
selectedResources: new Set<string>(),
|
||||
uploadProgress: null,
|
||||
client: null,
|
||||
clipboard: null,
|
||||
uploadAbortController: null,
|
||||
lastAction: null,
|
||||
favorites: (() => {
|
||||
try { return JSON.parse(localStorage.getItem('files-favorites') || '[]'); } catch { return []; }
|
||||
})(),
|
||||
recentFiles: (() => {
|
||||
try { return JSON.parse(localStorage.getItem('files-recent-files') || '[]'); } catch { return []; }
|
||||
})(),
|
||||
|
||||
initClient: (client: JMAPClient) => {
|
||||
set({ client });
|
||||
},
|
||||
|
||||
checkSupport: async () => {
|
||||
const { client } = get();
|
||||
if (!client) {
|
||||
set({ supportsFiles: false });
|
||||
return false;
|
||||
}
|
||||
// First check capability, then probe with a real request
|
||||
const supported = await client.probeFileNodeSupport();
|
||||
if (!supported) {
|
||||
console.warn('[Files] JMAP FileNode not supported. Available capabilities:', Object.keys(client.getCapabilities()));
|
||||
}
|
||||
set({ supportsFiles: supported });
|
||||
return supported;
|
||||
},
|
||||
|
||||
navigate: async (parentId: string | null, name?: string) => {
|
||||
const { client, pathStack } = get();
|
||||
if (!client) return;
|
||||
|
||||
set({ isLoading: true, error: null, currentParentId: parentId, selectedResources: new Set() });
|
||||
|
||||
// Update path stack
|
||||
let newStack: { id: string | null; name: string }[];
|
||||
if (parentId === null) {
|
||||
newStack = [{ id: null, name: '' }];
|
||||
} else {
|
||||
// Check if navigating to a parent in the stack
|
||||
const existingIdx = pathStack.findIndex(s => s.id === parentId);
|
||||
if (existingIdx >= 0) {
|
||||
newStack = pathStack.slice(0, existingIdx + 1);
|
||||
} else {
|
||||
newStack = [...pathStack, { id: parentId, name: name || parentId }];
|
||||
}
|
||||
}
|
||||
|
||||
const newPath = buildPathFromStack(newStack);
|
||||
set({ pathStack: newStack, currentPath: newPath });
|
||||
|
||||
try { localStorage.setItem('files-last-parent-id', parentId || ''); } catch { /* ignore */ }
|
||||
try { localStorage.setItem('files-path-stack', JSON.stringify(newStack)); } catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
// Always fetch all nodes from root — Stalwart doesn't support parentId nesting
|
||||
const allNodes = await client.listFileNodes(null);
|
||||
const prefix = getPathPrefix(newPath);
|
||||
const filteredNodes = filterNodesByPrefix(allNodes, prefix);
|
||||
const resources = filteredNodes.map(n => nodeToResource(n, prefix));
|
||||
// Sort: directories first, then alphabetically
|
||||
resources.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
set({ resources, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to list directory',
|
||||
isLoading: false,
|
||||
resources: [],
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
navigateByPath: async (path: string) => {
|
||||
const { pathStack, navigate } = get();
|
||||
if (path === '/') {
|
||||
await navigate(null);
|
||||
return;
|
||||
}
|
||||
// Try to match the path against the current pathStack
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const targetDepth = segments.length;
|
||||
// pathStack[0] is root (id: null, name: ''), subsequent entries match path segments
|
||||
if (targetDepth < pathStack.length) {
|
||||
const entry = pathStack[targetDepth];
|
||||
// Verify the names match
|
||||
const stackPath = pathStack.slice(1, targetDepth + 1).map(s => s.name).join('/');
|
||||
if (stackPath === segments.join('/')) {
|
||||
await navigate(entry.id, entry.name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fallback: if we can't resolve, stay at current location
|
||||
},
|
||||
|
||||
navigateUp: async () => {
|
||||
const { pathStack, navigate } = get();
|
||||
if (pathStack.length <= 1) return;
|
||||
const parent = pathStack[pathStack.length - 2];
|
||||
await navigate(parent.id, parent.name);
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
const { currentParentId, navigate, pathStack } = get();
|
||||
const currentEntry = pathStack[pathStack.length - 1];
|
||||
await navigate(currentParentId, currentEntry?.name);
|
||||
},
|
||||
|
||||
createDirectory: async (name: string) => {
|
||||
const { client, currentPath, refresh } = get();
|
||||
if (!client) return;
|
||||
|
||||
const prefix = getPathPrefix(currentPath);
|
||||
const fullName = prefix + name;
|
||||
await client.createFileDirectory(fullName, null);
|
||||
await refresh();
|
||||
},
|
||||
|
||||
uploadFile: async (file: File) => {
|
||||
const { client, currentPath } = get();
|
||||
if (!client) return;
|
||||
|
||||
const prefix = getPathPrefix(currentPath);
|
||||
const fullName = prefix + file.name;
|
||||
const abortController = new AbortController();
|
||||
set({ uploadAbortController: abortController });
|
||||
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: 1, totalFiles: 1 } });
|
||||
|
||||
try {
|
||||
if (abortController.signal.aborted) return;
|
||||
const { blobId, type } = await client.uploadBlob(file);
|
||||
if (abortController.signal.aborted) return;
|
||||
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: 1, totalFiles: 1 } });
|
||||
await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
|
||||
} finally {
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
}
|
||||
},
|
||||
|
||||
uploadFiles: async (files: File[]) => {
|
||||
const { client, currentPath, resources } = get();
|
||||
if (!client) return;
|
||||
|
||||
const prefix = getPathPrefix(currentPath);
|
||||
const abortController = new AbortController();
|
||||
set({ uploadAbortController: abortController });
|
||||
const totalFiles = files.length;
|
||||
const existingNames = new Set(resources.map(r => r.name));
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (abortController.signal.aborted) break;
|
||||
const file = files[i];
|
||||
const uniqueName = getUniqueName(file.name, existingNames);
|
||||
existingNames.add(uniqueName);
|
||||
const fullName = prefix + uniqueName;
|
||||
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: i + 1, totalFiles } });
|
||||
|
||||
try {
|
||||
const { blobId, type } = await client.uploadBlob(file);
|
||||
if (abortController.signal.aborted) break;
|
||||
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
|
||||
await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') break;
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
await get().refresh();
|
||||
},
|
||||
|
||||
cancelUpload: () => {
|
||||
const { uploadAbortController } = get();
|
||||
if (uploadAbortController) {
|
||||
uploadAbortController.abort();
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
}
|
||||
},
|
||||
|
||||
uploadFolder: async (files: File[]) => {
|
||||
const { client, currentPath } = get();
|
||||
if (!client || files.length === 0) return;
|
||||
|
||||
const prefix = getPathPrefix(currentPath);
|
||||
const abortController = new AbortController();
|
||||
set({ uploadAbortController: abortController });
|
||||
const totalFiles = files.length;
|
||||
|
||||
// Collect unique directory paths from the uploaded folder structure
|
||||
const dirs = new Set<string>();
|
||||
for (const file of files) {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||||
const parts = relativePath.split('/');
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
dirs.add(parts.slice(0, i).join('/'));
|
||||
}
|
||||
}
|
||||
|
||||
// Create directories as flat entries with prefixed names (no parentId nesting)
|
||||
const sortedDirs = [...dirs].sort((a, b) => a.split('/').length - b.split('/').length);
|
||||
for (const dir of sortedDirs) {
|
||||
if (abortController.signal.aborted) break;
|
||||
const fullDirName = prefix + dir;
|
||||
try {
|
||||
await client.createFileDirectory(fullDirName, null);
|
||||
} catch {
|
||||
// Directory may already exist — ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Upload files with full prefixed paths
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (abortController.signal.aborted) break;
|
||||
const file = files[i];
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||||
const fullName = prefix + relativePath;
|
||||
|
||||
set({ uploadProgress: { name: relativePath, loaded: 0, total: file.size, current: i + 1, totalFiles } });
|
||||
|
||||
try {
|
||||
const { blobId, type } = await client.uploadBlob(file);
|
||||
if (abortController.signal.aborted) break;
|
||||
set({ uploadProgress: { name: relativePath, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
|
||||
await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') break;
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
await get().refresh();
|
||||
},
|
||||
|
||||
deleteResource: async (name: string) => {
|
||||
const { client, resources, refresh } = get();
|
||||
if (!client) return;
|
||||
|
||||
const resource = resources.find(r => r.name === name);
|
||||
if (!resource) return;
|
||||
|
||||
const idsToDelete = [resource.id];
|
||||
|
||||
// If deleting a folder, also delete all files inside it
|
||||
if (resource.isDirectory) {
|
||||
const allNodes = await client.listFileNodes(null);
|
||||
const folderPrefix = resource.serverName + PATH_SEP;
|
||||
for (const node of allNodes) {
|
||||
if (node.name.startsWith(folderPrefix)) {
|
||||
idsToDelete.push(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.destroyFileNodes(idsToDelete);
|
||||
await refresh();
|
||||
},
|
||||
|
||||
deleteResources: async (names: string[]) => {
|
||||
const { client, resources, refresh } = get();
|
||||
if (!client) return;
|
||||
|
||||
const idsToDelete: string[] = [];
|
||||
let allNodes: FileNode[] | null = null;
|
||||
|
||||
for (const name of names) {
|
||||
const resource = resources.find(r => r.name === name);
|
||||
if (!resource) continue;
|
||||
idsToDelete.push(resource.id);
|
||||
|
||||
if (resource.isDirectory) {
|
||||
if (!allNodes) allNodes = await client.listFileNodes(null);
|
||||
const folderPrefix = resource.serverName + PATH_SEP;
|
||||
for (const node of allNodes) {
|
||||
if (node.name.startsWith(folderPrefix)) {
|
||||
idsToDelete.push(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (idsToDelete.length === 0) return;
|
||||
|
||||
await client.destroyFileNodes(idsToDelete);
|
||||
set({ selectedResources: new Set() });
|
||||
await refresh();
|
||||
},
|
||||
|
||||
renameResource: async (oldName: string, newName: string) => {
|
||||
const { client, resources, currentPath, refresh } = get();
|
||||
if (!client) return;
|
||||
|
||||
const resource = resources.find(r => r.name === oldName);
|
||||
if (!resource) return;
|
||||
|
||||
const prefix = getPathPrefix(currentPath);
|
||||
const oldServerName = resource.serverName;
|
||||
const newServerName = prefix + newName;
|
||||
|
||||
await client.updateFileNode(resource.id, { name: newServerName });
|
||||
|
||||
// If renaming a folder, also rename all files inside it
|
||||
if (resource.isDirectory) {
|
||||
const allNodes = await client.listFileNodes(null);
|
||||
const oldFolderPrefix = oldServerName + PATH_SEP;
|
||||
const newFolderPrefix = newServerName + PATH_SEP;
|
||||
for (const node of allNodes) {
|
||||
if (node.name.startsWith(oldFolderPrefix)) {
|
||||
const newNodeName = newFolderPrefix + node.name.slice(oldFolderPrefix.length);
|
||||
await client.updateFileNode(node.id, { name: newNodeName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
lastAction: {
|
||||
type: 'rename',
|
||||
entries: [{ id: resource.id, from: { name: oldServerName }, to: { name: newServerName } }],
|
||||
sourceParentId: null,
|
||||
},
|
||||
});
|
||||
await refresh();
|
||||
},
|
||||
|
||||
downloadResource: async (name: string) => {
|
||||
const { client, resources } = get();
|
||||
if (!client) return;
|
||||
|
||||
const resource = resources.find(r => r.name === name);
|
||||
if (!resource?.blobId) return;
|
||||
|
||||
await client.downloadBlob(resource.blobId, resource.name, resource.contentType);
|
||||
},
|
||||
|
||||
downloadResources: async (names: string[]) => {
|
||||
const { downloadResource } = get();
|
||||
for (const name of names) {
|
||||
await downloadResource(name);
|
||||
}
|
||||
},
|
||||
|
||||
getImageUrl: async (name: string) => {
|
||||
const { client, resources } = get();
|
||||
if (!client) throw new Error('No client');
|
||||
|
||||
const resource = resources.find(r => r.name === name);
|
||||
if (!resource?.blobId) throw new Error('No blob');
|
||||
|
||||
return client.fetchBlobAsObjectUrl(resource.blobId, resource.name, resource.contentType);
|
||||
},
|
||||
|
||||
getFileContent: async (name: string) => {
|
||||
const { client, resources } = get();
|
||||
if (!client) throw new Error('No client');
|
||||
|
||||
const resource = resources.find(r => r.name === name);
|
||||
if (!resource?.blobId) throw new Error('No blob');
|
||||
|
||||
const url = client.getBlobDownloadUrl(resource.blobId, resource.name, resource.contentType);
|
||||
const response = await fetch(url, {
|
||||
headers: { 'Authorization': client.getAuthHeader() },
|
||||
});
|
||||
if (!response.ok) throw new Error(`Failed to fetch file: ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
return { blob, contentType: resource.contentType || 'application/octet-stream' };
|
||||
},
|
||||
|
||||
createTextFile: async (name: string) => {
|
||||
const { client, currentPath, refresh } = get();
|
||||
if (!client) return;
|
||||
|
||||
const prefix = getPathPrefix(currentPath);
|
||||
const fullName = prefix + name;
|
||||
const emptyBlob = new File([''], name, { type: 'text/plain' });
|
||||
const { blobId } = await client.uploadBlob(emptyBlob);
|
||||
await client.createFileNode(fullName, blobId, 'text/plain', 0, null);
|
||||
await refresh();
|
||||
},
|
||||
|
||||
duplicateResource: async (name: string) => {
|
||||
const { client, resources, currentPath, refresh } = get();
|
||||
if (!client) return;
|
||||
|
||||
const resource = resources.find(r => r.name === name);
|
||||
if (!resource) return;
|
||||
|
||||
const prefix = getPathPrefix(currentPath);
|
||||
const dotIdx = name.lastIndexOf('.');
|
||||
const copyName = dotIdx > 0
|
||||
? `${name.substring(0, dotIdx)} (copy)${name.substring(dotIdx)}`
|
||||
: `${name} (copy)`;
|
||||
const fullCopyName = prefix + copyName;
|
||||
|
||||
await client.copyFileNode(resource.id, fullCopyName, null);
|
||||
await refresh();
|
||||
},
|
||||
|
||||
moveToFolder: async (names: string[], targetFolder: string) => {
|
||||
const { client, resources, refresh } = get();
|
||||
if (!client) return;
|
||||
|
||||
const targetResource = resources.find(r => r.name === targetFolder && r.isDirectory);
|
||||
if (!targetResource) return;
|
||||
|
||||
const entries: UndoAction['entries'] = [];
|
||||
for (const name of names) {
|
||||
const resource = resources.find(r => r.name === name);
|
||||
if (!resource) continue;
|
||||
const newServerName = targetResource.serverName + PATH_SEP + resource.name;
|
||||
await client.updateFileNode(resource.id, { name: newServerName });
|
||||
entries.push({ id: resource.id, from: { name: resource.serverName }, to: { name: newServerName } });
|
||||
}
|
||||
set({
|
||||
selectedResources: new Set(),
|
||||
lastAction: { type: 'move', entries, sourceParentId: null },
|
||||
});
|
||||
await refresh();
|
||||
},
|
||||
|
||||
moveToParent: async (names: string[]) => {
|
||||
const { client, resources, currentPath, refresh } = get();
|
||||
if (!client || currentPath === '/') return;
|
||||
|
||||
const prefix = getPathPrefix(currentPath);
|
||||
// Parent prefix: strip the last segment from the current prefix
|
||||
// e.g. "folder∕sub∕" → "folder∕", "folder∕" → ""
|
||||
const parentPrefix = prefix.slice(0, prefix.lastIndexOf(PATH_SEP, prefix.length - 2) + 1);
|
||||
|
||||
const entries: UndoAction['entries'] = [];
|
||||
for (const name of names) {
|
||||
const resource = resources.find(r => r.name === name);
|
||||
if (!resource) continue;
|
||||
const newServerName = parentPrefix + resource.name;
|
||||
await client.updateFileNode(resource.id, { name: newServerName });
|
||||
entries.push({ id: resource.id, from: { name: resource.serverName }, to: { name: newServerName } });
|
||||
}
|
||||
set({
|
||||
selectedResources: new Set(),
|
||||
lastAction: { type: 'move', entries, sourceParentId: null },
|
||||
});
|
||||
await refresh();
|
||||
},
|
||||
|
||||
cutResources: (names: string[]) => {
|
||||
const { currentPath, resources } = get();
|
||||
const ids = names.map(n => resources.find(r => r.name === n)?.id).filter(Boolean) as string[];
|
||||
const serverNames = names.map(n => resources.find(r => r.name === n)?.serverName).filter(Boolean) as string[];
|
||||
set({ clipboard: { mode: 'cut', ids, names, serverNames, sourceParentId: null, sourcePath: currentPath } });
|
||||
},
|
||||
|
||||
copyResources: (names: string[]) => {
|
||||
const { currentPath, resources } = get();
|
||||
const ids = names.map(n => resources.find(r => r.name === n)?.id).filter(Boolean) as string[];
|
||||
const serverNames = names.map(n => resources.find(r => r.name === n)?.serverName).filter(Boolean) as string[];
|
||||
set({ clipboard: { mode: 'copy', ids, names, serverNames, sourceParentId: null, sourcePath: currentPath } });
|
||||
},
|
||||
|
||||
pasteResources: async () => {
|
||||
const { client, currentPath, clipboard, refresh } = get();
|
||||
if (!client || !clipboard) return;
|
||||
|
||||
const prefix = getPathPrefix(currentPath);
|
||||
const entries: UndoAction['entries'] = [];
|
||||
|
||||
for (let i = 0; i < clipboard.ids.length; i++) {
|
||||
const id = clipboard.ids[i];
|
||||
const displayName = clipboard.names[i];
|
||||
const oldServerName = clipboard.serverNames?.[i];
|
||||
|
||||
if (clipboard.mode === 'cut') {
|
||||
const newServerName = prefix + displayName;
|
||||
await client.updateFileNode(id, { name: newServerName });
|
||||
entries.push({ id, from: { name: oldServerName }, to: { name: newServerName } });
|
||||
} else {
|
||||
const fullName = prefix + displayName;
|
||||
await client.copyFileNode(id, fullName, null);
|
||||
}
|
||||
}
|
||||
|
||||
if (clipboard.mode === 'cut') {
|
||||
set({
|
||||
clipboard: null,
|
||||
lastAction: { type: 'move', entries, sourceParentId: null },
|
||||
});
|
||||
}
|
||||
await refresh();
|
||||
},
|
||||
|
||||
selectResource: (name: string | null) => {
|
||||
set({ selectedResources: name ? new Set([name]) : new Set() });
|
||||
},
|
||||
|
||||
toggleSelect: (name: string) => {
|
||||
const { selectedResources } = get();
|
||||
const next = new Set(selectedResources);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
set({ selectedResources: next });
|
||||
},
|
||||
|
||||
selectAll: () => {
|
||||
const { resources } = get();
|
||||
set({ selectedResources: new Set(resources.map(r => r.name)) });
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
set({ selectedResources: new Set() });
|
||||
},
|
||||
|
||||
setSelection: (names: Set<string>) => {
|
||||
set({ selectedResources: new Set(names) });
|
||||
},
|
||||
|
||||
listPath: async (path: string) => {
|
||||
const { client } = get();
|
||||
if (!client) return [];
|
||||
|
||||
try {
|
||||
const allNodes = await client.listFileNodes(null);
|
||||
const prefix = getPathPrefix(path);
|
||||
const filtered = filterNodesByPrefix(allNodes, prefix);
|
||||
return filtered.map(n => nodeToResource(n, prefix));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
listByParentId: async (parentId: string | null) => {
|
||||
const { client } = get();
|
||||
if (!client) return [];
|
||||
try {
|
||||
const allNodes = await client.listFileNodes(null);
|
||||
|
||||
if (parentId === null) {
|
||||
// Root level: nodes with simple names (no "/")
|
||||
const rootNodes = allNodes.filter(n => !n.name.includes('/'));
|
||||
return rootNodes.map(n => nodeToResource(n));
|
||||
}
|
||||
|
||||
// Find the folder node to get its server name
|
||||
const folder = allNodes.find(n => n.id === parentId);
|
||||
if (!folder) return [];
|
||||
|
||||
const prefix = folder.name + PATH_SEP;
|
||||
const filtered = filterNodesByPrefix(allNodes, prefix);
|
||||
return filtered.map(n => nodeToResource(n, prefix));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
toggleFavorite: (path: string) => {
|
||||
const { favorites } = get();
|
||||
const next = favorites.includes(path)
|
||||
? favorites.filter(f => f !== path)
|
||||
: [...favorites, path];
|
||||
set({ favorites: next });
|
||||
try { localStorage.setItem('files-favorites', JSON.stringify(next)); } catch { /* ignore */ }
|
||||
},
|
||||
|
||||
addRecentFile: (name: string, id: string) => {
|
||||
const { recentFiles } = get();
|
||||
const entry = { name, id, timestamp: Date.now() };
|
||||
const filtered = recentFiles.filter(r => r.id !== id);
|
||||
const next = [entry, ...filtered].slice(0, 20);
|
||||
set({ recentFiles: next });
|
||||
try { localStorage.setItem('files-recent-files', JSON.stringify(next)); } catch { /* ignore */ }
|
||||
},
|
||||
|
||||
undoLastAction: async () => {
|
||||
const { client, lastAction, refresh } = get();
|
||||
if (!client || !lastAction) return;
|
||||
|
||||
for (const entry of lastAction.entries) {
|
||||
await client.updateFileNode(entry.id, entry.from);
|
||||
}
|
||||
set({ lastAction: null });
|
||||
await refresh();
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
import { create } from 'zustand';
|
||||
import { WebDAVClient, type WebDAVResource } from '@/lib/webdav/client';
|
||||
|
||||
interface UploadProgress {
|
||||
name: string;
|
||||
loaded: number;
|
||||
total: number;
|
||||
current: number;
|
||||
totalFiles: number;
|
||||
}
|
||||
|
||||
interface ClipboardState {
|
||||
mode: 'cut' | 'copy';
|
||||
paths: string[];
|
||||
names: string[];
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
interface UndoAction {
|
||||
type: 'rename' | 'move';
|
||||
// For rename: from/to paths
|
||||
// For move: array of {from, to} pairs
|
||||
entries: { from: string; to: string }[];
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
interface WebDAVState {
|
||||
currentPath: string;
|
||||
resources: WebDAVResource[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
supportsWebDAV: boolean | null;
|
||||
selectedResources: Set<string>;
|
||||
uploadProgress: UploadProgress | null;
|
||||
webdavClient: WebDAVClient | null;
|
||||
clipboard: ClipboardState | null;
|
||||
uploadAbortController: AbortController | null;
|
||||
favorites: string[];
|
||||
recentFiles: { name: string; path: string; timestamp: number }[];
|
||||
lastAction: UndoAction | null;
|
||||
|
||||
// Actions
|
||||
initClient: () => void;
|
||||
checkSupport: () => Promise<boolean>;
|
||||
navigate: (path: string) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
createDirectory: (name: string) => Promise<void>;
|
||||
uploadFile: (file: File) => Promise<void>;
|
||||
uploadFiles: (files: File[]) => Promise<void>;
|
||||
uploadFolder: (files: File[]) => Promise<void>;
|
||||
cancelUpload: () => void;
|
||||
deleteResource: (name: string) => Promise<void>;
|
||||
deleteResources: (names: string[]) => Promise<void>;
|
||||
renameResource: (oldName: string, newName: string) => Promise<void>;
|
||||
downloadResource: (name: string) => Promise<void>;
|
||||
downloadResources: (names: string[]) => Promise<void>;
|
||||
getImageUrl: (name: string) => Promise<string>;
|
||||
getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>;
|
||||
createTextFile: (name: string) => Promise<void>;
|
||||
duplicateResource: (name: string) => Promise<void>;
|
||||
moveToFolder: (names: string[], targetFolder: string) => Promise<void>;
|
||||
cutResources: (names: string[]) => void;
|
||||
copyResources: (names: string[]) => void;
|
||||
pasteResources: () => Promise<void>;
|
||||
selectResource: (name: string | null) => void;
|
||||
toggleSelect: (name: string) => void;
|
||||
selectAll: () => void;
|
||||
clearSelection: () => void;
|
||||
setSelection: (names: Set<string>) => void;
|
||||
listPath: (path: string) => Promise<WebDAVResource[]>;
|
||||
toggleFavorite: (path: string) => void;
|
||||
addRecentFile: (name: string, path: string) => void;
|
||||
undoLastAction: () => Promise<void>;
|
||||
}
|
||||
|
||||
function getUniqueName(name: string, existingNames: Set<string>): string {
|
||||
if (!existingNames.has(name)) return name;
|
||||
const dotIndex = name.lastIndexOf('.');
|
||||
const base = dotIndex > 0 ? name.substring(0, dotIndex) : name;
|
||||
const ext = dotIndex > 0 ? name.substring(dotIndex) : '';
|
||||
let counter = 1;
|
||||
while (existingNames.has(`${base} (${counter})${ext}`)) counter++;
|
||||
return `${base} (${counter})${ext}`;
|
||||
}
|
||||
|
||||
export const useWebDAVStore = create<WebDAVState>((set, get) => ({
|
||||
currentPath: '/',
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
supportsWebDAV: null,
|
||||
selectedResources: new Set<string>(),
|
||||
uploadProgress: null,
|
||||
webdavClient: null,
|
||||
clipboard: null,
|
||||
uploadAbortController: null,
|
||||
lastAction: null,
|
||||
favorites: (() => {
|
||||
try { return JSON.parse(localStorage.getItem('webdav-favorites') || '[]'); } catch { return []; }
|
||||
})(),
|
||||
recentFiles: (() => {
|
||||
try { return JSON.parse(localStorage.getItem('webdav-recent-files') || '[]'); } catch { return []; }
|
||||
})(),
|
||||
|
||||
initClient: () => {
|
||||
const client = new WebDAVClient();
|
||||
set({ webdavClient: client });
|
||||
},
|
||||
|
||||
checkSupport: async () => {
|
||||
const { webdavClient } = get();
|
||||
if (!webdavClient) return false;
|
||||
|
||||
try {
|
||||
const supported = await webdavClient.checkSupport();
|
||||
set({ supportsWebDAV: supported });
|
||||
return supported;
|
||||
} catch {
|
||||
set({ supportsWebDAV: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
navigate: async (path: string) => {
|
||||
const { webdavClient } = get();
|
||||
if (!webdavClient) return;
|
||||
|
||||
set({ isLoading: true, error: null, currentPath: path, selectedResources: new Set() });
|
||||
|
||||
// Remember last directory
|
||||
try { localStorage.setItem('webdav-last-path', path); } catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
const resources = await webdavClient.list(path);
|
||||
set({ resources, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to list directory',
|
||||
isLoading: false,
|
||||
resources: [],
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
const { currentPath, navigate } = get();
|
||||
await navigate(currentPath);
|
||||
},
|
||||
|
||||
createDirectory: async (name: string) => {
|
||||
const { webdavClient, currentPath, refresh } = get();
|
||||
if (!webdavClient) return;
|
||||
|
||||
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
|
||||
await webdavClient.createDirectory(fullPath);
|
||||
await refresh();
|
||||
},
|
||||
|
||||
uploadFile: async (file: File) => {
|
||||
const { webdavClient, currentPath } = get();
|
||||
if (!webdavClient) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
set({ uploadAbortController: abortController });
|
||||
const fullPath = currentPath === '/' ? `/${file.name}` : `${currentPath}/${file.name}`;
|
||||
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: 1, totalFiles: 1 } });
|
||||
|
||||
try {
|
||||
await webdavClient.uploadFile(fullPath, file, undefined, (loaded: number, total: number) => {
|
||||
set({ uploadProgress: { name: file.name, loaded, total, current: 1, totalFiles: 1 } });
|
||||
}, abortController.signal);
|
||||
} finally {
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
}
|
||||
},
|
||||
|
||||
uploadFiles: async (files: File[]) => {
|
||||
const { webdavClient, currentPath, resources } = get();
|
||||
if (!webdavClient) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
set({ uploadAbortController: abortController });
|
||||
const totalFiles = files.length;
|
||||
const existingNames = new Set(resources.map(r => r.name));
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (abortController.signal.aborted) break;
|
||||
const file = files[i];
|
||||
const uniqueName = getUniqueName(file.name, existingNames);
|
||||
existingNames.add(uniqueName);
|
||||
const fullPath = currentPath === '/' ? `/${uniqueName}` : `${currentPath}/${uniqueName}`;
|
||||
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: i + 1, totalFiles } });
|
||||
|
||||
try {
|
||||
await webdavClient.uploadFile(fullPath, file, undefined, (loaded: number, total: number) => {
|
||||
set({ uploadProgress: { name: file.name, loaded, total, current: i + 1, totalFiles } });
|
||||
}, abortController.signal);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') break;
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
await get().refresh();
|
||||
},
|
||||
|
||||
cancelUpload: () => {
|
||||
const { uploadAbortController } = get();
|
||||
if (uploadAbortController) {
|
||||
uploadAbortController.abort();
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
}
|
||||
},
|
||||
|
||||
uploadFolder: async (files: File[]) => {
|
||||
const { webdavClient, currentPath } = get();
|
||||
if (!webdavClient || files.length === 0) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
set({ uploadAbortController: abortController });
|
||||
const totalFiles = files.length;
|
||||
|
||||
// Collect all unique directory paths to create
|
||||
const dirs = new Set<string>();
|
||||
for (const file of files) {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||||
const parts = relativePath.split('/');
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
dirs.add(parts.slice(0, i).join('/'));
|
||||
}
|
||||
}
|
||||
|
||||
// Create directories first (sorted by depth)
|
||||
const sortedDirs = [...dirs].sort((a, b) => a.split('/').length - b.split('/').length);
|
||||
for (const dir of sortedDirs) {
|
||||
if (abortController.signal.aborted) break;
|
||||
const fullPath = currentPath === '/' ? `/${dir}` : `${currentPath}/${dir}`;
|
||||
try {
|
||||
await webdavClient.createDirectory(fullPath);
|
||||
} catch {
|
||||
// Directory may already exist
|
||||
}
|
||||
}
|
||||
|
||||
// Upload files
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (abortController.signal.aborted) break;
|
||||
const file = files[i];
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||||
const fullPath = currentPath === '/' ? `/${relativePath}` : `${currentPath}/${relativePath}`;
|
||||
set({ uploadProgress: { name: relativePath, loaded: 0, total: file.size, current: i + 1, totalFiles } });
|
||||
|
||||
try {
|
||||
await webdavClient.uploadFile(fullPath, file, undefined, (loaded: number, total: number) => {
|
||||
set({ uploadProgress: { name: relativePath, loaded, total, current: i + 1, totalFiles } });
|
||||
}, abortController.signal);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') break;
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
await get().refresh();
|
||||
},
|
||||
|
||||
deleteResource: async (name: string) => {
|
||||
const { webdavClient, currentPath, refresh } = get();
|
||||
if (!webdavClient) return;
|
||||
|
||||
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
|
||||
await webdavClient.delete(fullPath);
|
||||
await refresh();
|
||||
},
|
||||
|
||||
deleteResources: async (names: string[]) => {
|
||||
const { webdavClient, currentPath, refresh } = get();
|
||||
if (!webdavClient) return;
|
||||
|
||||
for (const name of names) {
|
||||
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
|
||||
await webdavClient.delete(fullPath);
|
||||
}
|
||||
set({ selectedResources: new Set() });
|
||||
await refresh();
|
||||
},
|
||||
|
||||
renameResource: async (oldName: string, newName: string) => {
|
||||
const { webdavClient, currentPath, refresh } = get();
|
||||
if (!webdavClient) return;
|
||||
|
||||
const oldPath = currentPath === '/' ? `/${oldName}` : `${currentPath}/${oldName}`;
|
||||
const newPath = currentPath === '/' ? `/${newName}` : `${currentPath}/${newName}`;
|
||||
await webdavClient.move(oldPath, newPath);
|
||||
set({ lastAction: { type: 'rename', entries: [{ from: oldPath, to: newPath }], sourcePath: currentPath } });
|
||||
await refresh();
|
||||
},
|
||||
|
||||
downloadResource: async (name: string) => {
|
||||
const { webdavClient, currentPath } = get();
|
||||
if (!webdavClient) return;
|
||||
|
||||
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
|
||||
const { blob, filename } = await webdavClient.downloadFile(fullPath);
|
||||
|
||||
// Trigger browser download
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
downloadResources: async (names: string[]) => {
|
||||
const { downloadResource } = get();
|
||||
for (const name of names) {
|
||||
await downloadResource(name);
|
||||
}
|
||||
},
|
||||
|
||||
getImageUrl: async (name: string) => {
|
||||
const { webdavClient, currentPath } = get();
|
||||
if (!webdavClient) throw new Error('No client');
|
||||
|
||||
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
|
||||
const { blob } = await webdavClient.downloadFile(fullPath);
|
||||
return URL.createObjectURL(blob);
|
||||
},
|
||||
|
||||
getFileContent: async (name: string) => {
|
||||
const { webdavClient, currentPath } = get();
|
||||
if (!webdavClient) throw new Error('No client');
|
||||
|
||||
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
|
||||
const { blob, contentType } = await webdavClient.downloadFile(fullPath);
|
||||
return { blob, contentType };
|
||||
},
|
||||
|
||||
createTextFile: async (name: string) => {
|
||||
const { webdavClient, currentPath, refresh } = get();
|
||||
if (!webdavClient) return;
|
||||
|
||||
const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
|
||||
await webdavClient.uploadFile(fullPath, new Blob([''], { type: 'text/plain' }), 'text/plain');
|
||||
await refresh();
|
||||
},
|
||||
|
||||
duplicateResource: async (name: string) => {
|
||||
const { webdavClient, currentPath, refresh } = get();
|
||||
if (!webdavClient) return;
|
||||
|
||||
const srcPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
|
||||
const dotIdx = name.lastIndexOf('.');
|
||||
const copyName = dotIdx > 0
|
||||
? `${name.substring(0, dotIdx)} (copy)${name.substring(dotIdx)}`
|
||||
: `${name} (copy)`;
|
||||
const destPath = currentPath === '/' ? `/${copyName}` : `${currentPath}/${copyName}`;
|
||||
await webdavClient.copy(srcPath, destPath);
|
||||
await refresh();
|
||||
},
|
||||
|
||||
moveToFolder: async (names: string[], targetFolder: string) => {
|
||||
const { webdavClient, currentPath, refresh } = get();
|
||||
if (!webdavClient) return;
|
||||
|
||||
const targetBase = currentPath === '/' ? `/${targetFolder}` : `${currentPath}/${targetFolder}`;
|
||||
const entries: { from: string; to: string }[] = [];
|
||||
for (const name of names) {
|
||||
const oldPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
|
||||
const newPath = `${targetBase}/${name}`;
|
||||
await webdavClient.move(oldPath, newPath);
|
||||
entries.push({ from: oldPath, to: newPath });
|
||||
}
|
||||
set({ selectedResources: new Set(), lastAction: { type: 'move', entries, sourcePath: currentPath } });
|
||||
await refresh();
|
||||
},
|
||||
|
||||
cutResources: (names: string[]) => {
|
||||
const { currentPath } = get();
|
||||
const paths = names.map(n => currentPath === '/' ? `/${n}` : `${currentPath}/${n}`);
|
||||
set({ clipboard: { mode: 'cut', paths, names, sourcePath: currentPath } });
|
||||
},
|
||||
|
||||
copyResources: (names: string[]) => {
|
||||
const { currentPath } = get();
|
||||
const paths = names.map(n => currentPath === '/' ? `/${n}` : `${currentPath}/${n}`);
|
||||
set({ clipboard: { mode: 'copy', paths, names, sourcePath: currentPath } });
|
||||
},
|
||||
|
||||
pasteResources: async () => {
|
||||
const { webdavClient, currentPath, clipboard, refresh } = get();
|
||||
if (!webdavClient || !clipboard) return;
|
||||
|
||||
const entries: { from: string; to: string }[] = [];
|
||||
for (let i = 0; i < clipboard.paths.length; i++) {
|
||||
const srcPath = clipboard.paths[i];
|
||||
const destPath = currentPath === '/' ? `/${clipboard.names[i]}` : `${currentPath}/${clipboard.names[i]}`;
|
||||
|
||||
if (clipboard.mode === 'cut') {
|
||||
await webdavClient.move(srcPath, destPath);
|
||||
entries.push({ from: srcPath, to: destPath });
|
||||
} else {
|
||||
await webdavClient.copy(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (clipboard.mode === 'cut') {
|
||||
set({ clipboard: null, lastAction: { type: 'move', entries, sourcePath: currentPath } });
|
||||
}
|
||||
await refresh();
|
||||
},
|
||||
|
||||
selectResource: (name: string | null) => {
|
||||
set({ selectedResources: name ? new Set([name]) : new Set() });
|
||||
},
|
||||
|
||||
toggleSelect: (name: string) => {
|
||||
const { selectedResources } = get();
|
||||
const next = new Set(selectedResources);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
set({ selectedResources: next });
|
||||
},
|
||||
|
||||
selectAll: () => {
|
||||
const { resources } = get();
|
||||
set({ selectedResources: new Set(resources.map(r => r.name)) });
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
set({ selectedResources: new Set() });
|
||||
},
|
||||
|
||||
setSelection: (names: Set<string>) => {
|
||||
set({ selectedResources: new Set(names) });
|
||||
},
|
||||
|
||||
listPath: async (path: string) => {
|
||||
const { webdavClient } = get();
|
||||
if (!webdavClient) return [];
|
||||
return webdavClient.list(path);
|
||||
},
|
||||
|
||||
toggleFavorite: (path: string) => {
|
||||
const { favorites } = get();
|
||||
const next = favorites.includes(path)
|
||||
? favorites.filter(f => f !== path)
|
||||
: [...favorites, path];
|
||||
set({ favorites: next });
|
||||
try { localStorage.setItem('webdav-favorites', JSON.stringify(next)); } catch { /* ignore */ }
|
||||
},
|
||||
|
||||
addRecentFile: (name: string, path: string) => {
|
||||
const { recentFiles } = get();
|
||||
const entry = { name, path, timestamp: Date.now() };
|
||||
const filtered = recentFiles.filter(r => r.path !== path);
|
||||
const next = [entry, ...filtered].slice(0, 20);
|
||||
set({ recentFiles: next });
|
||||
try { localStorage.setItem('webdav-recent-files', JSON.stringify(next)); } catch { /* ignore */ }
|
||||
},
|
||||
|
||||
undoLastAction: async () => {
|
||||
const { webdavClient, lastAction, refresh } = get();
|
||||
if (!webdavClient || !lastAction) return;
|
||||
|
||||
// Reverse all entries
|
||||
for (const entry of lastAction.entries) {
|
||||
await webdavClient.move(entry.to, entry.from);
|
||||
}
|
||||
set({ lastAction: null });
|
||||
await refresh();
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user