This commit is contained in:
Linus Rath
2026-03-16 09:54:03 +01:00
57 changed files with 11190 additions and 500 deletions
+163 -18
View File
@@ -1,23 +1,99 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Globe, Plus, RefreshCw, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store";
import type { JMAPClient } from "@/lib/jmap/client";
interface CalendarSidebarPanelProps {
calendars: Calendar[];
selectedCalendarIds: string[];
onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void;
onSubscribe?: () => void;
client?: JMAPClient | null;
}
export function CalendarSidebarPanel({
calendars,
selectedCalendarIds,
onToggleVisibility,
onColorChange,
onSubscribe,
client,
}: CalendarSidebarPanelProps) {
const t = useTranslations("calendar");
const tSub = useTranslations("calendar.subscription");
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
if (calendars.length === 0) return null;
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const colorPickerRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!colorPickerId && !contextMenuCalId) return;
const handleClick = (e: MouseEvent) => {
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
setColorPickerId(null);
}
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenuCalId(null);
}
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setColorPickerId(null);
setContextMenuCalId(null);
}
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [colorPickerId, contextMenuCalId]);
const getSubscriptionForCalendar = (calendarId: string) => {
return icalSubscriptions.find(s => s.calendarId === calendarId);
};
const handleRefreshSubscription = async (subId: string) => {
if (!client) return;
setRefreshingSubId(subId);
setContextMenuCalId(null);
try {
await refreshICalSubscription(client, subId);
toast.success(tSub('refresh_success'));
} catch {
toast.error(tSub('refresh_error'));
} finally {
setRefreshingSubId(null);
}
};
const handleUnsubscribe = async (subId: string) => {
if (!client) return;
setContextMenuCalId(null);
try {
await removeICalSubscription(client, subId);
toast.success(tSub('deleted'));
} catch {
toast.error(tSub('delete_error'));
}
};
if (calendars.length === 0 && !onSubscribe) return null;
return (
<div className="mt-4">
@@ -30,25 +106,94 @@ export function CalendarSidebarPanel({
const color = cal.color || "#3b82f6";
return (
<button
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
"hover:bg-muted"
)}
>
<span
<div key={cal.id} className="relative">
<button
onClick={() => onToggleVisibility(cal.id)}
onContextMenu={(e) => {
e.preventDefault();
if (isSubscriptionCalendar(cal.id) && client) {
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
setColorPickerId(null);
} else if (onColorChange) {
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
setContextMenuCalId(null);
}
}}
className={cn(
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
"hover:bg-muted"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
</button>
>
<span
className={cn(
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
{isSubscriptionCalendar(cal.id) && (
<>
<Globe className="w-3 h-3 text-muted-foreground flex-shrink-0" />
{refreshingSubId === getSubscriptionForCalendar(cal.id)?.id && (
<RefreshCw className="w-3 h-3 text-muted-foreground flex-shrink-0 animate-spin" />
)}
</>
)}
</button>
{/* Subscription context menu on right-click */}
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
const sub = getSubscriptionForCalendar(cal.id);
if (!sub) return null;
return (
<div
ref={contextMenuRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
>
<button
onClick={() => handleRefreshSubscription(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
{tSub('refresh')}
</button>
<button
onClick={() => handleUnsubscribe(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
{tSub('unsubscribe')}
</button>
{sub.lastRefreshed && (
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
{tSub('last_refreshed', { time: new Date(sub.lastRefreshed).toLocaleString() })}
</div>
)}
</div>
);
})()}
{/* Color picker popover on right-click */}
{colorPickerId === cal.id && onColorChange && (
<div
ref={colorPickerRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
>
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
<CalendarColorPicker
value={color}
onChange={(c) => {
onColorChange(cal.id, c);
setColorPickerId(null);
}}
allowCustom
/>
</div>
)}
</div>
);
})}
</div>
+46 -6
View File
@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown } from "lucide-react";
import { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
@@ -18,6 +18,7 @@ interface CalendarToolbarProps {
onViewModeChange: (mode: CalendarViewMode) => void;
onCreateEvent: () => void;
onImport?: () => void;
onSubscribe?: () => void;
isMobile?: boolean;
firstDayOfWeek?: number;
onNavigateBack?: () => void;
@@ -35,6 +36,7 @@ export function CalendarToolbar({
onViewModeChange,
onCreateEvent,
onImport,
onSubscribe,
isMobile,
firstDayOfWeek = 1,
calendars,
@@ -87,9 +89,22 @@ export function CalendarToolbar({
}
};
const [showImportDropdown, setShowImportDropdown] = useState(false);
const importDropdownRef = useRef<HTMLDivElement>(null);
const [showViewDropdown, setShowViewDropdown] = useState(false);
const viewDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showImportDropdown) return;
function handleClickOutside(e: MouseEvent) {
if (importDropdownRef.current && !importDropdownRef.current.contains(e.target as Node)) {
setShowImportDropdown(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showImportDropdown]);
useEffect(() => {
if (!showViewDropdown) return;
function handleClickOutside(e: MouseEvent) {
@@ -219,11 +234,36 @@ export function CalendarToolbar({
</div>
)}
{onImport && !isMobile && (
<Button variant="outline" size="sm" onClick={onImport}>
<Upload className="w-4 h-4 mr-1" />
{t("import.title")}
</Button>
{(onImport || onSubscribe) && !isMobile && (
<div className="relative" ref={importDropdownRef}>
<Button variant="outline" size="sm" onClick={() => setShowImportDropdown((v) => !v)}>
<Upload className="w-4 h-4 mr-1" />
{t("import.title")}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
{showImportDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
{onImport && (
<button
onClick={() => { onImport(); setShowImportDropdown(false); }}
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors text-foreground"
>
<Upload className="w-4 h-4" />
{t("import.title")}
</button>
)}
{onSubscribe && (
<button
onClick={() => { onSubscribe(); setShowImportDropdown(false); }}
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors text-foreground"
>
<Globe className="w-4 h-4" />
{t("subscription.title")}
</button>
)}
</div>
)}
</div>
)}
{!isMobile && (
+2 -1
View File
@@ -30,7 +30,8 @@ function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
return sanitizeColor(event.color, sanitizeColor(calendar?.color));
}
function parseDuration(duration: string): number {
function parseDuration(duration: string | undefined): number {
if (!duration) return 0;
let totalMinutes = 0;
const weekMatch = duration.match(/(\d+)W/);
const hourMatch = duration.match(/(\d+)H/);
+135 -24
View File
@@ -3,7 +3,7 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Upload, Check, Loader2, RefreshCw } from "lucide-react";
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
import { format, parseISO } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { JMAPClient } from "@/lib/jmap/client";
@@ -20,6 +20,7 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
type ImportStep = "select" | "preview" | "importing";
type ImportMode = "file" | "url";
export function ICalImportModal({ calendars, client, onClose }: ICalImportModalProps) {
const t = useTranslations("calendar.import");
@@ -38,6 +39,9 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
const [isParsing, setIsParsing] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const [importMode, setImportMode] = useState<ImportMode>("file");
const [urlInput, setUrlInput] = useState("");
const [isFetchingUrl, setIsFetchingUrl] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
@@ -103,6 +107,57 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
if (file) handleFile(file);
}, [handleFile]);
const handleUrlFetch = useCallback(async () => {
const trimmed = urlInput.trim();
if (!trimmed) return;
try {
new URL(trimmed);
} catch {
setError(t("invalid_url"));
return;
}
setError(null);
setIsFetchingUrl(true);
setIsParsing(true);
try {
const response = await fetch("/api/fetch-ical", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: trimmed }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || t("url_fetch_failed"));
}
const blob = await response.blob();
const file = new File([blob], "calendar.ics", { type: "text/calendar" });
const uploaded = await client.uploadBlob(file);
const accountId = client.getCalendarsAccountId();
const events = await client.parseCalendarEvents(accountId, uploaded.blobId);
if (events.length === 0) {
setError(t("no_events"));
setIsFetchingUrl(false);
setIsParsing(false);
return;
}
setParsedEvents(events);
setSelectedIndices(new Set(events.map((_, i) => i)));
setStep("preview");
} catch (err) {
setError(err instanceof Error ? err.message : t("url_fetch_failed"));
} finally {
setIsFetchingUrl(false);
setIsParsing(false);
}
}, [urlInput, client, t]);
const toggleEvent = useCallback((index: number) => {
setSelectedIndices((prev) => {
const next = new Set(prev);
@@ -202,29 +257,85 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
<div className="px-6 py-4 space-y-4">
{step === "select" && !isParsing && (
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors ${
isDragging
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-muted/50"
}`}
>
<Upload className="w-8 h-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium">{t("select_file")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("drop_file")}</p>
<p className="text-xs text-muted-foreground mt-2">{t("supported_formats")}</p>
<input
ref={fileInputRef}
type="file"
accept=".ics,.ical"
onChange={handleFileChange}
className="hidden"
/>
</div>
<>
<div className="flex border-b border-border mb-4">
<button
onClick={() => { setImportMode("file"); setError(null); }}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
importMode === "file"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Upload className="w-4 h-4" />
{t("tab_file")}
</button>
<button
onClick={() => { setImportMode("url"); setError(null); }}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
importMode === "url"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Globe className="w-4 h-4" />
{t("tab_url")}
</button>
</div>
{importMode === "file" && (
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors ${
isDragging
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-muted/50"
}`}
>
<Upload className="w-8 h-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium">{t("select_file")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("drop_file")}</p>
<p className="text-xs text-muted-foreground mt-2">{t("supported_formats")}</p>
<input
ref={fileInputRef}
type="file"
accept=".ics,.ical"
onChange={handleFileChange}
className="hidden"
/>
</div>
)}
{importMode === "url" && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">{t("url_description")}</p>
<div className="flex gap-2">
<input
type="url"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
placeholder={t("url_placeholder")}
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown={(e) => { if (e.key === "Enter") handleUrlFetch(); }}
/>
<Button
onClick={handleUrlFetch}
disabled={!urlInput.trim() || isFetchingUrl}
>
{isFetchingUrl ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
t("fetch")
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("url_hint")}</p>
</div>
)}
</>
)}
{isParsing && (
@@ -0,0 +1,205 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, Globe } from "lucide-react";
import type { JMAPClient } from "@/lib/jmap/client";
import { useCalendarStore } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { toast } from "@/stores/toast-store";
interface ICalSubscriptionModalProps {
client: JMAPClient;
onClose: () => void;
}
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) {
const t = useTranslations("calendar.subscription");
const tCommon = useTranslations("common");
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
const [url, setUrl] = useState("");
const [name, setName] = useState("");
const [color, setColor] = useState("#3b82f6");
const [refreshInterval, setRefreshInterval] = useState(60);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
const isValid = url.trim().length > 0 && name.trim().length > 0;
const handleSubmit = useCallback(async () => {
let trimmedUrl = url.trim();
if (!trimmedUrl || !name.trim()) return;
// Convert webcal:// to https://
if (trimmedUrl.startsWith("webcal://")) {
trimmedUrl = trimmedUrl.replace(/^webcal:\/\//, "https://");
}
try {
new URL(trimmedUrl);
} catch {
setError(t("invalid_url"));
return;
}
setError(null);
setIsSubmitting(true);
try {
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
if (subscription) {
toast.success(t("success", { name: name.trim() }));
onClose();
} else {
setError(t("error"));
}
} catch {
setError(t("error"));
} finally {
setIsSubmitting(false);
}
}, [url, name, color, refreshInterval, client, addICalSubscription, onClose, t]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
useEffect(() => {
const modal = modalRef.current;
if (!modal) return;
const focusableEls = modal.querySelectorAll<HTMLElement>(
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
);
const firstEl = focusableEls[0];
const lastEl = focusableEls[focusableEls.length - 1];
const handler = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl?.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl?.focus();
}
};
modal.addEventListener("keydown", handler);
firstEl?.focus();
return () => modal.removeEventListener("keydown", handler);
}, []);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("title")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("title")}</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4">
<p className="text-sm text-muted-foreground">{t("description")}</p>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("url_label")}
</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t("url_placeholder")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmitting}
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name_label")}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("name_placeholder")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmitting}
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("color_label")}
</label>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("refresh_interval")}
</label>
<select
value={refreshInterval}
onChange={(e) => setRefreshInterval(Number(e.target.value))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmitting}
>
<option value={15}>{t("interval_15")}</option>
<option value={30}>{t("interval_30")}</option>
<option value={60}>{t("interval_60")}</option>
<option value={360}>{t("interval_360")}</option>
<option value={1440}>{t("interval_1440")}</option>
</select>
</div>
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-md px-3 py-2">
{error}
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
{tCommon("cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{t("subscribing")}
</>
) : (
t("subscribe")
)}
</Button>
</div>
</div>
</div>
);
}
+18 -5
View File
@@ -22,7 +22,20 @@ function formatPhoneFeatures(features?: Record<string, boolean>): string {
return Object.keys(features).filter(k => features[k]).join(", ");
}
function formatDate(dateStr: string): string {
function formatDate(dateInput: string | Record<string, unknown>): string {
// Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? }
if (typeof dateInput === 'object' && dateInput !== null) {
const year = dateInput.year as number | undefined;
const month = dateInput.month as number | undefined;
const day = dateInput.day as number | undefined;
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const parts: string[] = [];
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]);
if (day) parts.push(String(day));
if (year) parts.push(String(year));
return parts.join(' ') || String(dateInput);
}
const dateStr = String(dateInput);
// Handle both ISO dates and partial dates like 1990-01-15 or --01-15
if (dateStr.startsWith("--")) {
// Partial date without year
@@ -216,12 +229,12 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
<Section icon={Globe} title={t("detail.online_services")} category="digital">
{onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2 group">
{svc.uri.startsWith("http") ? (
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{svc.user || svc.uri}
</a>
) : (
<span className="text-sm break-all">{svc.user || svc.uri}</span>
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
)}
{svc.service && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{svc.service}</span>
@@ -320,12 +333,12 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
<Section icon={KeyRound} title={t("detail.crypto_keys")} category="digital">
{cryptoKeys.map((key, i) => (
<div key={i} className="text-sm break-all">
{key.uri.startsWith("http") ? (
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{key.uri}
</a>
) : (
<span className="text-muted-foreground">{key.uri.substring(0, 80)}{key.uri.length > 80 ? "…" : ""}</span>
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
)}
</div>
))}
+1 -9
View File
@@ -2,7 +2,7 @@
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Search, Plus, BookUser, Info, Check, Trash2, Users, Download, X, UserPlus, Upload } from "lucide-react";
import { Search, Plus, BookUser, Info, Check, Trash2, Users, Download, X, UserPlus } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
@@ -17,7 +17,6 @@ interface ContactListProps {
onSearchChange: (query: string) => void;
onSelectContact: (id: string) => void;
onCreateNew: () => void;
onImport?: () => void;
supportsSync: boolean;
className?: string;
selectedContactIds: Set<string>;
@@ -36,7 +35,6 @@ export function ContactList({
onSearchChange,
onSelectContact,
onCreateNew,
onImport,
supportsSync,
className,
selectedContactIds,
@@ -185,12 +183,6 @@ export function ContactList({
<UserPlus className="w-4 h-4 mr-1.5" />
{t("create_new")}
</Button>
{onImport && (
<Button variant="outline" size="sm" onClick={onImport}>
<Upload className="w-4 h-4 mr-1.5" />
{t("import_vcard")}
</Button>
)}
</div>
</>
)}
+152 -18
View File
@@ -9,7 +9,9 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma
import { cn, formatFileSize } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useContactStore } from "@/stores/contact-store";
import { useTemplateStore } from "@/stores/template-store";
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
@@ -41,6 +43,7 @@ interface EmailComposerProps {
bcc: string[];
subject: string;
body: string;
htmlBody?: string;
draftId?: string;
fromEmail?: string;
fromName?: string;
@@ -59,6 +62,7 @@ interface EmailComposerProps {
cc?: { email?: string; name?: string }[];
subject?: string;
body?: string;
htmlBody?: string;
receivedAt?: string;
};
}
@@ -112,16 +116,22 @@ export function EmailComposer({
const getInitialBody = () => {
const prefix = initialDraftText || "";
if (!replyTo?.body) return prefix;
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : "";
const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
// When HTML body is available, don't include quoted text in the textarea
// The HTML original will be shown separately below the textarea
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
return prefix;
}
if (mode === 'forward') {
return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`;
} else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n> ${replyTo.body.split('\n').join('\n> ')}`;
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n> ${(replyTo.body || '').split('\n').join('\n> ')}`;
}
return prefix;
};
@@ -137,6 +147,18 @@ export function EmailComposer({
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const autoResizeTextarea = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
}, []);
useEffect(() => {
autoResizeTextarea();
}, [body, autoResizeTextarea]);
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
@@ -146,6 +168,7 @@ export function EmailComposer({
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const [showCloseDialog, setShowCloseDialog] = useState(false);
const [showAllAttachments, setShowAllAttachments] = useState(false);
const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate,
@@ -159,7 +182,9 @@ export function EmailComposer({
restoreFocus: true,
});
const { client, identities, primaryIdentity } = useAuthStore();
const { client } = useAuthStore();
const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null;
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
const addTemplate = useTemplateStore((s) => s.addTemplate);
@@ -330,13 +355,9 @@ export function EmailComposer({
return () => window.removeEventListener('keydown', handleTemplateKey);
}, []);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!client || !event.target.files) return;
const addFiles = useCallback(async (files: File[]) => {
if (!client || files.length === 0) return;
const files = Array.from(event.target.files);
// AbortController tracks cancellation state but uploadBlob doesn't accept a signal,
// so abort only prevents post-upload state updates (cosmetic cancellation)
const newAttachments = files.map(file => {
const controller = new AbortController();
return { file, uploading: true, abortController: controller };
@@ -372,12 +393,60 @@ export function EmailComposer({
);
}
}
}, [client, t]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return;
await addFiles(Array.from(event.target.files));
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const [isDraggingOver, setIsDraggingOver] = useState(false);
const dragTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const clearDragState = useCallback(() => {
if (dragTimeoutRef.current) clearTimeout(dragTimeoutRef.current);
dragTimeoutRef.current = null;
setIsDraggingOver(false);
}, []);
const resetDragTimeout = useCallback(() => {
if (dragTimeoutRef.current) clearTimeout(dragTimeoutRef.current);
dragTimeoutRef.current = setTimeout(clearDragState, 150);
}, [clearDragState]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes('Files')) {
setIsDraggingOver(true);
resetDragTimeout();
}
}, [resetDragTimeout]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
resetDragTimeout();
}, [resetDragTimeout]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
resetDragTimeout();
}, [resetDragTimeout]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
clearDragState();
if (e.dataTransfer.files?.length) {
addFiles(Array.from(e.dataTransfer.files));
}
}, [addFiles, clearDragState]);
const removeAttachment = (index: number) => {
const att = attachments[index];
att?.abortController?.abort();
@@ -549,13 +618,37 @@ export function EmailComposer({
: currentIdentity.email
: undefined;
// Append signature from the selected identity
let finalBody = body;
if (currentIdentity?.textSignature) {
finalBody = body + '\n\n-- \n' + currentIdentity.textSignature;
}
// Build HTML body when replying/forwarding with original HTML content
let finalHtmlBody: string | undefined;
if (replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
const escapedBody = body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
const signatureHtml = currentIdentity?.textSignature
? `<br><br>-- <br>${currentIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`
: '';
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : '';
const fromAddr = replyTo.from?.[0];
const fromStr = fromAddr ? `${fromAddr.name || fromAddr.email}` : tCommon('unknown');
const quoteHeader = mode === 'forward'
? `---------- ${t('prefix.forward')} ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
: `On ${date}, ${fromStr} wrote:<br>`;
finalHtmlBody = `<div>${escapedBody}</div>${signatureHtml}<br><div><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote></div>`;
}
try {
await onSend?.({
to: toAddresses,
cc: ccAddresses,
bcc: bccAddresses,
subject,
body,
body: finalBody,
htmlBody: finalHtmlBody,
draftId: finalDraftId || undefined,
fromEmail,
fromName: currentIdentity?.name || undefined,
@@ -617,7 +710,22 @@ export function EmailComposer({
};
return (
<div className={cn("flex flex-col h-full bg-background", className)}>
<div
className={cn("flex flex-col h-full bg-background relative", className)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Drag overlay */}
{isDraggingOver && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 border-2 border-dashed border-primary rounded-lg pointer-events-none">
<div className="flex flex-col items-center gap-2 text-primary">
<Paperclip className="w-8 h-8" />
<span className="text-sm font-medium">{t('drop_files')}</span>
</div>
</div>
)}
{/* Header - mobile: clean bar with close/send, desktop: title bar */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-background">
<div className="flex items-center gap-3">
@@ -659,7 +767,7 @@ export function EmailComposer({
</Button>
</div>
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 min-h-0 overflow-auto">
{/* Fields section */}
<div className="space-y-0 border-b">
{/* From field */}
@@ -825,10 +933,11 @@ export function EmailComposer({
</div>
{/* Body */}
<div className="flex-1 px-4 py-3 min-h-0">
<div className="px-4 py-3">
<textarea
ref={textareaRef}
className={cn(
"w-full h-full resize-none outline-none text-sm bg-transparent text-foreground placeholder:text-muted-foreground rounded",
"w-full resize-none outline-none text-sm bg-transparent text-foreground placeholder:text-muted-foreground rounded min-h-[100px] overflow-hidden",
validationErrors.body && "ring-2 ring-red-500 dark:ring-red-400"
)}
placeholder={t('body_placeholder')}
@@ -841,11 +950,29 @@ export function EmailComposer({
/>
</div>
{/* Quoted original HTML */}
{replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward') && (
<div className="border-t border-border">
<div className="px-4 py-2 text-xs text-muted-foreground">
{mode === 'forward'
? `---------- ${t('prefix.forward')} ----------`
: `${replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : ''}, ${replyTo.from?.[0]?.name || replyTo.from?.[0]?.email || tCommon('unknown')}:`
}
</div>
<div
className="email-reply-quote px-4 pb-3 border-l-2 border-muted-foreground/30 ml-4 max-w-none rounded"
style={{ backgroundColor: '#ffffff', color: '#1a1a1a', fontSize: '14px' }}
dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(replyTo.htmlBody) }}
/>
</div>
)}
</div>
{/* Attachments */}
{attachments.length > 0 && (
<div className="px-4 py-2 border-t">
<div className="px-4 py-2 border-t shrink-0">
<div className="flex flex-wrap gap-2">
{attachments.map((att, index) => (
{(showAllAttachments ? attachments : attachments.slice(0, 3)).map((att, index) => (
<div
key={index}
className={cn(
@@ -881,12 +1008,20 @@ export function EmailComposer({
</div>
</div>
))}
{attachments.length > 3 && (
<button
onClick={() => setShowAllAttachments(prev => !prev)}
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-sm bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
{showAllAttachments ? t('show_less') : `+${attachments.length - 3}`}
</button>
)}
</div>
</div>
)}
{/* Bottom toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background">
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0">
{/* Left side actions */}
<div className="flex items-center gap-1">
<input
@@ -946,7 +1081,6 @@ export function EmailComposer({
</Button>
</div>
</div>
</div>
{showTemplatePicker && (
<TemplatePicker
+47 -2
View File
@@ -4,7 +4,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
import { ThreadListItem } from "./thread-list-item";
import { EmailContextMenu } from "./email-context-menu";
import { cn } from "@/lib/utils";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX } from "lucide-react";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle } from "lucide-react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
@@ -74,6 +74,7 @@ export function EmailList({
isLoadingMore,
mailboxes,
selectedMailbox,
emptyMailbox,
expandedThreadIds,
threadEmailsCache,
isLoadingThread,
@@ -172,6 +173,28 @@ export function EmailList({
}
};
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isEmptyableFolder = currentMailbox?.role === 'trash' || currentMailbox?.role === 'junk';
const handleEmptyFolder = async () => {
if (!client || isProcessing || !currentMailbox) return;
const confirmed = await confirmDialog({
title: t('empty_folder.confirm_title'),
message: t('empty_folder.confirm_message'),
confirmText: t('empty_folder.confirm_button'),
variant: "destructive",
});
if (!confirmed) return;
setIsProcessing(true);
try {
await emptyMailbox(client, currentMailbox.id);
} finally {
setTimeout(() => setIsProcessing(false), 500);
}
};
const handleLoadMore = useCallback(() => {
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
loadMoreEmails(client);
@@ -308,7 +331,29 @@ export function EmailList({
/>
)}
{/* Empty Folder Banner for Junk/Trash */}
{isEmptyableFolder && emails.length > 0 && !hasSelection && (
<div className="px-4 py-2 border-b border-border bg-muted/30 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertTriangle className="w-4 h-4" />
<span>{currentMailbox?.role === 'junk' ? t('empty_folder.junk_hint') : t('empty_folder.trash_hint')}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleEmptyFolder}
disabled={isProcessing}
className="text-destructive border-destructive/30 hover:bg-destructive/10 text-xs"
>
{isProcessing ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Trash2 className="w-3 h-3 mr-1" />
)}
{t('empty_folder.button')}
</Button>
</div>
)}
{/* Email List */}
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative">
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+232
View File
@@ -0,0 +1,232 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { X, Download, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
interface FilePreviewModalProps {
name: string;
onClose: () => void;
onDownload: (name: string) => Promise<void>;
getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>;
}
const TEXT_EXTENSIONS = new Set([
"txt", "md", "markdown", "json", "xml", "html", "htm", "css", "js", "ts",
"jsx", "tsx", "py", "rb", "java", "c", "cpp", "h", "hpp", "go", "rs",
"sh", "bash", "zsh", "yaml", "yml", "toml", "ini", "cfg", "conf", "env",
"log", "csv", "sql", "graphql", "vue", "svelte", "astro", "php", "pl",
"swift", "kt", "scala", "r", "lua", "vim",
]);
function getFileType(name: string): "text" | "pdf" | "audio" | "video" | "markdown" | "unknown" {
const ext = name.split(".").pop()?.toLowerCase() || "";
const baseName = name.toLowerCase();
if (ext === "md" || ext === "markdown") return "markdown";
if (ext === "pdf") return "pdf";
if (["mp3", "wav", "ogg", "flac", "aac", "m4a", "wma", "opus"].includes(ext)) return "audio";
if (["mp4", "webm", "ogv", "mov", "avi", "mkv", "m4v"].includes(ext)) return "video";
if (TEXT_EXTENSIONS.has(ext) || ["dockerfile", "makefile", "readme", "license", "changelog"].includes(baseName)) return "text";
return "unknown";
}
function SimpleMarkdown({ content }: { content: string }) {
const lines = content.split("\n");
const elements: React.ReactNode[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Headers
if (line.startsWith("### ")) {
elements.push(<h3 key={i} className="text-lg font-semibold mt-4 mb-2">{processInline(line.slice(4))}</h3>);
} else if (line.startsWith("## ")) {
elements.push(<h2 key={i} className="text-xl font-semibold mt-5 mb-2">{processInline(line.slice(3))}</h2>);
} else if (line.startsWith("# ")) {
elements.push(<h1 key={i} className="text-2xl font-bold mt-6 mb-3">{processInline(line.slice(2))}</h1>);
} else if (line.startsWith("---") || line.startsWith("***")) {
elements.push(<hr key={i} className="my-4 border-border" />);
} else if (line.startsWith("- ") || line.startsWith("* ")) {
elements.push(<li key={i} className="ml-4 list-disc">{processInline(line.slice(2))}</li>);
} else if (/^\d+\. /.test(line)) {
elements.push(<li key={i} className="ml-4 list-decimal">{processInline(line.replace(/^\d+\. /, ""))}</li>);
} else if (line.startsWith("> ")) {
elements.push(<blockquote key={i} className="border-l-4 border-border pl-4 italic text-muted-foreground my-2">{processInline(line.slice(2))}</blockquote>);
} else if (line.startsWith("```")) {
// Code block - collect until closing ```
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
elements.push(
<pre key={i} className="bg-muted rounded p-3 my-2 overflow-x-auto text-sm font-mono">
<code>{codeLines.join("\n")}</code>
</pre>
);
} else if (line.trim() === "") {
elements.push(<div key={i} className="h-2" />);
} else {
elements.push(<p key={i} className="my-1">{processInline(line)}</p>);
}
}
return <div className="prose prose-sm dark:prose-invert max-w-none">{elements}</div>;
}
function processInline(text: string): React.ReactNode {
// Process bold, italic, code inline
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
while (remaining.length > 0) {
// Bold
const boldMatch = remaining.match(/\*\*(.+?)\*\*/);
// Inline code
const codeMatch = remaining.match(/`([^`]+)`/);
// Italic
const italicMatch = remaining.match(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/);
const matches = [
boldMatch && { type: "bold", match: boldMatch },
codeMatch && { type: "code", match: codeMatch },
italicMatch && { type: "italic", match: italicMatch },
].filter(Boolean).sort((a, b) => (a!.match.index ?? 0) - (b!.match.index ?? 0));
if (matches.length === 0) {
parts.push(remaining);
break;
}
const first = matches[0]!;
const idx = first.match.index ?? 0;
if (idx > 0) {
parts.push(remaining.slice(0, idx));
}
if (first.type === "bold") {
parts.push(<strong key={key++}>{first.match[1]}</strong>);
} else if (first.type === "code") {
parts.push(<code key={key++} className="bg-muted px-1 py-0.5 rounded text-sm font-mono">{first.match[1]}</code>);
} else {
parts.push(<em key={key++}>{first.match[1]}</em>);
}
remaining = remaining.slice(idx + first.match[0].length);
}
return parts.length === 1 ? parts[0] : <>{parts}</>;
}
export function FilePreviewModal({ name, onClose, onDownload, getFileContent }: FilePreviewModalProps) {
const t = useTranslations("files");
const [content, setContent] = useState<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const fileType = getFileType(name);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const { blob, contentType } = await getFileContent(name);
if (cancelled) return;
if (fileType === "text" || fileType === "markdown") {
const text = await blob.text();
if (!cancelled) setContent(text);
} else {
const url = URL.createObjectURL(blob);
if (!cancelled) setObjectUrl(url);
}
} catch {
if (!cancelled) setError(true);
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [name]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return (
<div role="dialog" aria-label={name} className="fixed inset-0 z-50 flex flex-col bg-black/80" onClick={onClose}>
<div className="flex items-center justify-between px-4 py-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-medium truncate">{name}</h3>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onDownload(name)}>
<Download className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
</div>
<div className="flex-1 flex items-center justify-center overflow-auto p-4" onClick={(e) => e.stopPropagation()}>
{loading && (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
)}
{error && (
<p className="text-sm text-destructive">{t("preview_error")}</p>
)}
{!loading && !error && (fileType === "text") && content !== null && (
<pre className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm font-mono whitespace-pre-wrap break-words">
{content}
</pre>
)}
{!loading && !error && fileType === "markdown" && content !== null && (
<div className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm">
<SimpleMarkdown content={content} />
</div>
)}
{!loading && !error && fileType === "pdf" && objectUrl && (
<iframe
src={objectUrl}
className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name}
/>
)}
{!loading && !error && fileType === "audio" && objectUrl && (
<div className="bg-background rounded-lg p-8 max-w-lg w-full">
<p className="text-sm font-medium mb-4 text-center">{name}</p>
<audio controls className="w-full" src={objectUrl} />
</div>
)}
{!loading && !error && fileType === "video" && objectUrl && (
<video controls className="max-w-4xl max-h-full rounded-lg" src={objectUrl} />
)}
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import { Upload, FolderPlus, FilePlus } from "lucide-react";
import { Button } from "@/components/ui/button";
interface FileUploadAreaProps {
onUpload: (files: File[]) => Promise<void>;
onCreateFolder: () => void;
onCreateTextFile?: () => void;
}
export function FileUploadArea({ onUpload, onCreateFolder, onCreateTextFile }: FileUploadAreaProps) {
const t = useTranslations("files");
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
await onUpload(files);
}
}, [onUpload]);
return (
<div className="flex items-center justify-center h-full p-8">
<div
className={`flex flex-col items-center gap-4 p-12 rounded-xl border-2 border-dashed transition-colors max-w-md w-full ${
isDragging ? "border-primary bg-primary/5" : "border-border"
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center">
<Upload className="w-8 h-8 text-muted-foreground" />
</div>
<div className="text-center">
<h3 className="text-base font-medium">{t("empty_state_title")}</h3>
<p className="text-sm text-muted-foreground mt-1">{t("empty_state_description")}</p>
<p className="text-xs text-muted-foreground mt-2">{t("drop_files_here")}</p>
</div>
<div className="flex gap-2 flex-wrap justify-center">
<Button
variant="outline"
size="sm"
onClick={onCreateFolder}
>
<FolderPlus className="w-4 h-4 mr-2" />
{t("new_folder")}
</Button>
{onCreateTextFile && (
<Button
variant="outline"
size="sm"
onClick={onCreateTextFile}
>
<FilePlus className="w-4 h-4 mr-2" />
{t("new_text_file")}
</Button>
)}
</div>
</div>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "@/components/settings/settings-section";
export type FolderLayout = "inline" | "sidebar";
export interface FilesSettings {
defaultViewMode: "list" | "grid";
showIcons: boolean;
coloredIcons: boolean;
defaultSortKey: "name" | "size" | "modified";
defaultSortDir: "asc" | "desc";
showHiddenFiles: boolean;
showThumbnails: boolean;
folderLayout: FolderLayout;
}
export const DEFAULT_FILES_SETTINGS: FilesSettings = {
defaultViewMode: "list",
showIcons: true,
coloredIcons: true,
defaultSortKey: "name",
defaultSortDir: "asc",
showHiddenFiles: false,
showThumbnails: true,
folderLayout: "inline",
};
export function loadFilesSettings(): FilesSettings {
if (typeof window === "undefined") return DEFAULT_FILES_SETTINGS;
try {
const raw = localStorage.getItem("files-settings");
if (raw) return { ...DEFAULT_FILES_SETTINGS, ...JSON.parse(raw) };
} catch { /* ignore */ }
return DEFAULT_FILES_SETTINGS;
}
export function saveFilesSettings(settings: FilesSettings) {
localStorage.setItem("files-settings", JSON.stringify(settings));
// Dispatch custom event for same-tab listeners (StorageEvent only fires cross-tab)
window.dispatchEvent(new CustomEvent("files-settings-changed"));
}
interface FilesSettingsDialogProps {
isOpen: boolean;
onClose: () => void;
settings: FilesSettings;
onSettingsChange: (settings: FilesSettings) => void;
}
export function FilesSettingsDialog({ isOpen, onClose, settings, onSettingsChange }: FilesSettingsDialogProps) {
const t = useTranslations("files");
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
if (isOpen) window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
if (isOpen) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen, onClose]);
if (!isOpen) return null;
const update = (patch: Partial<FilesSettings>) => {
const next = { ...settings, ...patch };
onSettingsChange(next);
saveFilesSettings(next);
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("settings_title")}
className="bg-background border border-border rounded-lg shadow-lg w-full max-w-md mx-4 max-h-[80vh] flex flex-col"
>
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold">{t("settings_title")}</h2>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
<div className="overflow-y-auto p-4 space-y-6">
<SettingsSection title={t("settings_display")}>
<SettingItem label={t("settings_folder_layout")} description={t("settings_folder_layout_desc")}>
<RadioGroup
value={settings.folderLayout}
onChange={(v) => update({ folderLayout: v as FolderLayout })}
options={[
{ value: "inline", label: t("settings_folder_layout_inline") },
{ value: "sidebar", label: t("settings_folder_layout_sidebar") },
]}
/>
</SettingItem>
<SettingItem label={t("settings_default_view")} description={t("settings_default_view_desc")}>
<RadioGroup
value={settings.defaultViewMode}
onChange={(v) => update({ defaultViewMode: v as "list" | "grid" })}
options={[
{ value: "list", label: t("list_view") },
{ value: "grid", label: t("grid_view") },
]}
/>
</SettingItem>
<SettingItem label={t("settings_default_sort")} description={t("settings_default_sort_desc")}>
<RadioGroup
value={settings.defaultSortKey}
onChange={(v) => update({ defaultSortKey: v as "name" | "size" | "modified" })}
options={[
{ value: "name", label: t("name") },
{ value: "size", label: t("size") },
{ value: "modified", label: t("modified") },
]}
/>
</SettingItem>
<SettingItem label={t("settings_sort_direction")} description={t("settings_sort_direction_desc")}>
<RadioGroup
value={settings.defaultSortDir}
onChange={(v) => update({ defaultSortDir: v as "asc" | "desc" })}
options={[
{ value: "asc", label: t("settings_ascending") },
{ value: "desc", label: t("settings_descending") },
]}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t("settings_icons")}>
<SettingItem label={t("settings_show_icons")} description={t("settings_show_icons_desc")}>
<ToggleSwitch
checked={settings.showIcons}
onChange={(v) => update({ showIcons: v })}
/>
</SettingItem>
<SettingItem label={t("settings_colored_icons")} description={t("settings_colored_icons_desc")}>
<ToggleSwitch
checked={settings.coloredIcons}
onChange={(v) => update({ coloredIcons: v })}
disabled={!settings.showIcons}
/>
</SettingItem>
<SettingItem label={t("settings_show_thumbnails")} description={t("settings_show_thumbnails_desc")}>
<ToggleSwitch
checked={settings.showThumbnails}
onChange={(v) => update({ showThumbnails: v })}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t("settings_behavior")}>
<SettingItem label={t("settings_show_hidden")} description={t("settings_show_hidden_desc")}>
<ToggleSwitch
checked={settings.showHiddenFiles}
onChange={(v) => update({ showHiddenFiles: v })}
/>
</SettingItem>
</SettingsSection>
</div>
</div>
</div>
);
}
+297
View File
@@ -0,0 +1,297 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import {
Folder,
FolderOpen,
ChevronRight,
ChevronDown,
Home,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useFileStore, type FileResource } from "@/stores/file-store";
interface FolderNode {
id: string;
name: string;
path: string;
}
interface FolderTreeSidebarProps {
currentPath: string;
onNavigate: (path: string, resourceId?: string | null) => void;
listByParentId: (parentId: string | null) => Promise<FileResource[]>;
width?: number;
isResizing?: boolean;
}
export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 256, isResizing }: FolderTreeSidebarProps) {
const t = useTranslations("files");
const client = useFileStore(s => s.client);
const [rootChildren, setRootChildren] = useState<FolderNode[] | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(["root"]));
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
// Cache: parentId (or "root") -> FolderNode[]
const [childrenCache, setChildrenCache] = useState<Map<string, FolderNode[]>>(new Map());
// Map folder path -> id for reverse lookup
const pathToIdRef = useRef<Map<string, string>>(new Map());
const loadChildren = useCallback(async (parentId: string | null, parentPath: string) => {
const cacheKey = parentId ?? "root";
// Skip if already loading or cached
if (childrenCache.has(cacheKey)) return;
setLoadingIds(prev => new Set(prev).add(cacheKey));
try {
const resources = await listByParentId(parentId);
const folders = resources
.filter(r => r.isDirectory)
.sort((a, b) => a.name.localeCompare(b.name))
.map(r => {
const folderPath = parentPath === "/" ? `/${r.name}` : `${parentPath}/${r.name}`;
pathToIdRef.current.set(folderPath, r.id);
return {
id: r.id,
name: r.name,
path: folderPath,
};
});
// Don't cache empty root results — empty root likely means client wasn't ready yet
if (folders.length > 0 || parentId !== null) {
setChildrenCache(prev => new Map(prev).set(cacheKey, folders));
}
if (parentId === null) {
setRootChildren(folders);
}
} catch {
// Silently fail
} finally {
setLoadingIds(prev => {
const next = new Set(prev);
next.delete(cacheKey);
return next;
});
}
}, [childrenCache, listByParentId]);
// Load root folders when client is available (handles page refresh timing)
useEffect(() => {
if (client) {
loadChildren(null, "/");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client]);
// Auto-expand along the current path when navigating
useEffect(() => {
if (currentPath === "/") return;
const segments = currentPath.split("/").filter(Boolean);
// Walk down the path and expand + load each ancestor
let ancestorPath = "";
for (let i = 0; i < segments.length; i++) {
ancestorPath = "/" + segments.slice(0, i + 1).join("/");
const folderId = pathToIdRef.current.get(ancestorPath);
if (folderId) {
setExpandedIds(prev => {
if (prev.has(folderId)) return prev;
return new Set(prev).add(folderId);
});
if (!childrenCache.has(folderId)) {
loadChildren(folderId, ancestorPath);
}
}
}
}, [currentPath, childrenCache, loadChildren]);
const handleToggleExpand = useCallback(async (folderId: string, folderPath: string) => {
setExpandedIds(prev => {
const next = new Set(prev);
if (next.has(folderId)) {
next.delete(folderId);
} else {
next.add(folderId);
}
return next;
});
if (!childrenCache.has(folderId)) {
await loadChildren(folderId, folderPath);
}
}, [childrenCache, loadChildren]);
const handleFolderClick = useCallback((path: string, id: string | null) => {
onNavigate(path, id);
}, [onNavigate]);
return (
<div
className={cn(
"border-r border-border bg-secondary overflow-hidden shrink-0 hidden lg:flex flex-col",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${width}px` }}
>
<div className="flex-1 overflow-y-auto py-1">
{/* Root / Home entry */}
<div
style={{ paddingBlock: "var(--density-sidebar-py)" }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 px-2",
currentPath === "/"
? "bg-accent text-accent-foreground"
: "hover:bg-muted text-foreground",
"font-medium"
)}
>
<button
onClick={() => handleFolderClick("/", null)}
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
>
<Home className={cn("w-4 h-4 flex-shrink-0 mr-2 transition-colors")} />
<span className="truncate">{t("breadcrumb_root")}</span>
</button>
</div>
{/* Folder tree */}
{rootChildren === null && loadingIds.has("root") ? (
<div className="px-3 py-2 space-y-2">
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
<div className="h-4 w-20 bg-muted animate-pulse rounded" />
<div className="h-4 w-28 bg-muted animate-pulse rounded" />
</div>
) : (
rootChildren?.map(folder => (
<FolderTreeItem
key={folder.id}
node={folder}
depth={0}
currentPath={currentPath}
expandedIds={expandedIds}
loadingIds={loadingIds}
childrenCache={childrenCache}
onToggleExpand={handleToggleExpand}
onFolderClick={handleFolderClick}
onLoadChildren={loadChildren}
/>
))
)}
</div>
</div>
);
}
function FolderTreeItem({
node,
depth,
currentPath,
expandedIds,
loadingIds,
childrenCache,
onToggleExpand,
onFolderClick,
onLoadChildren,
}: {
node: FolderNode;
depth: number;
currentPath: string;
expandedIds: Set<string>;
loadingIds: Set<string>;
childrenCache: Map<string, FolderNode[]>;
onToggleExpand: (folderId: string, folderPath: string) => void;
onFolderClick: (path: string, id: string | null) => void;
onLoadChildren: (parentId: string, parentPath: string) => Promise<void>;
}) {
const isExpanded = expandedIds.has(node.id);
const isSelected = currentPath === node.path;
const isLoading = loadingIds.has(node.id);
const children = childrenCache.get(node.id);
const hasChildren = children !== undefined && children.length > 0;
const indentPx = depth * 16;
const Icon = isExpanded && hasChildren ? FolderOpen : Folder;
// Eagerly load children on mount to know if subfolders exist
useEffect(() => {
if (children === undefined && !loadingIds.has(node.id)) {
onLoadChildren(node.id, node.path);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div
style={{ paddingBlock: "var(--density-sidebar-py)" }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 px-2",
isSelected
? "bg-accent text-accent-foreground"
: "hover:bg-muted text-foreground",
depth === 0 && "font-medium"
)}
>
{/* Expand/collapse chevron */}
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation();
onToggleExpand(node.id, node.path);
}}
className={cn(
"p-0.5 rounded mr-1 transition-all duration-200",
"hover:bg-muted active:bg-accent"
)}
style={{ marginLeft: `${indentPx}px` }}
>
{isExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
</button>
) : null}
{/* Folder name */}
<button
onClick={() => onFolderClick(node.path, node.id)}
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
style={{
paddingBlock: "var(--density-sidebar-py)",
paddingLeft: hasChildren ? "4px" : `${indentPx + 24}px`,
}}
>
<Icon className={cn(
"w-4 h-4 flex-shrink-0 mr-2 transition-colors",
isExpanded && hasChildren && "text-primary",
!hasChildren && depth > 0 && "text-muted-foreground"
)} />
<span className="truncate">{node.name}</span>
</button>
</div>
{/* Children */}
{isExpanded && children && (
<div className="relative">
{children.map(child => (
<FolderTreeItem
key={child.id}
node={child}
depth={depth + 1}
currentPath={currentPath}
expandedIds={expandedIds}
loadingIds={loadingIds}
childrenCache={childrenCache}
onToggleExpand={onToggleExpand}
onFolderClick={onFolderClick}
onLoadChildren={onLoadChildren}
/>
))}
</div>
)}
</>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { X, Download, ZoomIn, ZoomOut, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "next-intl";
interface ImagePreviewModalProps {
name: string;
onClose: () => void;
onDownload: (name: string) => Promise<void>;
getImageUrl: (name: string) => Promise<string>;
}
export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: ImagePreviewModalProps) {
const t = useTranslations("files");
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
useEffect(() => {
let revoke: string | null = null;
setLoading(true);
setError(false);
getImageUrl(name)
.then((url) => {
revoke = url;
setImageUrl(url);
setLoading(false);
})
.catch(() => {
setError(true);
setLoading(false);
});
return () => {
if (revoke) URL.revokeObjectURL(revoke);
};
}, [name, getImageUrl]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "+" || e.key === "=") setZoom((z) => Math.min(z + 0.25, 5));
if (e.key === "-") setZoom((z) => Math.max(z - 0.25, 0.25));
if (e.key === "r") setRotation((r) => r + 90);
}, [onClose]);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
return (
<div
role="dialog"
aria-label={name}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
onClick={onClose}
>
{/* Header */}
<div className="absolute top-0 left-0 right-0 flex items-center justify-between px-4 py-3 bg-gradient-to-b from-black/60 to-transparent z-10">
<span className="text-white text-sm font-medium truncate max-w-[50%]">{name}</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setZoom((z) => Math.min(z + 0.25, 5)); }}>
<ZoomIn className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setZoom((z) => Math.max(z - 0.25, 0.25)); }}>
<ZoomOut className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setRotation((r) => r + 90); }}>
<RotateCw className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); onDownload(name); }}>
<Download className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
</div>
{/* Image */}
<div className="flex items-center justify-center w-full h-full p-16" onClick={(e) => e.stopPropagation()}>
{loading && (
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin" />
)}
{error && (
<p className="text-white/70 text-sm">{t("preview_error")}</p>
)}
{imageUrl && !error && (
<img
src={imageUrl}
alt={name}
className="max-w-full max-h-full object-contain transition-transform duration-200"
style={{ transform: `scale(${zoom}) rotate(${rotation}deg)` }}
onLoad={() => setLoading(false)}
draggable={false}
/>
)}
</div>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface NewFolderDialogProps {
onConfirm: (name: string) => Promise<void>;
onCancel: () => void;
}
export function NewFolderDialog({ onConfirm, onCancel }: NewFolderDialogProps) {
const t = useTranslations("files");
const [name, setName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
setIsSubmitting(true);
try {
await onConfirm(trimmed);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div
className="bg-background border border-border rounded-lg shadow-lg p-6 w-full max-w-sm mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold mb-4">{t("new_folder")}</h2>
<form onSubmit={handleSubmit}>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("new_folder_name")}
className="mb-4"
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button type="submit" disabled={!name.trim() || isSubmitting}>
{t("create")}
</Button>
</div>
</form>
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface RenameDialogProps {
currentName: string;
title?: string;
label?: string;
onConfirm: (newName: string) => Promise<void>;
onCancel: () => void;
}
export function RenameDialog({ currentName, title, label, onConfirm, onCancel }: RenameDialogProps) {
const t = useTranslations("files");
const [name, setName] = useState(currentName);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
setIsSubmitting(true);
try {
await onConfirm(trimmed);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div
className="bg-background border border-border rounded-lg shadow-lg p-6 w-full max-w-sm mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold mb-4">{title || t("rename_title")}</h2>
<form onSubmit={handleSubmit}>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={label || t("new_name")}
className="mb-4"
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button type="submit" disabled={!name.trim() || isSubmitting}>
{t("save")}
</Button>
</div>
</form>
</div>
</div>
);
}
@@ -9,6 +9,11 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { IdentityForm } from './identity-form';
import { useIdentityStore } from '@/stores/identity-store';
import { useAuthStore } from '@/stores/auth-store';
function useSyncIdentities() {
const syncIdentities = useAuthStore((state) => state.syncIdentities);
return syncIdentities;
}
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { toast } from '@/stores/toast-store';
import { useFocusTrap } from '@/hooks/use-focus-trap';
@@ -34,6 +39,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const client = useAuthStore((state) => state.client);
const { identities, addIdentity, updateIdentityLocal, removeIdentity } = useIdentityStore();
const syncIdentities = useSyncIdentities();
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
@@ -82,6 +88,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
);
addIdentity(newIdentity);
syncIdentities();
setIsCreating(false);
toast.success(tNotif('identity_created'));
} catch (error) {
@@ -104,6 +111,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
});
updateIdentityLocal(identity.id, data);
syncIdentities();
setEditingId(null);
toast.success(tNotif('identity_updated'));
} catch (error) {
@@ -133,6 +141,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
try {
await client.deleteIdentity(identity.id);
removeIdentity(identity.id);
syncIdentities();
toast.success(tNotif('identity_deleted'));
} catch (error) {
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
+4 -1
View File
@@ -2,11 +2,12 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { Mail, Calendar, BookUser, Settings, LogOut } from "lucide-react";
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut } from "lucide-react";
import { usePathname, Link } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store";
import { useEmailStore } from "@/stores/email-store";
import { useWebDAVStore } from "@/stores/webdav-store";
import { cn, formatFileSize } from "@/lib/utils";
interface NavItem {
@@ -140,12 +141,14 @@ export function NavigationRail({
const pathname = usePathname();
const { supportsCalendar } = useCalendarStore();
const { mailboxes } = useEmailStore();
const { supportsWebDAV } = useWebDAVStore();
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const navItems: NavItem[] = [
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false },
{ id: "settings", icon: Settings, labelKey: "settings", href: "/settings" },
];
+28 -6
View File
@@ -46,6 +46,7 @@ interface SidebarProps {
onTagSelect?: (keywordId: string | null) => void;
onCompose?: () => void;
onSidebarClose?: () => void;
onUnreadFilterClick?: (mailboxId: string) => void;
className?: string;
}
@@ -84,6 +85,7 @@ function MailboxTreeItem({
onMailboxSelect,
onToggleExpand,
isCollapsed,
onUnreadFilterClick,
}: {
node: MailboxNode;
selectedMailbox: string;
@@ -91,6 +93,7 @@ function MailboxTreeItem({
onMailboxSelect?: (id: string) => void;
onToggleExpand: (id: string) => void;
isCollapsed: boolean;
onUnreadFilterClick?: (mailboxId: string) => void;
}) {
const t = useTranslations('sidebar');
const tNotifications = useTranslations('notifications');
@@ -188,12 +191,28 @@ function MailboxTreeItem({
<span className="flex-1 truncate">{node.name}</span>
<span className="flex items-center gap-1.5 ml-2 flex-shrink-0">
{node.unreadEmails > 0 && (
<span className={cn(
"text-xs rounded-full px-2 py-0.5 font-medium",
selectedMailbox === node.id
? "bg-primary text-primary-foreground"
: "bg-foreground text-background"
)}>
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onUnreadFilterClick?.(node.id);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onUnreadFilterClick?.(node.id);
}
}}
className={cn(
"text-xs rounded-full px-2 py-0.5 font-medium cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all",
selectedMailbox === node.id
? "bg-primary text-primary-foreground"
: "bg-foreground text-background"
)}
title={node.unreadEmails + " unread"}
>
{node.unreadEmails}
</span>
)}
@@ -217,6 +236,7 @@ function MailboxTreeItem({
onMailboxSelect={onMailboxSelect}
onToggleExpand={onToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
/>
))}
</div>
@@ -336,6 +356,7 @@ export function Sidebar({
onTagSelect,
onCompose,
onSidebarClose,
onUnreadFilterClick,
className,
}: SidebarProps) {
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
@@ -482,6 +503,7 @@ export function Sidebar({
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
/>
))}
</>
@@ -0,0 +1,611 @@
"use client";
import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import { toast } from '@/stores/toast-store';
import { SettingsSection } from './settings-section';
import { Plus, Pencil, Trash2, Check, X, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
const CALENDAR_COLORS = [
"#3b82f6", // blue
"#ef4444", // red
"#22c55e", // green
"#f59e0b", // amber
"#8b5cf6", // violet
"#ec4899", // pink
"#14b8a6", // teal
"#f97316", // orange
"#06b6d4", // cyan
"#84cc16", // lime
"#6366f1", // indigo
"#a855f7", // purple
"#e11d48", // rose
"#0ea5e9", // sky
"#10b981", // emerald
"#d946ef", // fuchsia
];
function CalendarColorPicker({
value,
onChange,
allowCustom,
}: {
value: string;
onChange: (color: string) => void;
allowCustom?: boolean;
}) {
const selectedIsPreset = CALENDAR_COLORS.includes(value);
return (
<div className="flex flex-wrap items-center gap-1.5">
{CALENDAR_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => onChange(color)}
className={cn(
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
value === color && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
style={{ backgroundColor: color }}
aria-label={color}
/>
))}
{allowCustom && (
<label
className={cn(
"relative w-6 h-6 rounded-full cursor-pointer transition-transform hover:scale-110 overflow-hidden border-2 border-dashed border-muted-foreground/40",
!selectedIsPreset && value && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
style={!selectedIsPreset && value ? { backgroundColor: value } : undefined}
title="Custom color"
>
<input
type="color"
value={value || "#3b82f6"}
onChange={(e) => onChange(e.target.value)}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
/>
{(selectedIsPreset || !value) && (
<span className="absolute inset-0 flex items-center justify-center text-muted-foreground text-xs font-bold">+</span>
)}
</label>
)}
</div>
);
}
function CalendarEditForm({
initial,
onSave,
onCancel,
isLoading,
}: {
initial?: { name: string; color: string };
onSave: (data: { name: string; color: string }) => void;
onCancel: () => void;
isLoading: boolean;
}) {
const t = useTranslations('calendar.management');
const [name, setName] = useState(initial?.name || '');
const [color, setColor] = useState(initial?.color || '#3b82f6');
const isValid = name.trim().length > 0;
return (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t('name')}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && isValid) onSave({ name: name.trim(), color });
if (e.key === 'Escape') onCancel();
}}
placeholder={t('name_placeholder')}
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
disabled={isLoading}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t('color')}
</label>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => isValid && onSave({ name: name.trim(), color })}
disabled={isLoading || !isValid}
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{initial ? t('save') : t('create')}
</button>
<button
onClick={onCancel}
disabled={isLoading}
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
</div>
);
}
export { CalendarColorPicker, CALENDAR_COLORS };
export function CalendarManagementSettings() {
const t = useTranslations('calendar.management');
const { client, serverUrl, username } = useAuthStore();
const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
const [isCreating, setIsCreating] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [clearingId, setClearingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [deletingSubId, setDeletingSubId] = useState<string | null>(null);
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const tImport = useTranslations('calendar.import');
const tSub = useTranslations('calendar.subscription');
const colorPickerRef = useRef<HTMLDivElement>(null);
// Load calendars if not yet loaded
useEffect(() => {
if (client && calendars.length === 0) {
fetchCalendars(client);
}
}, [client, calendars.length, fetchCalendars]);
const handleRefreshSubscription = async (subId: string) => {
if (!client) return;
setRefreshingSubId(subId);
try {
await refreshICalSubscription(client, subId);
toast.success(tSub('refresh_success'));
} catch {
toast.error(tSub('refresh_error'));
} finally {
setRefreshingSubId(null);
}
};
const handleDeleteSubscription = async (subId: string) => {
if (!client) return;
setIsLoading(true);
try {
await removeICalSubscription(client, subId);
setDeletingSubId(null);
toast.success(tSub('deleted'));
} catch {
toast.error(tSub('delete_error'));
} finally {
setIsLoading(false);
}
};
// Close color picker on click outside
useEffect(() => {
if (!colorPickerId) return;
const handleClick = (e: MouseEvent) => {
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
setColorPickerId(null);
}
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setColorPickerId(null);
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [colorPickerId]);
const handleCreate = async (data: { name: string; color: string }) => {
if (!client) return;
setIsLoading(true);
try {
await createCalendar(client, {
name: data.name,
color: data.color,
isVisible: true,
isSubscribed: true,
});
setIsCreating(false);
toast.success(t('calendar_created'));
} catch {
toast.error(t('error_create'));
} finally {
setIsLoading(false);
}
};
const handleUpdate = async (calendarId: string, data: { name: string; color: string }) => {
if (!client) return;
setIsLoading(true);
try {
await updateCalendar(client, calendarId, { name: data.name, color: data.color });
setEditingId(null);
toast.success(t('calendar_updated'));
} catch {
toast.error(t('error_update'));
} finally {
setIsLoading(false);
}
};
const handleColorChange = async (calendarId: string, color: string) => {
if (!client) return;
try {
await updateCalendar(client, calendarId, { color });
toast.success(t('color_updated'));
} catch {
toast.error(t('error_update'));
}
setColorPickerId(null);
};
const handleDelete = async (calendarId: string) => {
if (!client) return;
setIsLoading(true);
try {
await removeCalendar(client, calendarId);
setDeletingId(null);
toast.success(t('calendar_deleted'));
} catch {
toast.error(t('error_delete'));
} finally {
setIsLoading(false);
}
};
const handleClear = async (calendarId: string) => {
if (!client) return;
setIsLoading(true);
try {
const count = await clearCalendarEvents(client, calendarId);
setClearingId(null);
toast.success(t('events_cleared', { count }));
} catch {
toast.error(t('error_clear'));
} finally {
setIsLoading(false);
}
};
const buildCalDavUrl = (calendarId: string) => {
if (!serverUrl || !username) return null;
const base = serverUrl.replace(/\/$/, '');
return `${base}/dav/calendars/user/${encodeURIComponent(username)}/${encodeURIComponent(calendarId)}/`;
};
const handleCopyUrl = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
toast.success(t('url_copied'));
} catch {
// Fallback for non-HTTPS contexts
const textArea = document.createElement('textarea');
textArea.value = url;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
toast.success(t('url_copied'));
}
};
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="space-y-2">
{calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => {
const color = cal.color || '#3b82f6';
if (editingId === cal.id) {
return (
<CalendarEditForm
key={cal.id}
initial={{ name: cal.name, color }}
onSave={(data) => handleUpdate(cal.id, data)}
onCancel={() => setEditingId(null)}
isLoading={isLoading}
/>
);
}
if (deletingId === cal.id) {
return (
<div key={cal.id} className="flex items-center gap-3 py-2.5 px-3 bg-destructive/5 rounded-md border border-destructive/20">
<Trash2 className="w-4 h-4 text-destructive flex-shrink-0" />
<p className="text-sm text-foreground flex-1">
{t('confirm_delete', { name: cal.name })}
</p>
<button
onClick={() => handleDelete(cal.id)}
disabled={isLoading}
className="px-3 py-1 text-xs font-medium bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 disabled:opacity-50"
>
{t('delete')}
</button>
<button
onClick={() => setDeletingId(null)}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
);
}
if (clearingId === cal.id) {
return (
<div key={cal.id} className="flex items-center gap-3 py-2.5 px-3 bg-amber-500/5 rounded-md border border-amber-500/20">
<Eraser className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<p className="text-sm text-foreground flex-1">
{t('confirm_clear', { name: cal.name })}
</p>
<button
onClick={() => handleClear(cal.id)}
disabled={isLoading}
className="px-3 py-1 text-xs font-medium bg-amber-600 text-white rounded-md hover:bg-amber-700 disabled:opacity-50"
>
{t('clear_events')}
</button>
<button
onClick={() => setClearingId(null)}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
);
}
return (
<div
key={cal.id}
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
>
{/* Color swatch - clickable to change color */}
<div className="relative">
<button
type="button"
onClick={() => setColorPickerId(colorPickerId === cal.id ? null : cal.id)}
className="w-5 h-5 rounded-full shrink-0 transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
style={{ backgroundColor: color }}
title={t('change_color')}
/>
{/* Inline color picker popover */}
{colorPickerId === cal.id && (
<div
ref={colorPickerRef}
className="absolute left-0 top-full mt-2 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
>
<CalendarColorPicker
value={color}
onChange={(c) => handleColorChange(cal.id, c)}
allowCustom
/>
</div>
)}
</div>
<CalendarIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{cal.name}</span>
{(() => {
const caldavUrl = buildCalDavUrl(cal.id);
if (!caldavUrl) return null;
return (
<div className="flex items-center gap-1 mt-0.5">
<Link className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<span className="text-xs text-muted-foreground truncate" title={caldavUrl}>
{caldavUrl}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleCopyUrl(caldavUrl);
}}
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
title={t('copy_url')}
>
<Copy className="w-3 h-3" />
</button>
</div>
);
})()}
</div>
{cal.isDefault && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{t('default')}
</span>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingId(cal.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t('edit')}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setClearingId(cal.id)}
className="p-1.5 rounded-md hover:bg-amber-500/10 text-muted-foreground hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
title={t('clear_events')}
>
<Eraser className="w-3.5 h-3.5" />
</button>
{!cal.isDefault && (
<button
type="button"
onClick={() => setDeletingId(cal.id)}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={t('delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
})}
{isCreating ? (
<CalendarEditForm
onSave={handleCreate}
onCancel={() => setIsCreating(false)}
isLoading={isLoading}
/>
) : (
<div className="flex gap-2">
<button
type="button"
onClick={() => setIsCreating(true)}
className="flex items-center gap-2 flex-1 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
>
<Plus className="w-4 h-4" />
{t('add_calendar')}
</button>
<button
type="button"
onClick={() => setShowImportModal(true)}
className="flex items-center gap-2 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
>
<Upload className="w-4 h-4" />
{tImport('title')}
</button>
<button
type="button"
onClick={() => setShowSubscriptionModal(true)}
className="flex items-center gap-2 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
>
<Globe className="w-4 h-4" />
{tSub('title')}
</button>
</div>
)}
</div>
{/* iCal Subscriptions */}
{icalSubscriptions.length > 0 && (
<div className="mt-6 space-y-2">
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
<Globe className="w-4 h-4 text-muted-foreground" />
{tSub('section_title')}
</h4>
{icalSubscriptions.map((sub) => {
if (deletingSubId === sub.id) {
return (
<div key={sub.id} className="flex items-center gap-3 py-2.5 px-3 bg-destructive/5 rounded-md border border-destructive/20">
<Trash2 className="w-4 h-4 text-destructive flex-shrink-0" />
<p className="text-sm text-foreground flex-1">
{tSub('confirm_delete', { name: sub.name })}
</p>
<button
onClick={() => handleDeleteSubscription(sub.id)}
disabled={isLoading}
className="px-3 py-1 text-xs font-medium bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 disabled:opacity-50"
>
{t('delete')}
</button>
<button
onClick={() => setDeletingSubId(null)}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
);
}
return (
<div
key={sub.id}
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
>
<span
className="w-5 h-5 rounded-full shrink-0"
style={{ backgroundColor: sub.color }}
/>
<Globe className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{sub.name}</span>
<span className="text-xs text-muted-foreground truncate block" title={sub.url}>
{sub.url}
</span>
{sub.lastRefreshed && (
<span className="text-xs text-muted-foreground">
{tSub('last_refreshed', { time: new Date(sub.lastRefreshed).toLocaleString() })}
</span>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => handleRefreshSubscription(sub.id)}
disabled={refreshingSubId === sub.id}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
title={tSub('refresh')}
>
<RefreshCw className={cn("w-3.5 h-3.5", refreshingSubId === sub.id && "animate-spin")} />
</button>
<button
type="button"
onClick={() => setDeletingSubId(sub.id)}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={tSub('unsubscribe')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
)}
{showImportModal && client && (
<ICalImportModal
calendars={calendars}
client={client}
onClose={() => setShowImportModal(false)}
/>
)}
{showSubscriptionModal && client && (
<ICalSubscriptionModal
client={client}
onClose={() => setShowSubscriptionModal(false)}
/>
)}
</SettingsSection>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Upload, Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsSection, SettingItem } from "./settings-section";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
export function ContactsSettings() {
const t = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const {
contacts,
supportsSync,
importContacts,
} = useContactStore();
const [showImport, setShowImport] = useState(false);
const individuals = contacts.filter(c => c.kind !== "group");
const handleImport = useCallback(async (importedContacts: import("@/lib/jmap/types").ContactCard[]) => {
return importContacts(
supportsSync && client ? client : null,
importedContacts
);
}, [supportsSync, client, importContacts]);
const handleExport = () => {
if (individuals.length > 0) {
exportContacts(individuals);
toast.success(t("export.success", { count: individuals.length }));
}
};
if (showImport) {
return (
<div className="border border-border rounded-lg overflow-hidden" style={{ minHeight: 400 }}>
<ContactImportDialog
existingContacts={contacts}
onImport={handleImport}
onClose={() => setShowImport(false)}
/>
</div>
);
}
return (
<SettingsSection
title={tSettings("title")}
description={tSettings("description")}
>
<SettingItem
label={tSettings("import_label")}
description={tSettings("import_description")}
>
<Button variant="outline" size="sm" onClick={() => setShowImport(true)}>
<Upload className="w-4 h-4 mr-2" />
{t("import.title")}
</Button>
</SettingItem>
<SettingItem
label={tSettings("export_label")}
description={tSettings("export_description")}
>
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={individuals.length === 0}
>
<Download className="w-4 h-4 mr-2" />
{t("export.title")}
</Button>
</SettingItem>
</SettingsSection>
);
}
+9
View File
@@ -14,6 +14,7 @@ export function EmailSettings() {
const {
markAsReadDelay,
deleteAction,
permanentlyDeleteJunk,
showPreview,
emailsPerPage,
externalContentPolicy,
@@ -65,6 +66,14 @@ export function EmailSettings() {
</div>
</SettingItem>
{/* Permanently Delete Junk */}
<SettingItem label={t('permanently_delete_junk.label')} description={t('permanently_delete_junk.description')}>
<ToggleSwitch
checked={permanentlyDeleteJunk}
onChange={(checked) => updateSetting('permanentlyDeleteJunk', checked)}
/>
</SettingItem>
{/* Show Preview */}
<SettingItem label={t('show_preview.label')} description={t('show_preview.description')}>
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
+280
View File
@@ -0,0 +1,280 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Folder, FolderOpen, FileText, FileCode, ImageIcon, FileAudio, File, Home, ChevronRight, ChevronDown } from "lucide-react";
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section";
import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog";
import { cn } from "@/lib/utils";
interface SampleFile {
name: string;
isFolder: boolean;
size: number;
modified: string;
hidden?: boolean;
thumbnailUrl?: string;
}
const SAMPLE_FILES: SampleFile[] = [
{ name: "Documents", isFolder: true, size: 0, modified: "2026-03-10" },
{ name: "Photos", isFolder: true, size: 0, modified: "2026-03-14" },
{ name: "report.pdf", isFolder: false, size: 245000, modified: "2026-03-15" },
{ name: "notes.md", isFolder: false, size: 1200, modified: "2026-03-12" },
{ name: "vacation.jpg", isFolder: false, size: 3400000, modified: "2026-03-08", thumbnailUrl: "/branding/Bulwark_Logo_Color.png" },
{ name: "song.mp3", isFolder: false, size: 5200000, modified: "2026-03-01" },
{ name: ".config", isFolder: false, size: 340, modified: "2026-02-20", hidden: true },
];
function getPreviewIcon(file: SampleFile, colored: boolean, size: "sm" | "lg") {
const cls = size === "sm" ? "w-4 h-4 flex-shrink-0" : "w-8 h-8 flex-shrink-0";
if (file.isFolder) {
return <Folder className={cn(cls, colored ? "text-blue-500" : "text-muted-foreground")} />;
}
const ext = file.name.split(".").pop()?.toLowerCase();
switch (ext) {
case "jpg": case "png": case "gif":
return <ImageIcon className={cn(cls, colored ? "text-emerald-500" : "text-muted-foreground")} />;
case "mp3": case "wav":
return <FileAudio className={cn(cls, colored ? "text-purple-500" : "text-muted-foreground")} />;
case "pdf":
return <FileText className={cn(cls, colored ? "text-red-600" : "text-muted-foreground")} />;
case "md": case "json": case "js": case "ts":
return <FileCode className={cn(cls, colored ? "text-yellow-600" : "text-muted-foreground")} />;
default:
return <File className={cn(cls, "text-muted-foreground")} />;
}
}
function formatSize(bytes: number): string {
if (bytes === 0) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
const sortedFiles = useMemo(() => {
let files = SAMPLE_FILES.filter((f) => {
if (!settings.showHiddenFiles && f.hidden) return false;
if (settings.folderLayout === "sidebar" && f.isFolder) return false;
return true;
});
files.sort((a, b) => {
// Folders first
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
let cmp = 0;
switch (settings.defaultSortKey) {
case "name": cmp = a.name.localeCompare(b.name); break;
case "size": cmp = a.size - b.size; break;
case "modified": cmp = a.modified.localeCompare(b.modified); break;
}
return settings.defaultSortDir === "desc" ? -cmp : cmp;
});
return files;
}, [settings.showHiddenFiles, settings.folderLayout, settings.defaultSortKey, settings.defaultSortDir]);
const listView = (
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-3 px-2 py-1 text-[10px] font-medium text-muted-foreground border-b border-border bg-muted/50">
<span className="flex-1 min-w-0">Name</span>
<span className="w-14 text-right">Size</span>
<span className="w-16 text-right">Modified</span>
</div>
{sortedFiles.map((file) => (
<div
key={file.name}
className={cn(
"flex items-center gap-2 px-2 py-1.5 border-b border-border last:border-b-0 transition-colors hover:bg-muted/50",
file.hidden && "opacity-50"
)}
>
{settings.showThumbnails && file.thumbnailUrl ? (
<img src={file.thumbnailUrl} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" />
) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "sm")
) : null}
<span className={cn("flex-1 min-w-0 truncate text-[11px]", file.isFolder && "font-medium")}>
{file.name}
</span>
<span className="w-14 text-right text-[10px] text-muted-foreground tabular-nums">
{formatSize(file.size)}
</span>
<span className="w-16 text-right text-[10px] text-muted-foreground tabular-nums">
{file.modified.slice(5)}
</span>
</div>
))}
</div>
);
const gridView = (
<div className="flex-1 min-w-0 p-2">
<div className="grid grid-cols-[repeat(auto-fill,minmax(4.5rem,1fr))] gap-1.5">
{sortedFiles.map((file) => (
<div
key={file.name}
className={cn(
"flex flex-col items-center gap-1 p-2 rounded-md transition-colors hover:bg-muted/50",
file.hidden && "opacity-50"
)}
>
{settings.showThumbnails && file.thumbnailUrl ? (
<img src={file.thumbnailUrl} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "lg")
) : (
<div className="w-8 h-8" />
)}
<span className={cn("text-[9px] truncate w-full text-center", file.isFolder && "font-medium")}>
{file.name}
</span>
</div>
))}
</div>
</div>
);
const sidebar = settings.folderLayout === "sidebar" && (
<div className="w-24 border-r border-border bg-muted/30 py-1.5 flex-shrink-0">
<div className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-medium text-foreground">
<Home className="w-3 h-3 flex-shrink-0" />
<span className="truncate">Files</span>
</div>
<div className="flex items-center gap-1 px-2 py-0.5 text-[10px] text-foreground bg-accent rounded-sm mx-1">
<ChevronDown className="w-2.5 h-2.5 flex-shrink-0" />
<FolderOpen className="w-3 h-3 flex-shrink-0 text-blue-500" />
<span className="truncate">Documents</span>
</div>
<div className="flex items-center gap-1 px-2 py-0.5 text-[10px] text-muted-foreground" style={{ paddingLeft: "1.25rem" }}>
<ChevronRight className="w-2.5 h-2.5 flex-shrink-0" />
<Folder className="w-3 h-3 flex-shrink-0 text-blue-500" />
<span className="truncate">Photos</span>
</div>
</div>
);
return (
<div className="mt-4 rounded-lg border border-border overflow-hidden bg-background text-xs select-none">
<div className="flex" style={{ minHeight: "10rem" }}>
{sidebar}
{settings.defaultViewMode === "grid" ? gridView : listView}
</div>
</div>
);
}
export function FilesSettingsComponent() {
const t = useTranslations("settings.files");
const [settings, setSettings] = useState<FilesSettings>(loadFilesSettings);
// Listen for external changes (e.g. if file-browser updates settings)
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === "files-settings") {
setSettings(loadFilesSettings());
}
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
const update = useCallback((patch: Partial<FilesSettings>) => {
setSettings(prev => {
const next = { ...prev, ...patch };
saveFilesSettings(next);
return next;
});
}, []);
return (
<div>
<div className="sticky top-0 z-10 bg-background pb-4 -mx-4 px-4 -mt-4 pt-4 lg:-mx-6 lg:px-6 lg:-mt-6 lg:pt-6 border-b border-border mb-6">
<p className="text-sm font-medium text-foreground mb-1">{t("preview.label")}</p>
<FilesSettingsPreview settings={settings} />
</div>
<div className="space-y-8">
<SettingsSection title={t("display.title")} description={t("display.description")}>
<SettingItem label={t("folder_layout.label")} description={t("folder_layout.description")}>
<RadioGroup
value={settings.folderLayout}
onChange={(v) => update({ folderLayout: v as FolderLayout })}
options={[
{ value: "inline", label: t("folder_layout.inline") },
{ value: "sidebar", label: t("folder_layout.sidebar") },
]}
/>
</SettingItem>
<SettingItem label={t("default_view.label")} description={t("default_view.description")}>
<RadioGroup
value={settings.defaultViewMode}
onChange={(v) => update({ defaultViewMode: v as "list" | "grid" })}
options={[
{ value: "list", label: t("default_view.list") },
{ value: "grid", label: t("default_view.grid") },
]}
/>
</SettingItem>
<SettingItem label={t("default_sort.label")} description={t("default_sort.description")}>
<RadioGroup
value={settings.defaultSortKey}
onChange={(v) => update({ defaultSortKey: v as "name" | "size" | "modified" })}
options={[
{ value: "name", label: t("default_sort.name") },
{ value: "size", label: t("default_sort.size") },
{ value: "modified", label: t("default_sort.modified") },
]}
/>
</SettingItem>
<SettingItem label={t("sort_direction.label")} description={t("sort_direction.description")}>
<RadioGroup
value={settings.defaultSortDir}
onChange={(v) => update({ defaultSortDir: v as "asc" | "desc" })}
options={[
{ value: "asc", label: t("sort_direction.ascending") },
{ value: "desc", label: t("sort_direction.descending") },
]}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t("icons.title")} description={t("icons.description")}>
<SettingItem label={t("show_icons.label")} description={t("show_icons.description")}>
<ToggleSwitch
checked={settings.showIcons}
onChange={(v) => update({ showIcons: v })}
/>
</SettingItem>
<SettingItem label={t("colored_icons.label")} description={t("colored_icons.description")}>
<ToggleSwitch
checked={settings.coloredIcons}
onChange={(v) => update({ coloredIcons: v })}
disabled={!settings.showIcons}
/>
</SettingItem>
<SettingItem label={t("show_thumbnails.label")} description={t("show_thumbnails.description")}>
<ToggleSwitch
checked={settings.showThumbnails}
onChange={(v) => update({ showThumbnails: v })}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t("behavior.title")} description={t("behavior.description")}>
<SettingItem label={t("show_hidden.label")} description={t("show_hidden.description")}>
<ToggleSwitch
checked={settings.showHiddenFiles}
onChange={(v) => update({ showHiddenFiles: v })}
/>
</SettingItem>
</SettingsSection>
</div>
</div>
);
}