From f149501dff20e0612fab5d9421dd6fa4f33baadb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 14 Mar 2026 21:47:50 +0100 Subject: [PATCH 01/22] feat: auto-fetch full email content when an email is auto-selected --- app/[locale]/page.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 8512dbbc..ac1b2959 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -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 From caed067cda966e1b5d32ee985159d3be7ce06b1c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:14:58 +0100 Subject: [PATCH 02/22] feat: implement calendar management features including create, update, and delete functionalities --- app/[locale]/calendar/page.tsx | 16 +- app/[locale]/settings/page.tsx | 3 +- app/api/fetch-ical/route.ts | 108 ++++ .../calendar/calendar-sidebar-panel.tsx | 90 ++- components/calendar/event-card.tsx | 3 +- components/calendar/ical-import-modal.tsx | 159 ++++- .../calendar/ical-subscription-modal.tsx | 200 ++++++ .../settings/calendar-management-settings.tsx | 611 ++++++++++++++++++ lib/jmap/client.ts | 70 +- locales/de/common.json | 34 + locales/en/common.json | 67 ++ locales/es/common.json | 34 + locales/fr/common.json | 34 + locales/it/common.json | 34 + locales/ja/common.json | 34 + locales/nl/common.json | 34 + locales/pt/common.json | 34 + stores/calendar-store.ts | 275 ++++++++ 18 files changed, 1772 insertions(+), 68 deletions(-) create mode 100644 app/api/fetch-ical/route.ts create mode 100644 components/calendar/ical-subscription-modal.tsx create mode 100644 components/settings/calendar-management-settings.tsx diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index cc3908cb..7e49c884 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -51,7 +51,8 @@ export default function CalendarPage() { 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(); @@ -97,6 +98,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) { @@ -712,6 +723,9 @@ export default function CalendarPage() { calendars={calendars} selectedCalendarIds={selectedCalendarIds} onToggleVisibility={toggleCalendarVisibility} + onColorChange={client ? (calendarId, color) => { + updateCalendar(client, calendarId, { color }); + } : undefined} /> )} diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 410ed688..9a23caa1 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -11,6 +11,7 @@ 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'; @@ -84,7 +85,7 @@ export default function SettingsPage() { {activeTab === 'security' && } {activeTab === 'identities' && } {activeTab === 'vacation' && } - {activeTab === 'calendar' && } + {activeTab === 'calendar' && <>
} {activeTab === 'filters' && } {activeTab === 'templates' && } {activeTab === 'folders' && } diff --git a/app/api/fetch-ical/route.ts b/app/api/fetch-ical/route.ts new file mode 100644 index 00000000..40c1aac9 --- /dev/null +++ b/app/api/fetch-ical/route.ts @@ -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 }); + } +} diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 3dd998b1..b073c005 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -1,21 +1,49 @@ "use client"; +import { useState, useRef, useEffect } from "react"; import { useTranslations } from "next-intl"; +import { Globe } 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"; interface CalendarSidebarPanelProps { calendars: Calendar[]; selectedCalendarIds: string[]; onToggleVisibility: (id: string) => void; + onColorChange?: (calendarId: string, color: string) => void; } export function CalendarSidebarPanel({ calendars, selectedCalendarIds, onToggleVisibility, + onColorChange, }: CalendarSidebarPanelProps) { const t = useTranslations("calendar"); + const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar); + + const [colorPickerId, setColorPickerId] = useState(null); + const colorPickerRef = useRef(null); + + 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]); if (calendars.length === 0) return null; @@ -30,25 +58,53 @@ export function CalendarSidebarPanel({ const color = cal.color || "#3b82f6"; return ( - + > + + + {cal.name} + + {isSubscriptionCalendar(cal.id) && ( + + )} + + + {/* Color picker popover on right-click */} + {colorPickerId === cal.id && onColorChange && ( +
+

{t("management.change_color")}

+ { + onColorChange(cal.id, c); + setColorPickerId(null); + }} + allowCustom + /> +
+ )} + ); })} diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index 851b8d87..f0574793 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -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/); diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index fae028a0..3ec240ed 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -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(null); + const [importMode, setImportMode] = useState("file"); + const [urlInput, setUrlInput] = useState(""); + const [isFetchingUrl, setIsFetchingUrl] = useState(false); const fileInputRef = useRef(null); const modalRef = useRef(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
{step === "select" && !isParsing && ( -
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" - }`} - > - -

{t("select_file")}

-

{t("drop_file")}

-

{t("supported_formats")}

- -
+ <> +
+ + +
+ + {importMode === "file" && ( +
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" + }`} + > + +

{t("select_file")}

+

{t("drop_file")}

+

{t("supported_formats")}

+ +
+ )} + + {importMode === "url" && ( +
+

{t("url_description")}

+
+ 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(); }} + /> + +
+

{t("url_hint")}

+
+ )} + )} {isParsing && ( diff --git a/components/calendar/ical-subscription-modal.tsx b/components/calendar/ical-subscription-modal.tsx new file mode 100644 index 00000000..401ccfc9 --- /dev/null +++ b/components/calendar/ical-subscription-modal.tsx @@ -0,0 +1,200 @@ +"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(null); + const modalRef = useRef(null); + + const isValid = url.trim().length > 0 && name.trim().length > 0; + + const handleSubmit = useCallback(async () => { + const trimmedUrl = url.trim(); + if (!trimmedUrl || !name.trim()) return; + + 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( + '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 ( +
+ + ); +} diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx new file mode 100644 index 00000000..dc02e548 --- /dev/null +++ b/components/settings/calendar-management-settings.tsx @@ -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 ( +
+ {CALENDAR_COLORS.map((color) => ( +
+ ); +} + +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 ( +
+
+ + 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} + /> +
+ +
+ + +
+ +
+ + +
+
+ ); +} + +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(null); + const [deletingId, setDeletingId] = useState(null); + const [clearingId, setClearingId] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [colorPickerId, setColorPickerId] = useState(null); + const [showImportModal, setShowImportModal] = useState(false); + const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); + const [deletingSubId, setDeletingSubId] = useState(null); + const [refreshingSubId, setRefreshingSubId] = useState(null); + const tImport = useTranslations('calendar.import'); + const tSub = useTranslations('calendar.subscription'); + const colorPickerRef = useRef(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 ( + +
+ {calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => { + const color = cal.color || '#3b82f6'; + + if (editingId === cal.id) { + return ( + handleUpdate(cal.id, data)} + onCancel={() => setEditingId(null)} + isLoading={isLoading} + /> + ); + } + + if (deletingId === cal.id) { + return ( +
+ +

+ {t('confirm_delete', { name: cal.name })} +

+ + +
+ ); + } + + if (clearingId === cal.id) { + return ( +
+ +

+ {t('confirm_clear', { name: cal.name })} +

+ + +
+ ); + } + + return ( +
+ {/* Color swatch - clickable to change color */} +
+
+ + + +
+ {cal.name} + {(() => { + const caldavUrl = buildCalDavUrl(cal.id); + if (!caldavUrl) return null; + return ( +
+ + + {caldavUrl} + + +
+ ); + })()} +
+ + {cal.isDefault && ( + + {t('default')} + + )} + +
+ + + {!cal.isDefault && ( + + )} +
+
+ ); + })} + + {isCreating ? ( + setIsCreating(false)} + isLoading={isLoading} + /> + ) : ( +
+ + + +
+ )} +
+ + {/* iCal Subscriptions */} + {icalSubscriptions.length > 0 && ( +
+

+ + {tSub('section_title')} +

+ {icalSubscriptions.map((sub) => { + if (deletingSubId === sub.id) { + return ( +
+ +

+ {tSub('confirm_delete', { name: sub.name })} +

+ + +
+ ); + } + + return ( +
+ + +
+ {sub.name} + + {sub.url} + + {sub.lastRefreshed && ( + + {tSub('last_refreshed', { time: new Date(sub.lastRefreshed).toLocaleString() })} + + )} +
+
+ + +
+
+ ); + })} +
+ )} + + {showImportModal && client && ( + setShowImportModal(false)} + /> + )} + + {showSubscriptionModal && client && ( + setShowSubscriptionModal(false)} + /> + )} +
+ ); +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 0f5b3726..9f2fa182 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2052,7 +2052,8 @@ export class JMAPClient { const response = await this.request([ ["Calendar/set", { accountId, - destroy: [calendarId] + destroy: [calendarId], + onDestroyRemoveEvents: true }, "0"] ], this.calendarUsing()); @@ -2070,30 +2071,31 @@ export class JMAPClient { } async getCalendarEvents(calendarIds?: string[]): Promise { - try { - const accountId = this.getCalendarsAccountId(); + const accountId = this.getCalendarsAccountId(); - const queryArgs: Record = { 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 = { 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 +2109,7 @@ export class JMAPClient { const queryArgs: Record = { accountId, filter, - limit: limit || 100, + limit: limit || 1000, }; if (sort) { queryArgs.sort = sort; @@ -2279,6 +2281,26 @@ 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 }; + } + async downloadBlob(blobId: string, name?: string, type?: string): Promise { const url = this.getBlobDownloadUrl(blobId, name, type); const response = await this.authenticatedFetch(url, {}); diff --git a/locales/de/common.json b/locales/de/common.json index bac553fe..002b27bf 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1598,9 +1598,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 +1621,32 @@ "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" } }, "advanced_search": { diff --git a/locales/en/common.json b/locales/en/common.json index 77a2aca5..7d695d82 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1598,9 +1598,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 +1621,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.", + "url_label": "Calendar URL", + "url_placeholder": "https://example.com/calendar.ics", + "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": { diff --git a/locales/es/common.json b/locales/es/common.json index 5ff45f8e..b5873bd6 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1598,9 +1598,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 +1621,32 @@ "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" } }, "advanced_search": { diff --git a/locales/fr/common.json b/locales/fr/common.json index 92201d49..4e5e602d 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1598,9 +1598,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 +1621,32 @@ "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" } }, "advanced_search": { diff --git a/locales/it/common.json b/locales/it/common.json index dea325ef..d3df2d81 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1598,9 +1598,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 +1621,32 @@ "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" } }, "advanced_search": { diff --git a/locales/ja/common.json b/locales/ja/common.json index cddaf5c3..a87b4454 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1598,9 +1598,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 +1621,32 @@ "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をクリップボードにコピーしました" } }, "advanced_search": { diff --git a/locales/nl/common.json b/locales/nl/common.json index 645137da..093c3966 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1598,9 +1598,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 +1621,32 @@ "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" } }, "advanced_search": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 4befd9fd..81fd5395 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1598,9 +1598,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 +1621,32 @@ "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" } }, "advanced_search": { diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 6e21d548..2e165ba5 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -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; rsvpEvent: (client: JMAPClient, eventId: string, participantId: string, status: string) => Promise; importEvents: (client: JMAPClient, events: Partial[], calendarId: string) => Promise; + updateCalendar: (client: JMAPClient, calendarId: string, updates: Partial) => Promise; + createCalendar: (client: JMAPClient, calendar: Partial) => Promise; + removeCalendar: (client: JMAPClient, calendarId: string) => Promise; + clearCalendarEvents: (client: JMAPClient, calendarId: string) => Promise; 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; + removeICalSubscription: (client: JMAPClient, subscriptionId: string) => Promise; + refreshICalSubscription: (client: JMAPClient, subscriptionId: string) => Promise; + refreshAllSubscriptions: (client: JMAPClient) => Promise; + 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()( @@ -265,6 +288,92 @@ export const useCalendarStore = create()( 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()( 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(); + 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()( partialize: (state) => ({ selectedCalendarIds: state.selectedCalendarIds, viewMode: state.viewMode, + icalSubscriptions: state.icalSubscriptions, }), } ) From 8c5e3ee2408f6919c475fe5d69d71fdefdac60af Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:21:59 +0100 Subject: [PATCH 03/22] feat: enhance configuration fetching and implement retry logic for API requests --- hooks/use-config.ts | 2 +- stores/auth-store.ts | 45 ++++++++++++++++++++++++++++---------------- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/hooks/use-config.ts b/hooks/use-config.ts index 1e3bf653..d0364754 100644 --- a/hooks/use-config.ts +++ b/hooks/use-config.ts @@ -29,7 +29,7 @@ interface AppConfig extends ConfigData { let configCache: ConfigData | null = null; let configPromise: Promise | null = null; -async function fetchConfig(): Promise { +export async function fetchConfig(): Promise { // Return cached config if available if (configCache) { return configCache; diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 509b4129..17561495 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -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'; @@ -163,10 +164,13 @@ export const useAuthStore = create()( 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 +246,13 @@ export const useAuthStore = create()( 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 +400,13 @@ export const useAuthStore = create()( 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 +446,13 @@ export const useAuthStore = create()( 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) { From 5accf0a86cc4eb6234631647664520aa906c327d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:40:32 +0100 Subject: [PATCH 04/22] feat: enhance contact group management with uid normalization and improved member handling --- app/[locale]/contacts/page.tsx | 11 ++- app/api/dev-jmap/[...path]/route.ts | 101 ++++++++++++++++++++++------ locales/de/common.json | 3 + locales/en/common.json | 3 + locales/es/common.json | 3 + locales/fr/common.json | 3 + locales/it/common.json | 3 + locales/ja/common.json | 3 + locales/nl/common.json | 3 + locales/pt/common.json | 3 + stores/contact-store.ts | 49 ++++++++++++-- 11 files changed, 153 insertions(+), 32 deletions(-) diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index a5b15c04..718c47cb 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -218,11 +218,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 +231,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; diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index d44f2c55..4bf44859 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -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 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 = {}; + const updated: Record = {}; + const destroyed: string[] = []; + + if (args.create) { + for (const [tempId, data] of Object.entries(args.create as Record>)) { + 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>)) { + 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, diff --git a/locales/de/common.json b/locales/de/common.json index 002b27bf..b49bb8da 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1230,6 +1230,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", @@ -1376,6 +1377,7 @@ "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 +1414,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", diff --git a/locales/en/common.json b/locales/en/common.json index 7d695d82..7a0141be 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1230,6 +1230,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", @@ -1376,6 +1377,7 @@ "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 +1414,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", diff --git a/locales/es/common.json b/locales/es/common.json index b5873bd6..8cb4941a 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1230,6 +1230,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", @@ -1376,6 +1377,7 @@ "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 +1414,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", diff --git a/locales/fr/common.json b/locales/fr/common.json index 4e5e602d..b6198658 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1230,6 +1230,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", @@ -1376,6 +1377,7 @@ "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 +1414,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", diff --git a/locales/it/common.json b/locales/it/common.json index d3df2d81..76bf97df 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1230,6 +1230,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", @@ -1376,6 +1377,7 @@ "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 +1414,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", diff --git a/locales/ja/common.json b/locales/ja/common.json index a87b4454..7287112e 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1230,6 +1230,7 @@ "empty_search_hint": "別の検索語をお試しください", "clear_search": "検索をクリア", "import_vcard": "vCardをインポート", + "delete_confirm_title": "連絡先を削除", "delete_confirm": "この連絡先を削除してもよろしいですか?", "local_mode": "連絡先はローカルに保存されています(サーバーがJMAPコンタクトをサポートしていません)", "back_to_mail": "メールに戻る", @@ -1376,6 +1377,7 @@ "create": "新しいグループ", "edit": "グループを編集", "empty": "グループがありません", + "delete_confirm_title": "グループを削除", "delete_confirm": "このグループを削除してもよろしいですか?", "name_label": "グループ名", "name_placeholder": "例:チーム、家族", @@ -1412,6 +1414,7 @@ "selected": "{count, plural, other {#件選択中}}", "select_all": "すべて選択", "delete": "削除", + "delete_confirm_title": "連絡先を削除", "delete_confirm": "{count, plural, other {#件の連絡先}}を削除しますか?", "deleted": "{count, plural, other {#件の連絡先を削除しました}}", "add_to_group": "グループに追加", diff --git a/locales/nl/common.json b/locales/nl/common.json index 093c3966..9c3d7972 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1230,6 +1230,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", @@ -1376,6 +1377,7 @@ "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 +1414,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", diff --git a/locales/pt/common.json b/locales/pt/common.json index 81fd5395..562eba64 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1230,6 +1230,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", @@ -1376,6 +1377,7 @@ "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 +1414,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", diff --git a/stores/contact-store.ts b/stores/contact-store.ts index d35a3049..1ca8e622 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -247,13 +247,27 @@ export const useContactStore = create()( 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 = {}; - 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 = { kind: 'group', @@ -294,7 +308,11 @@ export const useContactStore = create()( 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 = { members: newMembers }; if (client && get().supportsSync) { @@ -313,7 +331,28 @@ export const useContactStore = create()( 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 = { members: newMembers }; if (client && get().supportsSync) { From 05f02402e301de93a1223d09c2e08fa52f9313ca Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:53:19 +0100 Subject: [PATCH 05/22] feat: add move-to mailbox functionality in email viewer --- app/[locale]/page.tsx | 7 + components/email/email-viewer.tsx | 256 ++++++++++++++++++++++++++++-- locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 1 + locales/fr/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/nl/common.json | 1 + locales/pt/common.json | 1 + 10 files changed, 258 insertions(+), 13 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index ac1b2959..225026fb 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1407,6 +1407,13 @@ export default function Home() { 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} /> diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index f4d8987b..411b8e9c 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -2,11 +2,11 @@ import { useState, useEffect, useMemo, useRef } from "react"; import DOMPurify from "dompurify"; -import { Email, ContactCard } from "@/lib/jmap/types"; +import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; -import { formatFileSize, cn } from "@/lib/utils"; +import { formatFileSize, cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { Reply, @@ -53,6 +53,9 @@ import { StickyNote, PanelRightClose, Send, + FolderInput, + Inbox, + Folder, } from "lucide-react"; import { useTranslations } from "next-intl"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; @@ -84,11 +87,14 @@ interface EmailViewerProps { onQuickReply?: (body: string) => Promise; onMarkAsSpam?: () => void; onUndoSpam?: () => void; + onMoveToMailbox?: (mailboxId: string) => void; onBack?: () => void; onShowShortcuts?: () => void; currentUserEmail?: string; currentUserName?: string; currentMailboxRole?: string; + mailboxes?: Mailbox[]; + selectedMailbox?: string; className?: string; } @@ -333,7 +339,7 @@ function ContactSidebarPanel({
{o.name} {o.units && o.units.length > 0 && ( - — {o.units.map(u => u.name).join(", ")} + — {o.units.map(u => u.name).join(", ")} )}
))} @@ -402,11 +408,14 @@ export function EmailViewer({ onQuickReply, onMarkAsSpam, onUndoSpam, + onMoveToMailbox, onBack, onShowShortcuts, currentUserEmail, currentUserName, currentMailboxRole, + mailboxes = [], + selectedMailbox = "", className, }: EmailViewerProps) { const t = useTranslations('email_viewer'); @@ -443,13 +452,54 @@ export function EmailViewer({ const [showSourceModal, setShowSourceModal] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [tagMenuOpen, setTagMenuOpen] = useState(false); + const [moveMenuOpen, setMoveMenuOpen] = useState(false); const moreMenuRef = useRef(null); const tagMenuRef = useRef(null); + const moveMenuRef = useRef(null); const currentColor = getCurrentColor(email?.keywords); + // Build mailbox tree for move-to dropdown + const moveTargetIds = useMemo(() => new Set( + mailboxes + .filter( + (m) => + m.id !== selectedMailbox && + m.role !== "drafts" && + !m.id.startsWith("shared-") && + m.myRights?.mayAddItems + ) + .map((m) => m.id) + ), [mailboxes, selectedMailbox]); + + const moveTree = useMemo(() => { + const tree = buildMailboxTree(mailboxes); + const filterTree = (nodes: MailboxNode[]): MailboxNode[] => { + return nodes.reduce((acc, node) => { + const filteredChildren = filterTree(node.children); + if (moveTargetIds.has(node.id) || filteredChildren.length > 0) { + acc.push({ ...node, children: filteredChildren }); + } + return acc; + }, []); + }; + return filterTree(tree); + }, [mailboxes, moveTargetIds]); + + // Get mailbox icon based on role + const getMoveMailboxIcon = (role?: string) => { + switch (role) { + case "inbox": return Inbox; + case "sent": return Send; + case "drafts": return File; + case "trash": return Trash2; + case "archive": return Archive; + default: return Folder; + } + }; + // Close dropdown menus on click outside useEffect(() => { - if (!moreMenuOpen && !tagMenuOpen) return; + if (!moreMenuOpen && !tagMenuOpen && !moveMenuOpen) return; function handleClickOutside(e: MouseEvent) { if (moreMenuOpen && moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)) { setMoreMenuOpen(false); @@ -457,15 +507,19 @@ export function EmailViewer({ if (tagMenuOpen && tagMenuRef.current && !tagMenuRef.current.contains(e.target as Node)) { setTagMenuOpen(false); } + if (moveMenuOpen && moveMenuRef.current && !moveMenuRef.current.contains(e.target as Node)) { + setMoveMenuOpen(false); + } } document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); - }, [moreMenuOpen, tagMenuOpen]); + }, [moreMenuOpen, tagMenuOpen, moveMenuOpen]); // Close dropdowns when email changes useEffect(() => { setMoreMenuOpen(false); setTagMenuOpen(false); + setMoveMenuOpen(false); }, [email?.id]); // Contact sidebar state @@ -1120,6 +1174,55 @@ export function EmailViewer({ )} )} + {/* Move to folder - hidden on mobile, available in More menu */} + {moveTree.length > 0 && onMoveToMailbox && ( +
+ + {moveMenuOpen && ( +
+ {(() => { + const renderNodes = (nodes: MailboxNode[], depth = 0) => { + return nodes.map((node) => { + const Icon = getMoveMailboxIcon(node.role); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {node.name} +
+ )} + {node.children.length > 0 && renderNodes(node.children, depth + 1)} +
+ ); + }); + }; + return renderNodes(moveTree); + })()} +
+ )} +
+ )} )} - {/* Tag submenu on mobile */} + {/* Move to folder submenu on mobile */} + {moveTree.length > 0 && onMoveToMailbox && ( +
+
+
{t('move_to')}
+ {(() => { + const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { + return nodes.map((node) => { + const Icon = getMoveMailboxIcon(node.role); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {node.name} +
+ )} + {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)} +
+ ); + }); + }; + return renderMobileNodes(moveTree); + })()} +
+
+ )} {/* Tag submenu on mobile */} {colorOptions.length > 0 && (
@@ -1449,6 +1591,55 @@ export function EmailViewer({ )} )} + {/* Move to folder - hidden on mobile, available in More menu */} + {moveTree.length > 0 && onMoveToMailbox && ( +
+ + {moveMenuOpen && ( +
+ {(() => { + const renderNodes = (nodes: MailboxNode[], depth = 0) => { + return nodes.map((node) => { + const Icon = getMoveMailboxIcon(node.role); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {node.name} +
+ )} + {node.children.length > 0 && renderNodes(node.children, depth + 1)} +
+ ); + }); + }; + return renderNodes(moveTree); + })()} +
+ )} +
+ )} )} - {/* Tag submenu on mobile */} + {/* Move to folder submenu on mobile */} + {moveTree.length > 0 && onMoveToMailbox && ( +
+
+
{t('move_to')}
+ {(() => { + const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { + return nodes.map((node) => { + const Icon = getMoveMailboxIcon(node.role); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {node.name} +
+ )} + {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)} +
+ ); + }); + }; + return renderMobileNodes(moveTree); + })()} +
+
+ )} {/* Tag submenu on mobile */} {colorOptions.length > 0 && (
@@ -2216,12 +2446,12 @@ export function EmailViewer({ }} /> )} - · + · )} {email.to && email.to.length > 0 && ( <> - → {t('recipient_to_prefix')} + → {t('recipient_to_prefix')} {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)} )} diff --git a/locales/de/common.json b/locales/de/common.json index b49bb8da..51d0ae88 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -175,6 +175,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", diff --git a/locales/en/common.json b/locales/en/common.json index 7a0141be..20be071b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -175,6 +175,7 @@ "set_color": "Set tag", "tag": "Tag", "more_actions": "More actions", + "move_to": "Move to...", "remove_color": "Remove tag", "more_count": "+{count} more", "characters_count": "{count} characters", diff --git a/locales/es/common.json b/locales/es/common.json index 8cb4941a..29f914ab 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -175,6 +175,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", diff --git a/locales/fr/common.json b/locales/fr/common.json index b6198658..de397397 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -175,6 +175,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", diff --git a/locales/it/common.json b/locales/it/common.json index 76bf97df..93657688 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -175,6 +175,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", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7287112e..e90088cc 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -175,6 +175,7 @@ "set_color": "ラベルを設定", "tag": "ラベル", "more_actions": "その他の操作", + "move_to": "移動...", "remove_color": "ラベルを削除", "more_count": "他{count}件", "characters_count": "{count}文字", diff --git a/locales/nl/common.json b/locales/nl/common.json index 9c3d7972..72d21257 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -175,6 +175,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", diff --git a/locales/pt/common.json b/locales/pt/common.json index 562eba64..e9952e12 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -175,6 +175,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", From cb74e3bf73eb45ea0a774d3b8c85206787ada157 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 15 Mar 2026 02:23:09 +0100 Subject: [PATCH 06/22] feat: add mobile bottom action bar with reply and email navigation - Move Reply, Reply All, Forward from toolbar to fixed bottom bar on mobile - Add Next/Previous email navigation buttons to the bottom bar - Style bottom bar to match existing NavigationRail horizontal pattern - Add onNavigateNext/onNavigatePrev props to EmailViewer - Wire up email list index-based navigation in page.tsx - Add padding to email content area to prevent bottom bar overlap --- app/[locale]/page.tsx | 13 ++ components/email/email-viewer.tsx | 360 +++++++++++++++++++++++++----- locales/en/common.json | 6 +- 3 files changed, 323 insertions(+), 56 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 225026fb..bb09912a 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -808,6 +808,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; @@ -1403,6 +1414,8 @@ export default function Home() { setTabletListVisible(true); selectEmail(null); }} + onNavigateNext={handleNavigateNext} + onNavigatePrev={handleNavigatePrev} onShowShortcuts={() => setShowShortcutsModal(true)} currentUserEmail={client?.["username"]} currentUserName={client?.["username"]?.split("@")[0]} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 411b8e9c..da9a52b1 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -19,6 +19,7 @@ import { ChevronDown, ChevronUp, ChevronLeft, + ChevronRight, Download, Mail, Clock, @@ -89,6 +90,8 @@ interface EmailViewerProps { onUndoSpam?: () => void; onMoveToMailbox?: (mailboxId: string) => void; onBack?: () => void; + onNavigateNext?: () => void; + onNavigatePrev?: () => void; onShowShortcuts?: () => void; currentUserEmail?: string; currentUserName?: string; @@ -339,7 +342,7 @@ function ContactSidebarPanel({
{o.name} {o.units && o.units.length > 0 && ( - — {o.units.map(u => u.name).join(", ")} + — {o.units.map(u => u.name).join(", ")} )}
))} @@ -410,6 +413,8 @@ export function EmailViewer({ onUndoSpam, onMoveToMailbox, onBack, + onNavigateNext, + onNavigatePrev, onShowShortcuts, currentUserEmail, currentUserName, @@ -438,7 +443,7 @@ export function EmailViewer({ })); // Tablet list visibility - const { isTablet } = useDeviceDetection(); + const { isTablet, isMobile } = useDeviceDetection(); const { tabletListVisible } = useUIStore(); const { identities, client } = useAuthStore(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); @@ -456,6 +461,8 @@ export function EmailViewer({ const moreMenuRef = useRef(null); const tagMenuRef = useRef(null); const moveMenuRef = useRef(null); + const toolbarRef = useRef(null); + const [overflowCount, setOverflowCount] = useState(0); const currentColor = getCurrentColor(email?.keywords); // Build mailbox tree for move-to dropdown @@ -522,6 +529,41 @@ export function EmailViewer({ setMoveMenuOpen(false); }, [email?.id]); + // Dynamically detect which toolbar items overflow and should move to the More menu + useEffect(() => { + const el = toolbarRef.current; + if (!el) return; + const calculate = () => { + const items = Array.from(el.querySelectorAll('[data-overflow-item]')); + if (items.length === 0) return; + // Sort descending by priority so highest number (least important) is first + items.sort((a, b) => + Number(b.dataset.overflowPriority || 0) - Number(a.dataset.overflowPriority || 0) + ); + // Show all items to measure their natural widths + items.forEach(item => { item.style.display = ''; }); + const containerWidth = el.clientWidth; + const leftGroup = el.firstElementChild as HTMLElement; + const rightGroup = el.lastElementChild as HTMLElement; + const mainGap = parseFloat(getComputedStyle(el).gap) || 0; + // Iteratively hide items until content fits + let count = 0; + const isOverflowing = () => + leftGroup.scrollWidth + rightGroup.scrollWidth + mainGap > containerWidth + 1; + for (const item of items) { + if (!isOverflowing()) break; + // Skip items already hidden by CSS (e.g., on mobile) + if (item.offsetWidth === 0) continue; + item.style.display = 'none'; + count++; + } + setOverflowCount(prev => prev === count ? prev : count); + }; + const observer = new ResizeObserver(calculate); + observer.observe(el); + return () => observer.disconnect(); + }, [toolbarPosition]); + // Contact sidebar state const [contactSidebarEmail, setContactSidebarEmail] = useState(null); const contacts = useContactStore((s) => s.contacts); @@ -1063,6 +1105,144 @@ export function EmailViewer({ key={email.id} className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)} > + {/* Mobile More menu sidebar overlay */} + {isMobile && moreMenuOpen && ( +
setMoreMenuOpen(false)} + /> + )} + {isMobile && ( +
+
+ {t('more_actions')} + +
+
+ + {(onMarkAsSpam || onUndoSpam) && ( + + )} + {/* Move to folder */} + {moveTree.length > 0 && onMoveToMailbox && ( + <> +
+
{t('move_to')}
+ {(() => { + const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { + return nodes.map((node) => { + const Icon = getMoveMailboxIcon(node.role); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {node.name} +
+ )} + {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)} +
+ ); + }); + }; + return renderMobileNodes(moveTree); + })()} +
+ + )} + {/* Tags */} + {colorOptions.length > 0 && ( + <> +
+
{t('tag')}
+ {colorOptions.map((option) => ( + + ))} + {currentColor && ( + + )} +
+ + )} + + + {onShowShortcuts && ( + + )} +
+
+ )} {/* Main email content */}
{/* Loading overlay when fetching new email */} @@ -1081,7 +1261,7 @@ export function EmailViewer({ "max-lg:sticky max-lg:top-0 max-lg:z-10" )}>
-
+
{/* Left: Back + Reply actions */}
{isTablet && !tabletListVisible && onBack && ( @@ -1099,31 +1279,31 @@ export function EmailViewer({ variant="ghost" size="sm" onClick={() => onReply?.()} - className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0" + className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0" title={t('tooltips.reply')} > - {t('reply')} + {t('reply')}
@@ -1134,11 +1314,13 @@ export function EmailViewer({
)} - {/* Archive - hidden on mobile, available in More menu */} + {/* Archive - hidden on mobile, overflows to More menu */} - {/* Spam - hidden on mobile, available in More menu */} + {/* Spam - hidden on mobile, overflows to More menu */} {(onMarkAsSpam || onUndoSpam) && ( -
- - {/* Tag Picker - click-based, hidden on mobile (available in More menu) */} -
+ {/* Tag Picker + Divider - hidden on mobile, overflows to More menu */} +
+
+
)}
+
- {/* Print - hidden on mobile, available in More menu */} + {/* Print - hidden on mobile, overflows to More menu */} - {moreMenuOpen && ( + {moreMenuOpen && !isMobile && (
- {/* Mobile-only actions */} + {/* Overflow actions - shown when hidden from toolbar or on mobile */} )} - {/* Move to folder submenu on mobile */} + {/* Move to folder submenu */} {moveTree.length > 0 && onMoveToMailbox && ( -
+
= 3 ? "" : "sm:hidden")}>
{t('move_to')}
{(() => { @@ -1383,9 +1570,9 @@ export function EmailViewer({ })()}
- )} {/* Tag submenu on mobile */} + )} {/* Tag submenu */} {colorOptions.length > 0 && ( -
+
= 2 ? "" : "sm:hidden")}>
{t('tag')}
{colorOptions.map((option) => ( @@ -1416,7 +1603,7 @@ export function EmailViewer({ )}
@@ -1561,23 +1748,27 @@ export function EmailViewer({
)} - {/* Archive - hidden on mobile, available in More menu */} + {/* Archive - hidden on mobile, overflows to More menu */} - {/* Spam - hidden on mobile, available in More menu */} + {/* Spam - hidden on mobile, overflows to More menu */} {(onMarkAsSpam || onUndoSpam) && ( -
- - {/* Tag Picker - click-based, hidden on mobile (available in More menu) */} -
+ {/* Tag Picker + Divider - hidden on mobile, overflows to More menu */} +
+
+
)}
+
- {/* Print - hidden on mobile, available in More menu */} + {/* Print - hidden on mobile, overflows to More menu */} - {moreMenuOpen && ( + {moreMenuOpen && !isMobile && (
- {/* Mobile-only actions */} + {/* Overflow actions - shown when hidden from toolbar or on mobile */} )} - {/* Move to folder submenu on mobile */} + {/* Move to folder submenu */} {moveTree.length > 0 && onMoveToMailbox && ( -
+
= 3 ? "" : "sm:hidden")}>
{t('move_to')}
{(() => { @@ -1810,9 +2004,9 @@ export function EmailViewer({ })()}
- )} {/* Tag submenu on mobile */} + )} {/* Tag submenu */} {colorOptions.length > 0 && ( -
+
= 2 ? "" : "sm:hidden")}>
{t('tag')}
{colorOptions.map((option) => ( @@ -1843,7 +2037,7 @@ export function EmailViewer({ )} + + + + +
+ + )} + {/* Contact Detail Sidebar - desktop only */} {contactSidebarEmail && !isMobileDevice && ( Date: Sun, 15 Mar 2026 05:24:27 +0100 Subject: [PATCH 07/22] feat: add WebDAV file browser with auth improvements Add a new Files section powered by WebDAV for browsing, uploading, downloading, renaming, and deleting files and folders. New features: - WebDAV file browser with grid/list views and breadcrumb navigation - File upload (drag-and-drop and button), folder creation, rename, delete - File preview modals for images and other file types - WebDAV proxy API route to handle authentication - Navigation rail entry for Files (auto-hidden when WebDAV is unsupported) Auth improvements: - Fix premature redirects on calendar, contacts, and settings pages by adding explicit auth check on mount before redirecting to login - Persist active settings tab in localStorage Other: - Expose getAuthHeader() and getServerUrl() on JMAPClient - Add WebDAV store with connection testing and capability detection - Add i18n translations for file browser in all 8 locales (de, en, es, fr, it, ja, nl, pt) --- app/[locale]/calendar/page.tsx | 16 +- app/[locale]/contacts/page.tsx | 14 +- app/[locale]/files/page.tsx | 423 ++++++ app/[locale]/settings/page.tsx | 23 +- app/api/webdav/route.ts | 110 ++ components/files/file-browser.tsx | 1647 ++++++++++++++++++++++ components/files/file-preview-modal.tsx | 232 +++ components/files/file-upload-area.tsx | 82 ++ components/files/image-preview-modal.tsx | 106 ++ components/files/new-folder-dialog.tsx | 58 + components/files/rename-dialog.tsx | 61 + components/layout/navigation-rail.tsx | 5 +- lib/jmap/client.ts | 8 + lib/webdav/client.ts | 294 ++++ locales/de/common.json | 79 ++ locales/en/common.json | 79 ++ locales/es/common.json | 79 ++ locales/fr/common.json | 79 ++ locales/it/common.json | 79 ++ locales/ja/common.json | 79 ++ locales/nl/common.json | 79 ++ locales/pt/common.json | 79 ++ stores/webdav-store.ts | 480 +++++++ 23 files changed, 4179 insertions(+), 12 deletions(-) create mode 100644 app/[locale]/files/page.tsx create mode 100644 app/api/webdav/route.ts create mode 100644 components/files/file-browser.tsx create mode 100644 components/files/file-preview-modal.tsx create mode 100644 components/files/file-upload-area.tsx create mode 100644 components/files/image-preview-modal.tsx create mode 100644 components/files/new-folder-dialog.tsx create mode 100644 components/files/rename-dialog.tsx create mode 100644 lib/webdav/client.ts create mode 100644 stores/webdav-store.ts diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 7e49c884..f5358428 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -45,7 +45,8 @@ export default function CalendarPage() { const router = useRouter(); const t = useTranslations("calendar"); const isMobile = useIsMobile(); - const { client, isAuthenticated, logout } = useAuthStore(); + const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); + const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const { quota, isPushConnected } = useEmailStore(); const { calendars, events, selectedDate, viewMode, selectedCalendarIds, @@ -76,14 +77,21 @@ export default function CalendarPage() { // Swipe navigation ref (handlers defined after navigatePrev/navigateNext) const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null); + // Check auth on mount useEffect(() => { - if (!isAuthenticated) { + checkAuth().finally(() => { + setInitialCheckDone(true); + }); + }, [checkAuth]); + + useEffect(() => { + if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } router.push("/login"); - } else if (!supportsCalendar) { + } else if (client && !supportsCalendar) { router.push("/"); } - }, [isAuthenticated, supportsCalendar, router]); + }, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, router]); useEffect(() => { if (error) { diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index 718c47cb..245c0b32 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -38,7 +38,8 @@ type View = export default function ContactsPage() { const router = useRouter(); const t = useTranslations("contacts"); - const { client, isAuthenticated, logout } = useAuthStore(); + const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); + const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const { quota, isPushConnected } = useEmailStore(); const { contacts, @@ -77,12 +78,19 @@ export default function ContactsPage() { const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const isMobile = useIsMobile(); + // Check auth on mount useEffect(() => { - if (!isAuthenticated) { + checkAuth().finally(() => { + setInitialCheckDone(true); + }); + }, [checkAuth]); + + useEffect(() => { + if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } router.push("/login"); } - }, [isAuthenticated, router]); + }, [initialCheckDone, isAuthenticated, authLoading, router]); useEffect(() => { if (client && supportsSync && !hasFetched.current) { diff --git a/app/[locale]/files/page.tsx b/app/[locale]/files/page.tsx new file mode 100644 index 00000000..20e177c4 --- /dev/null +++ b/app/[locale]/files/page.tsx @@ -0,0 +1,423 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { useRouter } from "@/i18n/navigation"; +import { useTranslations } from "next-intl"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { useWebDAVStore } from "@/stores/webdav-store"; +import { toast } from "@/stores/toast-store"; +import { cn } from "@/lib/utils"; +import { NavigationRail } from "@/components/layout/navigation-rail"; +import { useIsMobile } from "@/hooks/use-media-query"; +import { FileBrowser } from "@/components/files/file-browser"; +import { ImagePreviewModal } from "@/components/files/image-preview-modal"; +import { FilePreviewModal } from "@/components/files/file-preview-modal"; + +export default function FilesPage() { + const router = useRouter(); + const t = useTranslations("files"); + const { isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); + const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); + const { quota, isPushConnected } = useEmailStore(); + const { + currentPath, + resources, + isLoading, + error, + supportsWebDAV, + selectedResources, + uploadProgress, + clipboard, + initClient, + checkSupport, + navigate, + refresh, + createDirectory, + uploadFile, + uploadFiles, + uploadFolder, + deleteResource, + deleteResources, + renameResource, + downloadResource, + getImageUrl, + getFileContent, + createTextFile, + duplicateResource, + downloadResources, + moveToFolder, + cutResources, + copyResources, + pasteResources, + selectResource, + toggleSelect, + selectAll, + clearSelection, + setSelection, + listPath, + favorites, + recentFiles, + toggleFavorite, + addRecentFile, + cancelUpload, + undoLastAction, + lastAction, + } = useWebDAVStore(); + + const isMobile = useIsMobile(); + const hasFetched = useRef(false); + const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); + const [previewImage, setPreviewImage] = useState(null); + const [previewFile, setPreviewFile] = useState(null); + const [showDetails, setShowDetails] = useState(false); + const [detailName, setDetailName] = useState(null); + + const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null; + + // Check auth on mount + useEffect(() => { + checkAuth().finally(() => { + setInitialCheckDone(true); + }); + }, [checkAuth]); + + // Redirect if not authenticated + useEffect(() => { + if (initialCheckDone && !isAuthenticated && !authLoading) { + try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } + router.push("/login"); + } + }, [initialCheckDone, isAuthenticated, authLoading, router]); + + // Initialize WebDAV client + useEffect(() => { + if (isAuthenticated && !hasFetched.current) { + hasFetched.current = true; + initClient(); + } + }, [isAuthenticated, initClient]); + + // Check support and load root after client is initialized + const { webdavClient } = useWebDAVStore(); + useEffect(() => { + if (webdavClient && supportsWebDAV === null) { + checkSupport().then((supported) => { + if (supported) { + let initialPath = '/'; + try { + const saved = localStorage.getItem('webdav-last-path'); + if (saved) initialPath = saved; + } catch { /* ignore */ } + navigate(initialPath); + } + }); + } + }, [webdavClient, supportsWebDAV, checkSupport, navigate]); + + const handleNavigate = useCallback((path: string) => { + navigate(path); + }, [navigate]); + + const handleCreateFolder = useCallback(async (name: string) => { + try { + await createDirectory(name); + toast.success(t("create_folder_success")); + } catch (err) { + console.error("Failed to create folder:", err); + toast.error(t("create_folder_error")); + } + }, [createDirectory, t]); + + const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB + + const handleUploadFiles = useCallback(async (files: File[]) => { + const oversized = files.filter(f => f.size > MAX_FILE_SIZE); + const valid = files.filter(f => f.size <= MAX_FILE_SIZE); + if (oversized.length > 0) { + toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" })); + } + if (valid.length === 0) return; + try { + await uploadFiles(valid); + toast.success(t("upload_success", { count: valid.length })); + } catch (err) { + console.error("Failed to upload files:", err); + toast.error(t("upload_error")); + } + }, [uploadFiles, t]); + + const handleUploadFolder = useCallback(async (files: File[]) => { + const oversized = files.filter(f => f.size > MAX_FILE_SIZE); + const valid = files.filter(f => f.size <= MAX_FILE_SIZE); + if (oversized.length > 0) { + toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" })); + } + if (valid.length === 0) return; + try { + await uploadFolder(valid); + toast.success(t("upload_success", { count: valid.length })); + } catch (err) { + console.error("Failed to upload folder:", err); + toast.error(t("upload_error")); + } + }, [uploadFolder, t]); + + const handleDelete = useCallback(async (name: string) => { + const confirmed = await confirmDialog({ + title: t("delete_confirm_title"), + message: t("delete_confirm_message", { name }), + confirmText: t("delete"), + variant: "destructive", + }); + if (!confirmed) return; + + try { + await deleteResource(name); + toast.success(t("delete_success")); + } catch (err) { + console.error("Failed to delete:", err); + toast.error(t("delete_error")); + } + }, [deleteResource, confirmDialog, t]); + + const handleBatchDelete = useCallback(async (names: string[]) => { + const confirmed = await confirmDialog({ + title: t("delete_confirm_title"), + message: t("batch_delete_confirm_message", { count: names.length }), + confirmText: t("delete"), + variant: "destructive", + }); + if (!confirmed) return; + + try { + await deleteResources(names); + toast.success(t("batch_delete_success", { count: names.length })); + } catch (err) { + console.error("Failed to batch delete:", err); + toast.error(t("delete_error")); + } + }, [deleteResources, confirmDialog, t]); + + const handleUndo = useCallback(async () => { + try { + await undoLastAction(); + toast.success(t("undo_success")); + } catch (err) { + console.error("Failed to undo:", err); + toast.error(t("undo_error")); + } + }, [undoLastAction, t]); + + const handleRename = useCallback(async (oldName: string, newName: string) => { + try { + await renameResource(oldName, newName); + toast.success(t("rename_success"), { + action: { label: t("undo"), onClick: handleUndo }, + }); + } catch (err) { + console.error("Failed to rename:", err); + toast.error(t("rename_error")); + } + }, [renameResource, t, handleUndo]); + + const handleDownload = useCallback(async (name: string) => { + try { + await downloadResource(name); + addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name); + } catch (err) { + console.error("Failed to download:", err); + toast.error(t("download_error")); + } + }, [downloadResource, addRecentFile, currentPath, t]); + + const handleBatchDownload = useCallback(async (names: string[]) => { + try { + await downloadResources(names); + } catch (err) { + console.error("Failed to batch download:", err); + toast.error(t("download_error")); + } + }, [downloadResources, t]); + + const handleCreateTextFile = useCallback(async (name: string) => { + try { + await createTextFile(name); + toast.success(t("create_file_success")); + } catch (err) { + console.error("Failed to create file:", err); + toast.error(t("create_file_error")); + } + }, [createTextFile, t]); + + const handleDuplicate = useCallback(async (name: string) => { + try { + await duplicateResource(name); + toast.success(t("duplicate_success")); + } catch (err) { + console.error("Failed to duplicate:", err); + toast.error(t("duplicate_error")); + } + }, [duplicateResource, t]); + + const handleMoveToFolder = useCallback(async (names: string[], targetFolder: string) => { + try { + await moveToFolder(names, targetFolder); + toast.success(t("move_success", { count: names.length }), { + action: { label: t("undo"), onClick: handleUndo }, + }); + } catch (err) { + console.error("Failed to move:", err); + toast.error(t("move_error")); + } + }, [moveToFolder, t, handleUndo]); + + const handlePaste = useCallback(async () => { + try { + await pasteResources(); + toast.success(t("paste_success"), { + action: lastAction ? { label: t("undo"), onClick: handleUndo } : undefined, + }); + } catch (err) { + console.error("Failed to paste:", err); + toast.error(t("paste_error")); + } + }, [pasteResources, t, lastAction, handleUndo]); + + const handlePreviewImage = useCallback((name: string) => { + setPreviewImage(name); + addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name); + }, [addRecentFile, currentPath]); + + const handlePreviewFile = useCallback((name: string) => { + setPreviewFile(name); + addRecentFile(name, currentPath + (currentPath.endsWith('/') ? '' : '/') + name); + }, [addRecentFile, currentPath]); + + const handleShowDetails = useCallback((name: string) => { + setDetailName(name); + setShowDetails(true); + }, []); + + const handleToggleDetails = useCallback(() => { + setShowDetails(v => !v); + }, []); + + if (!isAuthenticated) return null; + + return ( +
+ {!isMobile && ( +
+ { logout(); router.push('/login'); }} + /> +
+ )} + +
+
+
+
+
+ +
+
+ +
+ {supportsWebDAV === false ? ( +
+

{t("not_available")}

+
+ ) : ( + + )} +
+
+
+ + {isMobile && ( + + )} +
+ + {/* Image preview modal */} + {previewImage && ( + setPreviewImage(null)} + onDownload={handleDownload} + getImageUrl={getImageUrl} + /> + )} + + {/* File preview modal (text, PDF, audio, video, markdown) */} + {previewFile && ( + setPreviewFile(null)} + onDownload={handleDownload} + getFileContent={getFileContent} + /> + )} + + +
+ ); +} diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 9a23caa1..bacf2ed5 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -31,19 +31,33 @@ export default function SettingsPage() { const router = useRouter(); const t = useTranslations('settings'); const tSidebar = useTranslations('sidebar'); - const { client, isAuthenticated, logout } = useAuthStore(); + const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); + const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const { quota, isPushConnected } = useEmailStore(); const { stalwartFeaturesEnabled } = useConfig(); - const [activeTab, setActiveTab] = useState('appearance'); + const [activeTab, setActiveTab] = useState(() => { + try { + const saved = localStorage.getItem('settings-active-tab'); + if (saved) return saved as Tab; + } catch { /* ignore */ } + return 'appearance'; + }); const [mobileShowContent, setMobileShowContent] = useState(false); const isDesktop = useIsDesktop(); + // Check auth on mount useEffect(() => { - if (!isAuthenticated) { + checkAuth().finally(() => { + setInitialCheckDone(true); + }); + }, [checkAuth]); + + useEffect(() => { + if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } router.push('/login'); } - }, [isAuthenticated, router]); + }, [initialCheckDone, isAuthenticated, authLoading, router]); if (!isAuthenticated) { return null; @@ -70,6 +84,7 @@ export default function SettingsPage() { const handleTabSelect = (tabId: Tab) => { setActiveTab(tabId); + try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ } if (!isDesktop) { setMobileShowContent(true); } diff --git a/app/api/webdav/route.ts b/app/api/webdav/route.ts new file mode 100644 index 00000000..9bda6c1a --- /dev/null +++ b/app/api/webdav/route.ts @@ -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 = { + '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 }); + } +} diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx new file mode 100644 index 00000000..be6d21d0 --- /dev/null +++ b/components/files/file-browser.tsx @@ -0,0 +1,1647 @@ +"use client"; + +import { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { + Folder, File, Upload, FolderPlus, Download, Trash2, + Pencil, RefreshCw, Home, ChevronRight, MoreVertical, + Search, ArrowUp, ArrowDown, X, LayoutGrid, LayoutList, + Copy, Clipboard, Scissors, Info, Image as ImageIcon, + FilePlus, CopyPlus, FileText, FileAudio, FileVideo, + AlertCircle, Star, Clock, FolderUp, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn, formatFileSize } from "@/lib/utils"; +import { NewFolderDialog } from "@/components/files/new-folder-dialog"; +import { RenameDialog } from "@/components/files/rename-dialog"; +import { FileUploadArea } from "@/components/files/file-upload-area"; +import type { WebDAVResource } from "@/lib/webdav/client"; + +type SortKey = "name" | "size" | "modified"; +type SortDir = "asc" | "desc"; +type ViewMode = "list" | "grid"; + +interface ClipboardState { + mode: "cut" | "copy"; + paths: string[]; + names: string[]; + sourcePath: string; +} + +interface FileBrowserProps { + currentPath: string; + resources: WebDAVResource[]; + isLoading: boolean; + error: string | null; + selectedResources: Set; + uploadProgress: { name: string; loaded: number; total: number; current: number; totalFiles: number } | null; + clipboard: ClipboardState | null; + onNavigate: (path: string) => void; + onCreateFolder: (name: string) => Promise; + onUploadFiles: (files: File[]) => Promise; + onUploadFolder: (files: File[]) => Promise; + onCancelUpload: () => void; + onDelete: (name: string) => Promise; + onBatchDelete: (names: string[]) => Promise; + onRename: (oldName: string, newName: string) => Promise; + onDownload: (name: string) => Promise; + onBatchDownload: (names: string[]) => Promise; + onRefresh: () => Promise; + onSelectResource: (name: string | null) => void; + onToggleSelect: (name: string) => void; + onSelectAll: () => void; + onClearSelection: () => void; + onSetSelection: (names: Set) => void; + onCut: (names: string[]) => void; + onCopy: (names: string[]) => void; + onPaste: () => Promise; + onMoveToFolder: (names: string[], targetFolder: string) => Promise; + onPreviewImage: (name: string) => void; + onPreviewFile: (name: string) => void; + onShowDetails: (name: string) => void; + onCreateTextFile: (name: string) => Promise; + onDuplicate: (name: string) => Promise; + getImageUrl: (name: string) => Promise; + listPath: (path: string) => Promise; + favorites: string[]; + recentFiles: { name: string; path: string; timestamp: number }[]; + onToggleFavorite: (path: string) => void; + showDetails: boolean; + onToggleDetails: () => void; + detailResource: WebDAVResource | null; +} + +const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]); + +function isImageFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + return IMAGE_EXTENSIONS.has(ext); +} + +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 isTextFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + const baseName = name.toLowerCase(); + return TEXT_EXTENSIONS.has(ext) || ["dockerfile", "makefile", "readme", "license", "changelog"].includes(baseName); +} + +const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "flac", "aac", "m4a", "wma", "opus"]); +function isAudioFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + return AUDIO_EXTENSIONS.has(ext); +} + +const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "ogv", "mov", "avi", "mkv", "m4v"]); +function isVideoFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + return VIDEO_EXTENSIONS.has(ext); +} + +const PDF_EXTENSIONS = new Set(["pdf"]); +function isPdfFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() || ""; + return PDF_EXTENSIONS.has(ext); +} + +function isPreviewable(name: string): boolean { + return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name); +} + +const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB + +function getFileIcon(resource: WebDAVResource) { + if (resource.isDirectory) { + return ; + } + if (isImageFile(resource.name)) { + return ; + } + if (isAudioFile(resource.name)) { + return ; + } + if (isVideoFile(resource.name)) { + return ; + } + if (isTextFile(resource.name)) { + return ; + } + return ; +} + +function getGridIcon(resource: WebDAVResource) { + if (resource.isDirectory) { + return ; + } + if (isImageFile(resource.name)) { + return ; + } + if (isAudioFile(resource.name)) { + return ; + } + if (isVideoFile(resource.name)) { + return ; + } + if (isTextFile(resource.name)) { + return ; + } + return ; +} + +function Thumbnail({ name, getImageUrl: fetchUrl, size = "sm" }: { + name: string; + getImageUrl: (n: string) => Promise; + size?: "sm" | "lg"; +}) { + const [src, setSrc] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + fetchUrl(name).then(url => { if (!cancelled) setSrc(url); }).catch(() => { if (!cancelled) setFailed(true); }); + return () => { cancelled = true; }; + }, [name, fetchUrl]); + + if (failed || !src) { + return size === "sm" + ? + : ; + } + + const cls = size === "sm" + ? "w-5 h-5 rounded object-cover" + : "w-10 h-10 rounded object-cover"; + + return {name}; +} + +function formatDate(dateString: string): string { + if (!dateString) return ""; + try { + return new Date(dateString).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return dateString; + } +} + +function SkeletonRow() { + return ( + + +
+
+
+
+
+ + +
+ + +
+ + +
+ + + ); +} + +export function FileBrowser({ + currentPath, + resources, + isLoading, + error, + selectedResources, + uploadProgress, + onNavigate, + onCreateFolder, + onUploadFiles, + onUploadFolder, + onCancelUpload, + onDelete, + onBatchDelete, + onRename, + onDownload, + onBatchDownload, + onRefresh, + onSelectResource, + onToggleSelect, + onSelectAll, + onClearSelection, + onSetSelection, + onCut, + onCopy, + onPaste, + onMoveToFolder, + onPreviewImage, + onPreviewFile, + onShowDetails, + onCreateTextFile, + onDuplicate, + getImageUrl, + listPath, + favorites, + recentFiles, + onToggleFavorite, + showDetails, + onToggleDetails, + detailResource, + clipboard, +}: FileBrowserProps) { + const t = useTranslations("files"); + const [showNewFolder, setShowNewFolder] = useState(false); + const [renameTarget, setRenameTarget] = useState(null); + const [isDraggingOver, setIsDraggingOver] = useState(false); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null); + const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null); + const [showNewTextFile, setShowNewTextFile] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [showSearch, setShowSearch] = useState(false); + const [sortKey, setSortKey] = useState("name"); + const [sortDir, setSortDir] = useState("asc"); + const [viewMode, setViewMode] = useState(() => { + if (typeof window !== "undefined") { + return (localStorage.getItem("webdav-view-mode") as ViewMode) || "list"; + } + return "list"; + }); + const [dragTarget, setDragTarget] = useState(null); + const [breadcrumbDropdown, setBreadcrumbDropdown] = useState<{ + path: string; + folders: WebDAVResource[]; + x: number; + y: number; + } | null>(null); + const [marquee, setMarquee] = useState<{ + startX: number; + startY: number; + currentX: number; + currentY: number; + } | null>(null); + const marqueeRef = useRef<{ + additive: boolean; + initialSelection: Set; + } | null>(null); + const fileInputRef = useRef(null); + const folderInputRef = useRef(null); + const searchInputRef = useRef(null); + const containerRef = useRef(null); + const scrollAreaRef = useRef(null); + + // Reset search when navigating + useEffect(() => { + setSearchQuery(""); + }, [currentPath]); + + // Focus search input when shown + useEffect(() => { + if (showSearch) searchInputRef.current?.focus(); + }, [showSearch]); + + // Persist view mode + const handleViewModeChange = useCallback((mode: ViewMode) => { + setViewMode(mode); + localStorage.setItem("webdav-view-mode", mode); + }, []); + + // Filter and sort resources + const displayResources = useMemo(() => { + let filtered = resources; + if (searchQuery) { + const q = searchQuery.toLowerCase(); + filtered = resources.filter(r => r.name.toLowerCase().includes(q)); + } + + const sorted = [...filtered].sort((a, b) => { + // Directories always first + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; + + let cmp = 0; + switch (sortKey) { + case "name": + cmp = a.name.localeCompare(b.name); + break; + case "size": + cmp = a.contentLength - b.contentLength; + break; + case "modified": + cmp = new Date(a.lastModified || 0).getTime() - new Date(b.lastModified || 0).getTime(); + break; + } + return sortDir === "asc" ? cmp : -cmp; + }); + return sorted; + }, [resources, searchQuery, sortKey, sortDir]); + + // Build breadcrumb segments + const breadcrumbs = currentPath === '/' + ? [{ name: t("breadcrumb_root"), path: '/' }] + : [ + { name: t("breadcrumb_root"), path: '/' }, + ...currentPath.split('/').filter(Boolean).map((segment, i, arr) => ({ + name: segment, + path: '/' + arr.slice(0, i + 1).join('/'), + })), + ]; + + const handleNavigateUp = () => { + if (currentPath === '/') return; + const segments = currentPath.split('/').filter(Boolean); + segments.pop(); + const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/'); + onNavigate(parentPath); + }; + + const handleResourceClick = (resource: WebDAVResource, e: React.MouseEvent) => { + if (resource.isDirectory) { + // Ctrl/Cmd+click on directories also toggles selection + if (e.ctrlKey || e.metaKey) { + onToggleSelect(resource.name); + return; + } + const newPath = currentPath === '/' + ? `/${resource.name}` + : `${currentPath}/${resource.name}`; + onNavigate(newPath); + } else { + if (e.ctrlKey || e.metaKey) { + onToggleSelect(resource.name); + } else if (e.shiftKey && resources.length > 0) { + // Shift+click range select + handleShiftSelect(resource.name); + } else { + onSelectResource(resource.name === [...selectedResources][0] && selectedResources.size === 1 ? null : resource.name); + } + } + }; + + const handleShiftSelect = (targetName: string) => { + const lastSelected = [...selectedResources].pop(); + if (!lastSelected) { + onToggleSelect(targetName); + return; + } + const names = displayResources.map(r => r.name); + const startIdx = names.indexOf(lastSelected); + const endIdx = names.indexOf(targetName); + if (startIdx === -1 || endIdx === -1) return; + const from = Math.min(startIdx, endIdx); + const to = Math.max(startIdx, endIdx); + for (let i = from; i <= to; i++) { + if (!selectedResources.has(names[i])) { + onToggleSelect(names[i]); + } + } + }; + + // Marquee (rubber-band) selection + const handleMarqueeMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; + const target = e.target as HTMLElement; + if (target.closest('[data-resource]') || target.closest('input') || target.closest('button') || target.closest('thead')) return; + + const scrollArea = scrollAreaRef.current; + if (!scrollArea) return; + + const rect = scrollArea.getBoundingClientRect(); + const x = e.clientX - rect.left + scrollArea.scrollLeft; + const y = e.clientY - rect.top + scrollArea.scrollTop; + + const additive = e.ctrlKey || e.metaKey; + marqueeRef.current = { + additive, + initialSelection: additive ? new Set(selectedResources) : new Set(), + }; + + setMarquee({ startX: x, startY: y, currentX: x, currentY: y }); + + if (!additive) { + onClearSelection(); + } + + e.preventDefault(); + }, [selectedResources, onClearSelection]); + + useEffect(() => { + if (!marquee) return; + + const handleMouseMove = (e: MouseEvent) => { + const scrollArea = scrollAreaRef.current; + if (!scrollArea) return; + + const rect = scrollArea.getBoundingClientRect(); + const x = e.clientX - rect.left + scrollArea.scrollLeft; + const y = e.clientY - rect.top + scrollArea.scrollTop; + + setMarquee(prev => prev ? { ...prev, currentX: x, currentY: y } : null); + + // Calculate marquee rect + const info = marqueeRef.current; + if (!info) return; + + const mx = Math.min(marquee.startX, x); + const my = Math.min(marquee.startY, y); + const mw = Math.abs(x - marquee.startX); + const mh = Math.abs(y - marquee.startY); + + // Find intersecting items + const elements = scrollArea.querySelectorAll('[data-resource]'); + const containerRect = scrollArea.getBoundingClientRect(); + const newSelection = new Set(info.initialSelection); + + elements.forEach(el => { + const name = el.getAttribute('data-resource'); + if (!name) return; + const elRect = el.getBoundingClientRect(); + const elX = elRect.left - containerRect.left + scrollArea.scrollLeft; + const elY = elRect.top - containerRect.top + scrollArea.scrollTop; + + if (mx < elX + elRect.width && mx + mw > elX && my < elY + elRect.height && my + mh > elY) { + newSelection.add(name); + } + }); + + onSetSelection(newSelection); + }; + + const handleMouseUp = () => { + setMarquee(null); + marqueeRef.current = null; + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [marquee, onSetSelection]); + + const handleResourceDoubleClick = (resource: WebDAVResource) => { + if (resource.isDirectory) { + const newPath = currentPath === '/' + ? `/${resource.name}` + : `${currentPath}/${resource.name}`; + onNavigate(newPath); + } else if (isPreviewable(resource.name)) { + if (isImageFile(resource.name)) { + onPreviewImage(resource.name); + } else { + onPreviewFile(resource.name); + } + } else { + onDownload(resource.name); + } + }; + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDraggingOver(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDraggingOver(false); + }, []); + + const handleDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDraggingOver(false); + + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + setIsUploading(true); + try { + await onUploadFiles(files); + } finally { + setIsUploading(false); + } + } + }, [onUploadFiles]); + + const handleFileInputChange = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length > 0) { + setIsUploading(true); + try { + await onUploadFiles(files); + } finally { + setIsUploading(false); + } + } + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleFolderInputChange = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length > 0) { + setIsUploading(true); + try { + await onUploadFolder(files); + } finally { + setIsUploading(false); + } + } + if (folderInputRef.current) { + folderInputRef.current.value = ''; + } + }; + + const contextMenuRef = useRef(null); + + const handleContextMenu = (e: React.MouseEvent, name: string) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, name }); + }; + + // Adjust context menu position to stay within viewport + useEffect(() => { + if (contextMenu && contextMenuRef.current) { + const menu = contextMenuRef.current; + const rect = menu.getBoundingClientRect(); + let { x, y } = contextMenu; + let adjusted = false; + + if (x + rect.width > window.innerWidth) { + x = window.innerWidth - rect.width - 8; + adjusted = true; + } + if (y + rect.height > window.innerHeight) { + y = window.innerHeight - rect.height - 8; + adjusted = true; + } + + if (adjusted) { + setContextMenu({ ...contextMenu, x, y }); + } + } + }, [contextMenu]); + + const handleContainerClick = () => { + if (contextMenu) setContextMenu(null); + if (emptyContextMenu) setEmptyContextMenu(null); + if (breadcrumbDropdown) setBreadcrumbDropdown(null); + }; + + const handleBreadcrumbRightClick = async (e: React.MouseEvent, crumbPath: string) => { + e.preventDefault(); + e.stopPropagation(); + const parentPath = crumbPath === '/' ? '/' : '/' + crumbPath.split('/').filter(Boolean).slice(0, -1).join('/') || '/'; + try { + const items = await listPath(parentPath === '/' ? '/' : parentPath); + const folders = items.filter(r => r.isDirectory); + setBreadcrumbDropdown({ path: parentPath, folders, x: e.clientX, y: e.clientY }); + } catch { + // ignore + } + }; + + const handleSortClick = (key: SortKey) => { + if (sortKey === key) { + setSortDir(d => d === "asc" ? "desc" : "asc"); + } else { + setSortKey(key); + setSortDir("asc"); + } + }; + + const SortIndicator = ({ column }: { column: SortKey }) => { + if (sortKey !== column) return null; + return sortDir === "asc" + ? + : ; + }; + + // Keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + // Don't capture when typing in inputs or dialogs + const tag = (e.target as HTMLElement)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + if (showNewFolder || renameTarget) return; + + // Ctrl+F / Cmd+F: toggle search + if ((e.ctrlKey || e.metaKey) && e.key === 'f') { + e.preventDefault(); + setShowSearch(v => !v); + return; + } + + // Ctrl+A / Cmd+A: select all + if ((e.ctrlKey || e.metaKey) && e.key === 'a') { + e.preventDefault(); + onSelectAll(); + return; + } + + // Ctrl+C / Cmd+C: copy selected + if ((e.ctrlKey || e.metaKey) && e.key === 'c') { + if (selectedResources.size > 0) { + e.preventDefault(); + onCopy([...selectedResources]); + } + return; + } + + // Ctrl+X / Cmd+X: cut selected + if ((e.ctrlKey || e.metaKey) && e.key === 'x') { + if (selectedResources.size > 0) { + e.preventDefault(); + onCut([...selectedResources]); + } + return; + } + + // Ctrl+V / Cmd+V: paste + if ((e.ctrlKey || e.metaKey) && e.key === 'v') { + if (clipboard) { + e.preventDefault(); + onPaste(); + } + return; + } + + // Escape: clear selection, close search + if (e.key === 'Escape') { + if (showSearch) { setShowSearch(false); setSearchQuery(""); } + else if (selectedResources.size > 0) onClearSelection(); + return; + } + + // Delete: delete selected + if (e.key === 'Delete') { + if (selectedResources.size === 1) { + onDelete([...selectedResources][0]); + } else if (selectedResources.size > 1) { + onBatchDelete([...selectedResources]); + } + return; + } + + // F2: rename selected (single) + if (e.key === 'F2' && selectedResources.size === 1) { + setRenameTarget([...selectedResources][0]); + return; + } + + // Enter: open selected directory or download selected file + if (e.key === 'Enter' && selectedResources.size === 1) { + const name = [...selectedResources][0]; + const resource = resources.find(r => r.name === name); + if (resource?.isDirectory) { + const newPath = currentPath === '/' + ? `/${resource.name}` + : `${currentPath}/${resource.name}`; + onNavigate(newPath); + } else if (resource) { + onDownload(resource.name); + } + return; + } + + // Backspace: navigate up + if (e.key === 'Backspace' && currentPath !== '/') { + handleNavigateUp(); + return; + } + }; + + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [selectedResources, resources, currentPath, showSearch, showNewFolder, renameTarget, onDelete, onBatchDelete, onSelectAll, onClearSelection, onNavigate, onDownload, onCut, onCopy, onPaste, clipboard, handleNavigateUp]); + + const allSelected = resources.length > 0 && selectedResources.size === resources.length; + const someSelected = selectedResources.size > 0 && !allSelected; + + return ( +
+ {/* Toolbar */} +
+ {/* Breadcrumbs */} + + + {/* Action buttons */} +
+ {selectedResources.size > 1 && ( + <> + + + + )} + {clipboard && ( + + )} + + + + + + + + +
+
+ + {/* Search bar */} + {showSearch && ( +
+ + setSearchQuery(e.target.value)} + placeholder={t("search_placeholder")} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + onKeyDown={(e) => { + if (e.key === 'Escape') { + setShowSearch(false); + setSearchQuery(""); + } + }} + /> + {searchQuery && ( + + )} +
+ )} + + {/* Hidden file input */} + + {/* Hidden folder input */} + )} + /> + + {/* Error display with retry */} + {error && ( +
+ + {error} + +
+ )} + + {/* Drag overlay */} + {isDraggingOver && ( +
+
+ +

{t("drop_files_here")}

+
+
+ )} + + {/* Upload progress */} + {uploadProgress && ( +
+
+ + + {t("uploading")} {uploadProgress.name} + {uploadProgress.totalFiles > 1 && ( + + ({uploadProgress.current}/{uploadProgress.totalFiles}) + + )} + + + {uploadProgress.total > 0 + ? `${Math.round((uploadProgress.loaded / uploadProgress.total) * 100)}%` + : "…"} + + +
+
+
0 + ? `${(uploadProgress.loaded / uploadProgress.total) * 100}%` + : '0%' }} + /> +
+
+ )} + + {/* File list */} +
+ {/* Favorites & Recent sidebar */} + {(favorites.length > 0 || recentFiles.length > 0) && ( +
+ {favorites.length > 0 && ( +
+

+ + {t("favorites")} +

+
+ {favorites.map((fav) => ( + + ))} +
+
+ )} + {recentFiles.length > 0 && ( +
+

+ + {t("recent")} +

+
+ {recentFiles.slice(0, 10).map((recent) => ( + + ))} +
+
+ )} +
+ )} +
+ {isLoading && resources.length === 0 ? ( + + + + + + + + + + + + + + + + +
+
+
+ {t("name")} +
+
{t("size")}{t("modified")} +
+ ) : resources.length === 0 && !searchQuery ? ( + { + setIsUploading(true); + try { + await onUploadFiles(files); + } finally { + setIsUploading(false); + } + }} + onCreateFolder={() => setShowNewFolder(true)} + onCreateTextFile={() => setShowNewTextFile(true)} + /> + ) : viewMode === "grid" ? ( + /* ======= GRID VIEW ======= */ +
+ {currentPath !== '/' && !searchQuery && ( +
+ + .. +
+ )} + {displayResources.length === 0 && searchQuery ? ( +

{t("no_results")}

+ ) : ( +
{ + if ((e.target as HTMLElement).closest('[data-resource]')) return; + e.preventDefault(); + setEmptyContextMenu({ x: e.clientX, y: e.clientY }); + }} + > + {displayResources.map((resource) => ( +
{ + const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name]; + e.dataTransfer.setData("application/x-webdav-names", JSON.stringify(names)); + e.dataTransfer.effectAllowed = "move"; + }} + onDragOver={(e) => { + if (resource.isDirectory) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragTarget(resource.name); + } + }} + onDragLeave={() => setDragTarget(null)} + onDrop={async (e) => { + e.preventDefault(); + setDragTarget(null); + if (!resource.isDirectory) return; + const raw = e.dataTransfer.getData("application/x-webdav-names"); + if (!raw) return; + const names: string[] = JSON.parse(raw); + if (names.includes(resource.name)) return; + await onMoveToFolder(names, resource.name); + }} + className={cn( + "flex flex-col items-center gap-2 p-3 rounded-lg cursor-pointer transition-colors relative group", + selectedResources.has(resource.name) + ? "bg-primary/10 ring-1 ring-primary/30" + : dragTarget === resource.name + ? "bg-primary/5 ring-1 ring-primary/40" + : "hover:bg-muted/50", + clipboard?.mode === "cut" && clipboard.names.includes(resource.name) && "opacity-50" + )} + onClick={(e) => handleResourceClick(resource, e)} + onDoubleClick={() => handleResourceDoubleClick(resource)} + onContextMenu={(e) => handleContextMenu(e, resource.name)} + > + onToggleSelect(resource.name)} + className="w-3.5 h-3.5 rounded border-border accent-primary cursor-pointer absolute top-2 left-2 opacity-0 group-hover:opacity-100 data-[checked=true]:opacity-100" + data-checked={selectedResources.has(resource.name)} + onClick={(e) => e.stopPropagation()} + /> + {isImageFile(resource.name) + ? + : getGridIcon(resource)} + + {resource.name} + +
+ ))} +
+ )} +
+ ) : ( + /* ======= LIST VIEW ======= */ + { + if ((e.target as HTMLElement).closest('tr[data-resource]')) return; + e.preventDefault(); + setEmptyContextMenu({ x: e.clientX, y: e.clientY }); + }} + > + + + + + + + + + {currentPath !== '/' && !searchQuery && ( + + + + )} + {displayResources.length === 0 && searchQuery ? ( + + + + ) : displayResources.map((resource) => ( + { + const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name]; + e.dataTransfer.setData("application/x-webdav-names", JSON.stringify(names)); + e.dataTransfer.effectAllowed = "move"; + }} + onDragOver={(e) => { + if (resource.isDirectory) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragTarget(resource.name); + } + }} + onDragLeave={() => setDragTarget(null)} + onDrop={async (e) => { + e.preventDefault(); + setDragTarget(null); + if (!resource.isDirectory) return; + const raw = e.dataTransfer.getData("application/x-webdav-names"); + if (!raw) return; + const names: string[] = JSON.parse(raw); + if (names.includes(resource.name)) return; + await onMoveToFolder(names, resource.name); + }} + className={cn( + "border-b border-border cursor-pointer transition-colors", + selectedResources.has(resource.name) + ? "bg-primary/10" + : dragTarget === resource.name + ? "bg-primary/5 ring-1 ring-primary/40" + : "hover:bg-muted/50", + clipboard?.mode === "cut" && clipboard.names.includes(resource.name) && "opacity-50" + )} + onClick={(e) => handleResourceClick(resource, e)} + onDoubleClick={() => handleResourceDoubleClick(resource)} + onContextMenu={(e) => handleContextMenu(e, resource.name)} + > + + + + + + ))} + +
+
+ { if (el) el.indeterminate = someSelected; }} + onChange={() => allSelected ? onClearSelection() : onSelectAll()} + className="w-4 h-4 rounded border-border accent-primary cursor-pointer" + onClick={(e) => e.stopPropagation()} + /> + +
+
+ + + + +
+
+
+ + .. +
+
+ + +
+ {t("no_results")} +
+
+ onToggleSelect(resource.name)} + className="w-4 h-4 rounded border-border accent-primary cursor-pointer shrink-0" + onClick={(e) => e.stopPropagation()} + /> + {isImageFile(resource.name) + ? + : getFileIcon(resource)} + {resource.name} +
+
+ {resource.isDirectory ? "—" : formatFileSize(resource.contentLength)} + + {formatDate(resource.lastModified)} + + +
+ )} + + {/* Context menu */} + {contextMenu && ( +
e.stopPropagation()} + > + {!resources.find(r => r.name === contextMenu.name)?.isDirectory && isPreviewable(contextMenu.name) && ( + + )} + {!resources.find(r => r.name === contextMenu.name)?.isDirectory && ( + + )} + + + {clipboard && ( + + )} + {!resources.find(r => r.name === contextMenu.name)?.isDirectory && ( + + )} +
+ + + +
+ )} + + {/* Empty-area context menu */} + {emptyContextMenu && ( +
e.stopPropagation()} + > + + + + + {clipboard && ( + <> +
+ + + )} +
+ +
+ )} + + {/* Breadcrumb dropdown */} + {breadcrumbDropdown && ( +
e.stopPropagation()} + > + {breadcrumbDropdown.folders.length === 0 ? ( +

{t("no_results")}

+ ) : ( + breadcrumbDropdown.folders.map((folder) => { + const folderPath = breadcrumbDropdown.path === '/' + ? `/${folder.name}` + : `${breadcrumbDropdown.path}/${folder.name}`; + return ( + + ); + }) + )} +
+ )} + + {/* Marquee selection rectangle */} + {marquee && ( +
+ )} +
+ + {/* Details sidebar */} + {showDetails && detailResource && ( +
+
+

{t("details")}

+ +
+
+ {detailResource.isDirectory + ? + : isImageFile(detailResource.name) + ? + : } +

{detailResource.name}

+
+
+
+
{t("type")}
+
{detailResource.isDirectory ? t("folder") : (detailResource.contentType || t("file"))}
+
+ {!detailResource.isDirectory && ( +
+
{t("size")}
+
{formatFileSize(detailResource.contentLength)}
+
+ )} + {detailResource.lastModified && ( +
+
{t("modified")}
+
{formatDate(detailResource.lastModified)}
+
+ )} + {detailResource.etag && ( +
+
ETag
+
{detailResource.etag}
+
+ )} +
+
{t("path")}
+
+ {currentPath === "/" ? `/${detailResource.name}` : `${currentPath}/${detailResource.name}`} +
+
+
+
+ )} +
+ + {/* New folder dialog */} + {showNewFolder && ( + { + await onCreateFolder(name); + setShowNewFolder(false); + }} + onCancel={() => setShowNewFolder(false)} + /> + )} + + {/* New text file dialog */} + {showNewTextFile && ( + { + await onCreateTextFile(name); + setShowNewTextFile(false); + }} + onCancel={() => setShowNewTextFile(false)} + /> + )} + + {/* Rename dialog */} + {renameTarget && ( + { + await onRename(renameTarget, newName); + setRenameTarget(null); + }} + onCancel={() => setRenameTarget(null)} + /> + )} +
+ ); +} diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx new file mode 100644 index 00000000..c021804d --- /dev/null +++ b/components/files/file-preview-modal.tsx @@ -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; + 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(

{processInline(line.slice(4))}

); + } else if (line.startsWith("## ")) { + elements.push(

{processInline(line.slice(3))}

); + } else if (line.startsWith("# ")) { + elements.push(

{processInline(line.slice(2))}

); + } else if (line.startsWith("---") || line.startsWith("***")) { + elements.push(
); + } else if (line.startsWith("- ") || line.startsWith("* ")) { + elements.push(
  • {processInline(line.slice(2))}
  • ); + } else if (/^\d+\. /.test(line)) { + elements.push(
  • {processInline(line.replace(/^\d+\. /, ""))}
  • ); + } else if (line.startsWith("> ")) { + elements.push(
    {processInline(line.slice(2))}
    ); + } 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( +
    +          {codeLines.join("\n")}
    +        
    + ); + } else if (line.trim() === "") { + elements.push(
    ); + } else { + elements.push(

    {processInline(line)}

    ); + } + } + + return
    {elements}
    ; +} + +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(/(? (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({first.match[1]}); + } else if (first.type === "code") { + parts.push({first.match[1]}); + } else { + parts.push({first.match[1]}); + } + + 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(null); + const [objectUrl, setObjectUrl] = useState(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 ( +
    +
    e.stopPropagation()}> +

    {name}

    +
    + + +
    +
    + +
    e.stopPropagation()}> + {loading && ( +
    + +
    + )} + + {error && ( +

    {t("preview_error")}

    + )} + + {!loading && !error && (fileType === "text") && content !== null && ( +
    +            {content}
    +          
    + )} + + {!loading && !error && fileType === "markdown" && content !== null && ( +
    + +
    + )} + + {!loading && !error && fileType === "pdf" && objectUrl && ( +