Merge remote-tracking branch 'origin/dev' into sync-github-and-ci-fix

This commit is contained in:
Bernd Rodler
2026-08-07 14:03:10 +02:00
62 changed files with 7794 additions and 125 deletions
+64 -1
View File
@@ -13,6 +13,8 @@ import { useSettingsStore } from "@/stores/settings-store";
import type { PendingEventPreview } from "./event-modal";
import { toast } from "@/stores/toast-store";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
import { Pencil, Trash2, Copy } from "lucide-react";
interface CalendarMonthViewProps {
selectedDate: Date;
@@ -25,6 +27,9 @@ interface CalendarMonthViewProps {
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
onCreateAtTime?: (date: Date) => void;
onEditEvent?: (event: CalendarEvent) => void;
onDeleteEvent?: (event: CalendarEvent) => void;
onDuplicateEvent?: (event: CalendarEvent) => void;
firstDayOfWeek?: number;
isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
@@ -41,6 +46,9 @@ export function CalendarMonthView({
onContextMenuEvent,
onContextMenuEmpty,
onCreateAtTime,
onEditEvent,
onDeleteEvent,
onDuplicateEvent,
firstDayOfWeek = 1,
isMobile,
pendingPreview,
@@ -112,6 +120,54 @@ export function CalendarMonthView({
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
// Radial menu state
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
const [radialMenuEvent, setRadialMenuEvent] = useState<CalendarEvent | null>(null);
const closeRadialMenu = useCallback(() => {
setRadialMenuOpen(false);
}, []);
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
if (!radialMenuEvent) return [];
const ev = radialMenuEvent;
const items: RadialMenuItem[] = [];
if (onEditEvent) {
items.push({
id: "edit",
icon: <Pencil className="w-5 h-5" />,
label: t("edit"),
onClick: () => { onEditEvent(ev); },
});
}
if (onDeleteEvent) {
items.push({
id: "delete",
icon: <Trash2 className="w-5 h-5" />,
label: t("delete"),
onClick: () => { onDeleteEvent(ev); },
destructive: true,
});
}
if (onDuplicateEvent) {
items.push({
id: "duplicate",
icon: <Copy className="w-5 h-5" />,
label: t("duplicate"),
onClick: () => { onDuplicateEvent(ev); },
});
}
return items;
}, [radialMenuEvent, t, onEditEvent, onDeleteEvent, onDuplicateEvent]);
const handleRadialMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => {
setRadialMenuPos({ x: e.clientX, y: e.clientY });
setRadialMenuEvent(event);
setRadialMenuOpen(true);
onContextMenuEvent?.(e, event);
}, [onContextMenuEvent]);
const handleCellDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
e.preventDefault();
@@ -294,7 +350,7 @@ export function CalendarMonthView({
onClick={(rect) => onSelectEvent(segment.event, rect)}
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
onContextMenu={handleRadialMenuEvent}
draggable
className={isMobile ? "text-[10px] px-1" : undefined}
/>
@@ -306,6 +362,13 @@ export function CalendarMonthView({
</div>
))}
</div>
<RadialMenu
items={radialMenuItems}
isOpen={radialMenuOpen}
position={radialMenuPos}
onClose={closeRadialMenu}
/>
</div>
);
}
+89 -17
View File
@@ -6,7 +6,7 @@ import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import {
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
Pencil, Trash2, Copy, Send, Check,
Pencil, Trash2, Copy, Send, Check, ExternalLink, Globe,
} from "lucide-react";
import { format, isSameDay } from "date-fns";
import { cn } from "@/lib/utils";
@@ -107,6 +107,25 @@ function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTransl
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
}
const URL_REGEX = /(https?:\/\/[^\s<]+[^\s<.,;:!?'")\]}>])/g;
function linkifyText(text: string): (string | { url: string })[] {
const parts: (string | { url: string })[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = URL_REGEX.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
parts.push({ url: match[1] });
lastIndex = match.index + match[1].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts;
}
export function EventDetailPopover({
event,
calendar,
@@ -383,21 +402,34 @@ export function EventDetailPopover({
{locationName && (
<div className="flex items-start gap-2.5">
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
{/^https?:\/\//i.test(locationName) ? (
<a
href={locationName}
target="_blank"
rel="noreferrer"
className="text-sm text-primary hover:underline truncate"
title={locationName}
>
{(() => {
try { return new URL(locationName).hostname; } catch { return locationName; }
})()}
</a>
) : (
<span className="text-sm text-foreground">{locationName}</span>
)}
<div className="min-w-0">
{/^https?:\/\//i.test(locationName) ? (
<a
href={locationName}
target="_blank"
rel="noreferrer"
className="text-sm text-primary hover:underline truncate block"
title={locationName}
>
{(() => {
try { return new URL(locationName).hostname; } catch { return locationName; }
})()}
</a>
) : (
<>
<span className="text-sm text-foreground">{locationName}</span>
<a
href={`https://maps.google.com/?q=${encodeURIComponent(locationName)}`}
target="_blank"
rel="noreferrer"
className="text-xs text-primary hover:underline mt-0.5 inline-flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
View on Map
</a>
</>
)}
</div>
</div>
)}
@@ -413,6 +445,8 @@ export function EventDetailPopover({
title={virtualLocation}
>
{(() => {
const isVncMeeting = event.links?.["vnctalk-meeting"];
if (isVncMeeting) return "Join VNCtalk Meeting";
try {
return new URL(virtualLocation).hostname;
} catch {
@@ -423,6 +457,30 @@ export function EventDetailPopover({
</div>
)}
{/* VNCtalk Meeting "Join" button (when meeting via links) */}
{!virtualLocation && event.links?.["vnctalk-meeting"] && (
<div className="flex items-start gap-2.5">
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<a
href={event.links["vnctalk-meeting"].href}
target="_blank"
rel="noreferrer"
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="w-3.5 h-3.5" />
Join VNCtalk Meeting
</a>
</div>
)}
{/* Timezone */}
{!event.showWithoutTime && event.timeZone && (
<div className="flex items-start gap-2.5">
<Globe className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">{event.timeZone}</span>
</div>
)}
{/* Participants */}
{hasParticipants && (
<div className="flex items-start gap-2.5">
@@ -476,7 +534,21 @@ export function EventDetailPopover({
<div className="flex items-start gap-2.5">
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<p className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3">
{event.description}
{linkifyText(event.description).map((part, i) =>
typeof part === "string" ? (
<span key={i}>{part}</span>
) : (
<a
key={i}
href={part.url}
target="_blank"
rel="noreferrer"
className="text-primary hover:underline"
>
{part.url}
</a>
)
)}
</p>
</div>
)}
+352 -20
View File
@@ -4,13 +4,14 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations, useLocale } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus, Eye, EyeOff, ExternalLink, Reply, ReplyAll, Globe, Building2 } from "lucide-react";
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
import { FreeBusyView } from "./free-busy-view";
import {
isOrganizer,
getUserParticipantId,
@@ -25,6 +26,10 @@ import { generateUUID } from "@/lib/utils";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { calendarHooks } from "@/lib/plugin-hooks";
import type { ConflictWarning } from "@/lib/plugin-types";
import { RecipientPopover } from "@/components/email/recipient-popover";
import { useProTabStore } from "@/stores/pro-tab-store";
import { ResourcePicker } from "./resource-picker";
import { useResourceStore } from "@/stores/resource-store";
export interface PendingEventPreview {
start: Date;
@@ -49,6 +54,10 @@ interface EventModalProps {
onPreviewChange?: (preview: PendingEventPreview | null) => void;
currentUserEmails?: string[];
isMobile?: boolean;
prefillTitle?: string;
prefillDescription?: string;
prefillParticipants?: { name?: string; email: string }[];
prefillDate?: string;
}
function formatDateInput(d: Date): string {
@@ -59,6 +68,25 @@ function formatTimeInput(d: Date): string {
return format(d, "HH:mm");
}
const URL_REGEX = /(https?:\/\/[^\s<]+[^\s<.,;:!?'")\]}>])/g;
function linkifyText(text: string): (string | { url: string })[] {
const parts: (string | { url: string })[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = URL_REGEX.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
parts.push({ url: match[1] });
lastIndex = match.index + match[1].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts;
}
function buildDuration(startDate: Date, endDate: Date): string {
const diffMs = endDate.getTime() - startDate.getTime();
const totalMinutes = Math.max(0, Math.floor(diffMs / 60000));
@@ -178,6 +206,10 @@ export function EventModal({
onPreviewChange,
currentUserEmails = [],
isMobile = false,
prefillTitle,
prefillDescription,
prefillParticipants,
prefillDate,
}: EventModalProps) {
const t = useTranslations("calendar");
const locale = useLocale();
@@ -228,6 +260,10 @@ export function EventModal({
d.setHours(now.getHours() + 1, 0, 0, 0);
return d;
}
if (prefillDate) {
const d = new Date(prefillDate);
if (!isNaN(d.getTime())) return d;
}
const d = new Date();
d.setHours(d.getHours() + 1, 0, 0, 0);
return d;
@@ -244,8 +280,8 @@ export function EventModal({
return addHours(getInitialStart(), 1);
};
const [title, setTitle] = useState(event?.title || "");
const [description, setDescription] = useState(event?.description || "");
const [title, setTitle] = useState(event?.title || prefillTitle || "");
const [description, setDescription] = useState(event?.description || prefillDescription || "");
const [location, setLocation] = useState(
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
);
@@ -328,13 +364,29 @@ export function EventModal({
const [isSaving, setIsSaving] = useState(false);
const [attendees, setAttendees] = useState<{ name: string; email: string }[]>(() => {
if (!event?.participants) return [];
if (!event?.participants) {
if (prefillParticipants && prefillParticipants.length > 0) {
return prefillParticipants.map(p => ({ name: p.name || "", email: p.email }));
}
return [];
}
return existingParticipants
.filter(p => !p.isOrganizer)
.map(p => ({ name: p.name, email: p.email }));
});
const [sendInvitations, setSendInvitations] = useState(true);
const [showFreeBusy, setShowFreeBusy] = useState(false);
const participantInputRef = useRef<ParticipantInputHandle>(null);
const [createVncMeeting, setCreateVncMeeting] = useState(false);
const [meetingCreating, setMeetingCreating] = useState(false);
const [timezone, setTimezone] = useState(() => {
if (event?.timeZone) return event.timeZone;
try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return "UTC"; }
});
const openComposeTab = useProTabStore((s) => s.openComposeTab);
const resourceStore = useResourceStore();
const [showResources, setShowResources] = useState(false);
// Plugin transform: collect conflict warnings for the current event form.
// Re-runs (debounced) whenever fields that affect scheduling change.
@@ -361,6 +413,13 @@ export function EventModal({
return () => { cancelled = true; clearTimeout(t); };
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId]);
useEffect(() => {
if (event?.id) {
resourceStore.fetchEventBookings(event.id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [event?.id]);
// Report live preview to parent for grid outline
useEffect(() => {
if (!onPreviewChange || isEdit) return;
@@ -416,7 +475,7 @@ export function EventModal({
duration = buildDuration(start, end);
}
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const timeZone = timezone;
const data: Partial<CalendarEvent> = {
title: trimmedTitle,
@@ -534,14 +593,72 @@ export function EventModal({
data.organizerCalendarAddress = null;
}
// VNCtalk meeting creation
if (createVncMeeting && effectiveAttendees.length > 0 && !allDay) {
setMeetingCreating(true);
try {
const vncRes = await fetch("/api/vnctalk/meeting", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: trimmedTitle,
start: startStr,
end: allDay
? `${endDate}T23:59:59`
: `${endDate}T${endTime}:00`,
invitees: effectiveAttendees.map((a: { email: string }) => a.email),
description: description.trim() || undefined,
}),
});
if (vncRes.ok) {
const { meetingUrl, meetingId } = await vncRes.json();
data.virtualLocations = {
vl1: {
"@type": "VirtualLocation",
name: "VNCtalk Meeting",
description: `Meeting ID: ${meetingId}`,
uri: meetingUrl,
features: null,
},
};
data.links = {
"vnctalk-meeting": {
"@type": "Link",
href: meetingUrl,
cid: meetingId,
contentType: null,
size: null,
rel: "vnctalk-meeting",
display: null,
title: "VNCtalk Meeting",
},
};
}
} catch (err) {
console.error("Failed to create VNCtalk meeting:", err);
} finally {
setMeetingCreating(false);
}
}
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
setIsSaving(true);
try {
await onSave(data, shouldSendScheduling);
if (resourceStore.selectedResources.length > 0) {
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
const eventRef = event?.id || data.uid;
await resourceStore.bookSelectedResources(
startStr,
endStr,
eventRef,
);
}
} finally {
setIsSaving(false);
}
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving, createVncMeeting, timezone, resourceStore]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return;
@@ -575,6 +692,30 @@ export function EventModal({
onDuplicate(data);
}, [event, onDuplicate]);
const handleReply = useCallback((replyAll: boolean) => {
if (!event) return;
const participants = getParticipantList(event);
const recipientEmails = replyAll
? participants.map((p) => ({ email: p.email, name: p.name }))
: (() => {
const org = participants.find((p) => p.isOrganizer);
return org ? [{ email: org.email, name: org.name }] : [];
})();
if (recipientEmails.length === 0) return;
openComposeTab({
sessionId: Date.now(),
mode: replyAll ? "replyAll" : "reply",
title: `Re: ${event.title}`,
replyTo: {
subject: `Re: ${event.title}`,
to: recipientEmails,
},
});
}, [event, openComposeTab]);
const handleReplyAll = useCallback(() => handleReply(true), [handleReply]);
const handleReplySingle = useCallback(() => handleReply(false), [handleReply]);
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -855,22 +996,57 @@ export function EventModal({
{locationName && (
<div className="flex items-start gap-2.5">
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
{/^https?:\/\//i.test(locationName) ? (
<a href={locationName} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={locationName}>
{(() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()}
</a>
) : (
<span className="text-sm text-foreground">{locationName}</span>
)}
<div className="min-w-0">
{/^https?:\/\//i.test(locationName) ? (
<a href={locationName} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate block" title={locationName}>
{(() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()}
</a>
) : (
<>
<span className="text-sm text-foreground">{locationName}</span>
<a
href={`https://maps.google.com/?q=${encodeURIComponent(locationName)}`}
target="_blank"
rel="noreferrer"
className="text-xs text-primary hover:underline mt-0.5 inline-flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
View on Map
</a>
</>
)}
</div>
</div>
)}
{/* Virtual Location */}
{/* Virtual Location / Meeting Link */}
{virtualLoc && (
<div className="flex items-start gap-2.5">
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<a href={virtualLoc} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={virtualLoc}>
{(() => { try { return new URL(virtualLoc).hostname; } catch { return virtualLoc; } })()}
<div className="min-w-0">
<a href={virtualLoc} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate block" title={virtualLoc}>
{(() => {
const isVncMeeting = event.links?.["vnctalk-meeting"];
if (isVncMeeting) return "Join VNCtalk Meeting";
try { return new URL(virtualLoc).hostname; } catch { return virtualLoc; }
})()}
</a>
</div>
</div>
)}
{/* VNCtalk Meeting "Join" button (when meeting via links) */}
{!virtualLoc && event.links?.["vnctalk-meeting"] && (
<div className="flex items-start gap-2.5">
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<a
href={event.links["vnctalk-meeting"].href}
target="_blank"
rel="noreferrer"
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="w-3.5 h-3.5" />
Join VNCtalk Meeting
</a>
</div>
)}
@@ -887,7 +1063,7 @@ export function EventModal({
{viewParticipants.map((p) => (
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
<span className="truncate text-foreground">
{p.name || p.email}
<RecipientPopover name={p.name} email={p.email} />
{p.isOrganizer && (
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
)}
@@ -900,6 +1076,34 @@ export function EventModal({
</div>
)}
{/* Resources (booked) */}
{resourceStore.bookings.length > 0 && (
<div className="flex items-start gap-2.5">
<Building2 className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="flex flex-wrap gap-1.5">
{resourceStore.bookings.map((b) => {
const res = resourceStore.resources.find((r) => r.id === b.resourceId);
return (
<span
key={b.id}
className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs font-medium"
>
{res?.name || b.resourceId}
</span>
);
})}
</div>
</div>
)}
{/* Timezone */}
{!event.showWithoutTime && event.timeZone && (
<div className="flex items-start gap-2.5">
<Globe className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">{event.timeZone}</span>
</div>
)}
{/* Recurrence */}
{recurrenceLabel && (
<div className="flex items-start gap-2.5">
@@ -920,7 +1124,23 @@ export function EventModal({
{event.description && (
<div className="flex items-start gap-2.5">
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<p className="text-sm text-muted-foreground whitespace-pre-line">{event.description}</p>
<p className="text-sm text-muted-foreground whitespace-pre-line">
{linkifyText(event.description).map((part, i) =>
typeof part === "string" ? (
<span key={i}>{part}</span>
) : (
<a
key={i}
href={part.url}
target="_blank"
rel="noreferrer"
className="text-primary hover:underline"
>
{part.url}
</a>
)
)}
</p>
</div>
)}
</div>
@@ -933,7 +1153,7 @@ export function EventModal({
showDeleteConfirm ? (
<div className="flex items-center gap-2">
<span className="text-sm text-destructive">{t("form.delete_confirm")}</span>
<Button variant="outline" size="sm" onClick={() => { onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-destructive border-destructive/30">
<Button variant="outline" size="sm" onClick={() => { resourceStore.cancelEventBookings(event.id); onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-destructive border-destructive/30">
{t("events.delete")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
@@ -953,6 +1173,18 @@ export function EventModal({
{t("events.duplicate")}
</Button>
)}
{hasParticipants && !showDeleteConfirm && (
<>
<Button variant="ghost" size="sm" onClick={handleReplySingle} aria-label="Reply to organizer">
<Reply className="w-4 h-4 me-1" />
Reply
</Button>
<Button variant="ghost" size="sm" onClick={handleReplyAll} aria-label="Reply All">
<ReplyAll className="w-4 h-4 me-1" />
Reply All
</Button>
</>
)}
</div>
{!showDeleteConfirm && (
<Button onClick={() => setMode("edit")}>
@@ -1042,6 +1274,21 @@ export function EventModal({
setVirtualLocation,
}}
/>
{attendees.length > 0 && !allDay && (
<div className="flex items-center gap-2 mt-2">
<input
type="checkbox"
id="createVncMeeting"
checked={createVncMeeting}
onChange={(e) => setCreateVncMeeting(e.target.checked)}
className="rounded border-input"
disabled={meetingCreating}
/>
<label htmlFor="createVncMeeting" className="text-sm">
{meetingCreating ? "Creating meeting..." : "Create VNCtalk Meeting"}
</label>
</div>
)}
</div>
<div>
@@ -1057,6 +1304,44 @@ export function EventModal({
onAdd={handleAddAttendee}
onRemove={handleRemoveAttendee}
/>
{attendees.length > 0 && !allDay && (
<div className="mt-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowFreeBusy((prev) => !prev)}
className="text-xs"
>
{showFreeBusy ? (
<EyeOff className="w-3.5 h-3.5 me-1" />
) : (
<Eye className="w-3.5 h-3.5 me-1" />
)}
{showFreeBusy ? t("freeBusy.hide") : t("freeBusy.check")}
</Button>
{showFreeBusy && (
<div className="mt-3">
<FreeBusyView
participants={attendees}
startDate={(() => {
const d = new Date(`${startDate}T${startTime}:00`);
return isNaN(d.getTime()) ? new Date() : d;
})()}
endDate={(() => {
const d = new Date(`${endDate}T${endTime}:00`);
return isNaN(d.getTime()) ? addHours(new Date(`${startDate}T${startTime}:00`), 8) : d;
})()}
onTimeSelect={(start, end) => {
setStartDate(formatDateInput(start));
setStartTime(formatTimeInput(start));
setEndDate(formatDateInput(end));
setEndTime(formatTimeInput(end));
}}
/>
</div>
)}
</div>
)}
{isEdit && statusCounts && (existingParticipants.length > 0) && (
<p className="text-xs text-muted-foreground mt-1.5">
{t("participants.status_summary", {
@@ -1067,6 +1352,27 @@ export function EventModal({
)}
</div>
<div>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => setShowResources((prev) => !prev)}
className="text-xs"
>
<Building2 className="w-3.5 h-3.5 me-1" />
{showResources ? t("resources.hide") : t("resources.title")}
</Button>
{showResources && (
<div className="mt-3">
<ResourcePicker
start={allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`}
end={allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`}
/>
</div>
)}
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
@@ -1121,6 +1427,32 @@ export function EventModal({
)}
</div>
{!allDay && (
<div>
<label className="text-sm font-medium mb-1 block">
<span className="flex items-center gap-1.5">
<Globe className="w-4 h-4" />
Timezone
</span>
</label>
<select
value={timezone}
onChange={(e) => setTimezone(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"
>
{(() => {
try {
return Intl.supportedValuesOf("timeZone");
} catch {
return [timezone || "UTC"];
}
})().map((tz: string) => (
<option key={tz} value={tz}>{tz}</option>
))}
</select>
</div>
)}
{pluginConflictWarnings.length > 0 && (
<div className="space-y-1.5">
{pluginConflictWarnings.map(w => (
@@ -1304,7 +1636,7 @@ export function EventModal({
<Button
variant="outline"
size="sm"
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
onClick={() => { resourceStore.cancelEventBookings(event!.id); onDelete(event!.id, hasParticipants || undefined); onClose(); }}
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
>
{t("events.delete")}
+352
View File
@@ -0,0 +1,352 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { useTranslations } from "next-intl";
import { addMinutes, differenceInMinutes, format } from "date-fns";
import { Avatar } from "@/components/ui/avatar";
import { useAuthStore } from "@/stores/auth-store";
import { cn } from "@/lib/utils";
import { fetchFreeBusy, type FreeBusySlot, isWorkingHour as isWorkingHourFn } from "@/lib/calendar-freebusy";
export interface ResourceFreeBusyEntry {
id: string;
name: string;
availabilityMap: Map<number, FreeBusySlot["status"]>;
}
export interface FreeBusyViewProps {
participants: { name?: string; email: string }[];
startDate: Date;
endDate: Date;
onTimeSelect?: (start: Date, end: Date) => void;
resources?: ResourceFreeBusyEntry[];
}
const SLOT_MINUTES = 30;
const WORK_START_HOUR = 8;
const WORK_END_HOUR = 18;
const statusColors: Record<FreeBusySlot["status"], string> = {
free: "bg-emerald-100 dark:bg-emerald-900/40 border-emerald-200 dark:border-emerald-800",
busy: "bg-red-100 dark:bg-red-900/40 border-red-200 dark:border-red-800",
tentative: "bg-amber-100 dark:bg-amber-900/40 border-amber-200 dark:border-amber-800",
unavailable: "bg-purple-100 dark:bg-purple-900/40 border-purple-200 dark:border-purple-800",
unknown: "bg-muted border-muted-foreground/20",
};
const statusHoverColors: Record<FreeBusySlot["status"], string> = {
free: "hover:bg-emerald-200 dark:hover:bg-emerald-800/60",
busy: "hover:bg-red-200 dark:hover:bg-red-800/60",
tentative: "hover:bg-amber-200 dark:hover:bg-amber-800/60",
unavailable: "hover:bg-purple-200 dark:hover:bg-purple-800/60",
unknown: "hover:bg-muted-foreground/20",
};
function clampToSlot(d: Date): Date {
const clone = new Date(d);
clone.setSeconds(0, 0);
const mins = clone.getMinutes();
const remainder = mins % SLOT_MINUTES;
if (remainder !== 0) {
clone.setMinutes(mins - remainder, 0, 0);
}
return clone;
}
function buildHourSlots(start: Date, end: Date): { label: string; slots: FreeBusySlot[] }[] {
const hours: { label: string; slots: FreeBusySlot[] }[] = [];
let cursor = clampToSlot(start);
while (cursor < end) {
const hourEnd = new Date(cursor);
hourEnd.setHours(hourEnd.getHours() + 1, 0, 0, 0);
const hourSlots: FreeBusySlot[] = [];
let slotCursor = new Date(cursor);
while (slotCursor < hourEnd && slotCursor < end) {
const slotEnd = addMinutes(slotCursor, SLOT_MINUTES);
hourSlots.push({
start: new Date(slotCursor),
end: slotEnd > end ? new Date(end) : slotEnd,
status: "unknown",
});
slotCursor = slotEnd;
}
hours.push({ label: format(cursor, "HH:mm"), slots: hourSlots });
cursor = hourEnd;
}
return hours;
}
function isWorkingHour(hour: number): boolean {
return isWorkingHourFn(hour, WORK_START_HOUR, WORK_END_HOUR);
}
export function FreeBusyView({
participants,
startDate,
endDate,
onTimeSelect,
resources = [],
}: FreeBusyViewProps) {
const t = useTranslations("calendar");
const client = useAuthStore((s) => s.client);
const [freeBusyData, setFreeBusyData] = useState<Map<string, FreeBusySlot[]> | null>(null);
const [loading, setLoading] = useState(false);
const [hoveredSlot, setHoveredSlot] = useState<{
participant: string;
slotIndex: number;
} | null>(null);
const hourSlots = useMemo(() => buildHourSlots(startDate, endDate), [startDate, endDate]);
const totalHalfHourSlots = useMemo(() => {
let c = 0;
for (const h of hourSlots) c += h.slots.length;
return c;
}, [hourSlots]);
const now = new Date();
const showNowLine =
now >= startDate && now <= endDate;
const nowPositionPercent = showNowLine
? Math.max(0, Math.min(100, (differenceInMinutes(now, startDate) / differenceInMinutes(endDate, startDate)) * 100))
: null;
useEffect(() => {
if (!client || participants.length === 0) return;
let cancelled = false;
setLoading(true);
fetchFreeBusy(client, participants, startDate, endDate)
.then((data) => {
if (!cancelled) {
setFreeBusyData(data);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [client, participants, startDate, endDate]);
const handleSlotClick = useCallback(
(slot: FreeBusySlot) => {
if (slot.status === "free" && onTimeSelect) {
onTimeSelect(new Date(slot.start), new Date(slot.end));
}
},
[onTimeSelect]
);
const timezone = useMemo(
() => Intl.DateTimeFormat().resolvedOptions().timeZone,
[]
);
if (participants.length === 0) {
return (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("freeBusy.no_participants")}
</p>
);
}
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="text-xs text-muted-foreground">
{t("freeBusy.timezone")}: {timezone}
</div>
{loading && (
<div className="text-xs text-muted-foreground animate-pulse">
{t("freeBusy.loading")}
</div>
)}
</div>
<div className="overflow-auto border border-border rounded-lg">
<div className="min-w-max" style={{ minWidth: totalHalfHourSlots * 24 + 200 }}>
<table className="w-full border-collapse text-xs">
<thead>
<tr>
<th className="sticky left-0 z-10 bg-background border-b border-r border-border px-3 py-2 text-left w-[180px] min-w-[180px]">
{t("participants.title")}
</th>
{hourSlots.map((hour, i) => (
<th
key={i}
colSpan={hour.slots.length}
className={cn(
"border-b border-r border-border px-1 py-2 text-center font-medium",
isWorkingHour(new Date(hour.slots[0]?.start).getHours())
? "bg-muted/50"
: "bg-muted/20"
)}
>
{hour.label}
</th>
))}
</tr>
</thead>
<tbody>
{participants.map((p) => {
const key = p.email.toLowerCase();
const slots = freeBusyData?.get(key);
return (
<tr key={key} className="border-b border-border">
<td className="sticky left-0 z-10 bg-background border-r border-border px-3 py-2">
<div className="flex items-center gap-2">
<Avatar
name={p.name}
email={p.email}
size="sm"
className="shrink-0"
/>
<div className="min-w-0">
<div className="font-medium truncate">
{p.name || p.email}
</div>
{p.name && (
<div className="text-[10px] text-muted-foreground truncate">
{p.email}
</div>
)}
</div>
</div>
</td>
{hourSlots.map((hour) =>
hour.slots.map((hourSlot, si) => {
const globalSlotIndex =
hourSlots
.slice(0, hourSlots.indexOf(hour))
.reduce((acc, h) => acc + h.slots.length, 0) + si;
const slot = slots?.[globalSlotIndex];
const status = slot?.status ?? "unknown";
const isFree = status === "free";
const isHovered =
hoveredSlot?.participant === key &&
hoveredSlot?.slotIndex === globalSlotIndex;
return (
<td
key={si}
className={cn(
"border-r border-border py-1 text-center relative cursor-default transition-colors",
statusColors[status],
isFree && statusHoverColors[status],
isFree && "cursor-pointer",
isHovered && "ring-1 ring-inset ring-primary/50",
isWorkingHour(new Date(hourSlot.start).getHours())
? ""
: "opacity-70"
)}
title={format(hourSlot.start, "HH:mm")}
onClick={() =>
isFree ? handleSlotClick(slot!) : undefined
}
onMouseEnter={() =>
setHoveredSlot({
participant: key,
slotIndex: globalSlotIndex,
})
}
onMouseLeave={() => setHoveredSlot(null)}
>
{status === "free" && (
<span className="block w-full h-full">&nbsp;</span>
)}
</td>
);
})
)}
</tr>
);
})}
{resources.map((res) => (
<tr key={`res-${res.id}`} className="border-b border-border">
<td className="sticky left-0 z-10 bg-background border-r border-border px-3 py-2">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center shrink-0">
<span className="text-[10px] font-bold text-blue-600 dark:text-blue-400">
R
</span>
</div>
<div className="min-w-0">
<div className="font-medium truncate text-sm">
{res.name}
</div>
</div>
</div>
</td>
{hourSlots.map((hour) =>
hour.slots.map((hourSlot, si) => {
const globalSlotIndex =
hourSlots
.slice(0, hourSlots.indexOf(hour))
.reduce((acc, h) => acc + h.slots.length, 0) + si;
const status = res.availabilityMap.get(globalSlotIndex) ?? "unknown";
const isFree = status === "free";
return (
<td
key={si}
className={cn(
"border-r border-border py-1 text-center relative cursor-default transition-colors",
statusColors[status],
isFree && "cursor-pointer",
isWorkingHour(new Date(hourSlot.start).getHours())
? ""
: "opacity-70"
)}
title={`${res.name} - ${format(hourSlot.start, "HH:mm")}`}
>
{status === "free" && (
<span className="block w-full h-full">&nbsp;</span>
)}
</td>
);
})
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
{showNowLine && nowPositionPercent !== null && (
<div
className="absolute pointer-events-none z-20"
style={{
left: `calc(180px + ${nowPositionPercent}% * (1 - 180px / ${totalHalfHourSlots * 24 + 200}))`,
}}
/>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
<span className="inline-flex items-center gap-1">
<span className="w-3 h-3 rounded border border-emerald-200 dark:border-emerald-800 bg-emerald-100 dark:bg-emerald-900/40" />
{t("freeBusy.free")}
</span>
<span className="inline-flex items-center gap-1">
<span className="w-3 h-3 rounded border border-red-200 dark:border-red-800 bg-red-100 dark:bg-red-900/40" />
{t("freeBusy.busy")}
</span>
<span className="inline-flex items-center gap-1">
<span className="w-3 h-3 rounded border border-amber-200 dark:border-amber-800 bg-amber-100 dark:bg-amber-900/40" />
{t("freeBusy.tentative")}
</span>
<span className="inline-flex items-center gap-1">
<span className="w-3 h-3 rounded border border-purple-200 dark:border-purple-800 bg-purple-100 dark:bg-purple-900/40" />
{t("freeBusy.unavailable")}
</span>
<span className="inline-flex items-center gap-1">
<span className="w-3 h-3 rounded border border-muted-foreground/20 bg-muted" />
{t("freeBusy.unknown")}
</span>
</div>
</div>
);
}
@@ -0,0 +1,196 @@
"use client";
import { useState, useMemo, useCallback, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
format,
isToday,
isSameDay,
addMonths,
subMonths,
isSameMonth,
} from "date-fns";
import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { useAuthStore } from "@/stores/auth-store";
import { getEventDayBounds } from "@/lib/calendar-utils";
interface MiniCalendarDashletProps {
events?: { date: string; color?: string }[];
onDayClick?: (date: Date) => void;
selectedDate?: Date;
}
const ALL_DAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
export function MiniCalendarDashlet({
events: propEvents,
onDayClick,
selectedDate: propSelectedDate,
}: MiniCalendarDashletProps) {
const t = useTranslations("calendar");
const router = useRouter();
const firstDayOfWeek = useSettingsStore((s) => s.firstDayOfWeek);
const storeSelectedDate = useCalendarStore((s) => s.selectedDate);
const storeEvents = useCalendarStore((s) => s.events);
const selectedDate = propSelectedDate ?? storeSelectedDate;
const client = useAuthStore((s) => s.client);
const [displayMonth, setDisplayMonth] = useState(() => new Date());
const weekStartsOn = useMemo(() => {
if (firstDayOfWeek === 0) return 0 as const;
if (firstDayOfWeek === 6) return 6 as const;
return 1 as const;
}, [firstDayOfWeek]);
useEffect(() => {
if (!client) return;
const start = format(startOfMonth(displayMonth), "yyyy-MM-dd'T'00:00:00");
const end = format(endOfMonth(displayMonth), "yyyy-MM-dd'T'23:59:59");
const { dateRange } = useCalendarStore.getState();
if (dateRange?.start === start && dateRange?.end === end) return;
useCalendarStore.getState().fetchEvents(client, start, end);
}, [displayMonth, client]);
const days = useMemo(() => {
const monthStart = startOfMonth(displayMonth);
const monthEnd = endOfMonth(displayMonth);
const calStart = startOfWeek(monthStart, { weekStartsOn });
const calEnd = endOfWeek(monthEnd, { weekStartsOn });
return eachDayOfInterval({ start: calStart, end: calEnd });
}, [displayMonth, weekStartsOn]);
const eventDates = useMemo(() => {
const set = new Set<string>();
for (const e of storeEvents) {
try {
const { startDay, endDay } = getEventDayBounds(e);
const cursor = new Date(startDay);
while (cursor <= endDay) {
set.add(format(cursor, "yyyy-MM-dd"));
cursor.setDate(cursor.getDate() + 1);
}
} catch {
/* skip */
}
}
if (propEvents) {
for (const e of propEvents) {
set.add(e.date);
}
}
return set;
}, [storeEvents, propEvents]);
const dayHeaders = useMemo(
() => [...ALL_DAY_KEYS.slice(weekStartsOn), ...ALL_DAY_KEYS.slice(0, weekStartsOn)],
[weekStartsOn],
);
const handlePrevMonth = useCallback(() => {
setDisplayMonth((prev) => subMonths(prev, 1));
}, []);
const handleNextMonth = useCallback(() => {
setDisplayMonth((prev) => addMonths(prev, 1));
}, []);
const handleGoToToday = useCallback(() => {
setDisplayMonth(new Date());
}, []);
const handleDayClick = useCallback(
(day: Date) => {
useCalendarStore.getState().setSelectedDate(day);
if (onDayClick) {
onDayClick(day);
} else {
router.push("/calendar");
}
},
[onDayClick, router],
);
return (
<div className="select-none px-2 py-1.5">
<div className="flex items-center justify-between mb-1">
<button
onClick={handlePrevMonth}
className="p-0.5 rounded hover:bg-muted transition-colors"
aria-label={t("nav_prev")}
>
<ChevronLeft className="w-3.5 h-3.5 text-muted-foreground" />
</button>
<button
onClick={handleGoToToday}
className="text-xs font-medium hover:bg-muted px-1.5 py-0.5 rounded transition-colors"
title={t("views.today")}
>
{format(displayMonth, "MMM yyyy")}
</button>
<button
onClick={handleNextMonth}
className="p-0.5 rounded hover:bg-muted transition-colors"
aria-label={t("nav_next")}
>
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div>
<div className="grid grid-cols-7 mb-0.5">
{dayHeaders.map((dh) => (
<div
key={dh}
className="text-center text-[9px] font-medium text-muted-foreground py-0.5"
>
{t(`days.${dh}`)}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-0">
{days.map((day) => {
const inMonth = isSameMonth(day, displayMonth);
const selected = isSameDay(day, selectedDate);
const today = isToday(day);
const dateStr = format(day, "yyyy-MM-dd");
const hasEvent = eventDates.has(dateStr);
const dotColor =
propEvents?.find((e) => e.date === dateStr && e.color)?.color ??
undefined;
return (
<button
key={day.toISOString()}
onClick={() => handleDayClick(day)}
className={cn(
"relative flex items-center justify-center w-6 h-6 text-[11px] rounded-full transition-colors mx-auto",
!inMonth && "text-muted-foreground/30",
inMonth && !selected && "hover:bg-muted",
today && !selected && "font-bold text-primary",
selected && "bg-primary text-primary-foreground",
)}
>
{day.getDate()}
{hasEvent && !selected && (
<span
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary"
style={dotColor ? { backgroundColor: dotColor } : undefined}
/>
)}
</button>
);
})}
</div>
</div>
);
}
+243
View File
@@ -0,0 +1,243 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { useResourceStore } from "@/stores/resource-store";
import type { Resource } from "@/lib/resources/client";
import {
Building2,
Car,
Wrench,
Box,
MapPin,
Users,
Search,
X,
Check,
} from "lucide-react";
interface ResourcePickerProps {
start?: string;
end?: string;
compact?: boolean;
}
const typeIcons: Record<Resource["type"], typeof Building2> = {
room: Building2,
vehicle: Car,
equipment: Wrench,
other: Box,
};
type TypeFilter = "all" | Resource["type"];
export function ResourcePicker({ start, end, compact = false }: ResourcePickerProps) {
const t = useTranslations("calendar");
const {
resources,
selectedResources,
isLoading,
fetchResources,
searchResources,
toggleResource,
deselectResource,
clearSelection,
} = useResourceStore();
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const [query, setQuery] = useState("");
const [availabilityMap, setAvailabilityMap] = useState<Record<string, "available" | "conflict" | "unknown">>({});
useEffect(() => {
fetchResources();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filtered = useMemo(() => {
let list = typeFilter === "all" ? resources : resources.filter((r) => r.type === typeFilter);
if (query.trim()) {
list = searchResources(query).filter((r) => typeFilter === "all" || r.type === typeFilter);
}
return list;
}, [resources, typeFilter, query, searchResources]);
useEffect(() => {
if (!start || !end) return;
let cancelled = false;
const checkAll = async () => {
const map: Record<string, "available" | "conflict" | "unknown"> = {};
for (const resource of filtered) {
try {
const params = new URLSearchParams({ start, end });
const { apiFetch } = await import("@/lib/browser-navigation");
const res = await apiFetch(
`/api/resources/${resource.id}/availability?${params.toString()}`
);
if (res.ok) {
const data = await res.json();
map[resource.id] = data.available ? "available" : "conflict";
} else {
map[resource.id] = "unknown";
}
} catch {
map[resource.id] = "unknown";
}
}
if (!cancelled) setAvailabilityMap(map);
};
checkAll();
return () => {
cancelled = true;
};
}, [filtered, start, end]);
const filters: { key: TypeFilter; label: string }[] = [
{ key: "all", label: t("resources.filter_all") },
{ key: "room", label: t("resources.type_room") },
{ key: "vehicle", label: t("resources.type_vehicle") },
{ key: "equipment", label: t("resources.type_equipment") },
{ key: "other", label: t("resources.type_other") },
];
return (
<div className="space-y-3">
<div className="flex items-center gap-2 mb-3 flex-wrap">
{filters.map((f) => (
<button
key={f.key}
type="button"
onClick={() => setTypeFilter(f.key)}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium transition-colors",
typeFilter === f.key
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
)}
>
{f.label}
</button>
))}
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("resources.search_placeholder")}
className="pl-8"
/>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin w-5 h-5 border-2 border-primary border-t-transparent rounded-full" />
</div>
) : filtered.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("resources.no_resources")}
</p>
) : (
<div className={cn(
"border border-border rounded-lg divide-y divide-border",
!compact && "max-h-64 overflow-y-auto"
)}>
{filtered.map((resource) => {
const TypeIcon = typeIcons[resource.type];
const isSelected = selectedResources.some((r) => r.id === resource.id);
const avail = availabilityMap[resource.id] || "unknown";
return (
<button
key={resource.id}
type="button"
onClick={() => toggleResource(resource)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors",
isSelected
? "bg-primary/10 hover:bg-primary/15"
: "hover:bg-muted/50"
)}
>
<span className="relative flex-shrink-0">
<TypeIcon className="w-5 h-5 text-muted-foreground" />
{start && end && (
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-background",
avail === "available" && "bg-emerald-500",
avail === "conflict" && "bg-red-500",
avail === "unknown" && "bg-muted-foreground/40"
)}
/>
)}
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{resource.name}</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{resource.location && (
<span className="inline-flex items-center gap-0.5">
<MapPin className="w-3 h-3" />
{resource.location}
</span>
)}
{resource.capacity != null && resource.capacity > 0 && (
<span className="inline-flex items-center gap-0.5">
<Users className="w-3 h-3" />
{resource.capacity}
</span>
)}
</div>
</div>
<span
className={cn(
"w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors",
isSelected
? "bg-primary border-primary text-primary-foreground"
: "border-muted-foreground/40"
)}
>
{isSelected && <Check className="w-3.5 h-3.5" />}
</span>
</button>
);
})}
</div>
)}
{selectedResources.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{selectedResources.map((resource) => (
<span
key={resource.id}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary px-2.5 py-1 text-xs font-medium"
>
{resource.name}
<button
type="button"
onClick={() => deselectResource(resource.id)}
className="ml-0.5 rounded-full p-0.5 hover:bg-primary/20 transition-colors"
aria-label={t("resources.remove", { name: resource.name })}
>
<X className="w-3 h-3" />
</button>
</span>
))}
{selectedResources.length > 0 && (
<button
type="button"
onClick={clearSelection}
className="text-xs text-muted-foreground hover:text-foreground ml-1"
>
{t("resources.clear_all")}
</button>
)}
</div>
)}
</div>
);
}
+276 -27
View File
@@ -2,26 +2,39 @@
import { useState, useRef, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Upload, FileText, AlertTriangle, X, Check } from "lucide-react";
import { Upload, FileText, AlertTriangle, X, Check, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { parseVCard, detectDuplicates } from "@/lib/vcard";
import type { ContactCard } from "@/lib/jmap/types";
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import {
parseCSV,
autoMapColumns,
mapRowToContact,
detectDuplicatesByEmail,
type CsvColumnMapping,
type CsvParseResult,
} from "@/lib/contact-csv-import";
type FileType = "vcf" | "csv" | null;
interface ContactImportDialogProps {
existingContacts: ContactCard[];
addressBooks?: AddressBook[];
onImport: (contacts: ContactCard[]) => Promise<number>;
onClose: () => void;
}
export function ContactImportDialog({
existingContacts,
addressBooks,
onImport,
onClose,
}: ContactImportDialogProps) {
const t = useTranslations("contacts");
const fileRef = useRef<HTMLInputElement>(null);
const [fileType, setFileType] = useState<FileType>(null);
const [parsed, setParsed] = useState<ContactCard[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [duplicates, setDuplicates] = useState<Map<number, string>>(new Map());
@@ -29,41 +42,111 @@ export function ContactImportDialog({
const [result, setResult] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [csvData, setCsvData] = useState<CsvParseResult | null>(null);
const [mapping, setMapping] = useState<CsvColumnMapping | null>(null);
const [targetBookId, setTargetBookId] = useState("");
const [showPreview, setShowPreview] = useState(false);
const ALLOWED_ACCEPT = ".vcf,.vcard,.csv,text/csv,text/vcard";
const books = addressBooks || [];
const defaultBookId =
books.find((b) => b.isDefault)?.id || books[0]?.id || "";
const effectiveBookId = targetBookId || defaultBookId;
const bookOptions = books.map((b) => ({
value: b.id,
label: b.name,
}));
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setError(null);
setResult(null);
setFileType(null);
setParsed([]);
setSelected(new Set());
setDuplicates(new Map());
setCsvData(null);
setMapping(null);
setShowPreview(false);
setTargetBookId("");
if (file.size > 5 * 1024 * 1024) {
if (file.size > 10 * 1024 * 1024) {
setError(t("import.file_too_large"));
return;
}
const name = file.name.toLowerCase();
try {
const text = await file.text();
const contacts = parseVCard(text);
if (name.endsWith(".csv") || file.type === "text/csv") {
setFileType("csv");
const text = await file.text();
const result = parseCSV(text);
if (contacts.length === 0) {
setError(t("import.no_contacts"));
return;
if (result.rows.length === 0) {
setError(t("import.no_contacts"));
return;
}
setCsvData(result);
setMapping(autoMapColumns(result.headers));
setTargetBookId(defaultBookId);
} else {
setFileType("vcf");
const text = await file.text();
const contacts = parseVCard(text);
if (contacts.length === 0) {
setError(t("import.no_contacts"));
return;
}
const dupes = detectDuplicates(existingContacts, contacts);
setParsed(contacts);
setDuplicates(dupes);
const initialSelected = new Set<number>();
contacts.forEach((_, idx) => {
if (!dupes.has(idx)) initialSelected.add(idx);
});
setSelected(initialSelected);
}
const dupes = detectDuplicates(existingContacts, contacts);
setParsed(contacts);
setDuplicates(dupes);
const initialSelected = new Set<number>();
contacts.forEach((_, idx) => {
if (!dupes.has(idx)) initialSelected.add(idx);
});
setSelected(initialSelected);
} catch (error) {
console.error('Failed to parse vCard:', error);
} catch (err) {
console.error("Failed to parse file:", err);
setError(t("import.parse_error"));
}
}, [existingContacts, t]);
}, [existingContacts, t, defaultBookId]);
const applyCsvMapping = useCallback(() => {
if (!csvData || !mapping) return;
const bookIds = effectiveBookId ? { [effectiveBookId]: true } : {};
const contacts: ContactCard[] = [];
for (const row of csvData.rows) {
const contact = mapRowToContact(row, mapping, bookIds);
if (contact) contacts.push(contact);
}
if (contacts.length === 0) {
setError(t("import.no_contacts"));
return;
}
const dupes = detectDuplicatesByEmail(existingContacts, contacts);
setParsed(contacts);
setDuplicates(dupes);
const initialSelected = new Set<number>();
contacts.forEach((_, idx) => {
if (!dupes.has(idx)) initialSelected.add(idx);
});
setSelected(initialSelected);
setShowPreview(true);
}, [csvData, mapping, effectiveBookId, existingContacts, t]);
const toggleSelect = (idx: number) => {
const next = new Set(selected);
@@ -91,14 +174,160 @@ export function ContactImportDialog({
try {
const count = await onImport(toImport);
setResult(count);
} catch (error) {
console.error('Failed to import contacts:', error);
} catch (err) {
console.error("Failed to import contacts:", err);
setError(t("import.failed"));
} finally {
setIsImporting(false);
}
};
const renderCsvMapping = () => {
if (!csvData || !mapping) return null;
const fields: Array<{ key: keyof CsvColumnMapping; label: string }> = [
{ key: "firstName", label: t("import.csv_first_name") },
{ key: "lastName", label: t("import.csv_last_name") },
{ key: "email", label: t("import.csv_email") },
{ key: "phone", label: t("import.csv_phone") },
{ key: "company", label: t("import.csv_company") },
{ key: "jobTitle", label: t("import.csv_job_title") },
{ key: "address", label: t("import.csv_address") },
{ key: "city", label: t("import.csv_city") },
{ key: "region", label: t("import.csv_region") },
{ key: "postcode", label: t("import.csv_postcode") },
{ key: "country", label: t("import.csv_country") },
{ key: "website", label: t("import.csv_website") },
{ key: "note", label: t("import.csv_note") },
{ key: "nickname", label: t("import.csv_nickname") },
];
const headerOptions = csvData.headers.map((h, i) => ({
value: String(i),
label: h,
}));
return (
<div className="space-y-3">
<p className="text-sm font-medium">{t("import.csv_map_columns")}</p>
<div className="grid grid-cols-2 gap-2 max-h-64 overflow-y-auto">
{fields.map(({ key, label }) => (
<div key={key} className="flex items-center gap-2">
<label className="text-xs text-muted-foreground w-24 flex-shrink-0 truncate">
{label}
</label>
<select
value={mapping[key] >= 0 ? String(mapping[key]) : "-1"}
onChange={(e) => {
setMapping((prev) => prev ? {
...prev,
[key]: parseInt(e.target.value, 10),
} : null);
}}
className="flex-1 px-2 py-1 text-xs rounded border border-border bg-muted text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
dir="auto"
>
<option value="-1">{t("import.csv_ignore")}</option>
{headerOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
))}
</div>
{books.length > 0 && (
<div className="flex items-center gap-2 pt-2">
<label className="text-xs text-muted-foreground flex-shrink-0">
{t("import.csv_address_book")}
</label>
<select
value={effectiveBookId}
onChange={(e) => setTargetBookId(e.target.value)}
className="px-2 py-1 text-xs rounded border border-border bg-muted text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
dir="auto"
>
{bookOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
<div className="flex gap-2 pt-1">
<Button size="sm" onClick={applyCsvMapping}>
{t("import.csv_preview")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setFileType(null);
setCsvData(null);
setMapping(null);
if (fileRef.current) fileRef.current.value = "";
}}
>
{t("form.cancel")}
</Button>
</div>
</div>
);
};
const renderCsvPreview = () => {
if (!csvData || !mapping || !showPreview) return null;
const previewRows = csvData.rows.slice(0, 5);
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{t("import.csv_preview_title", { count: parsed.length })}</p>
<Button
variant="ghost"
size="sm"
onClick={() => setShowPreview(false)}
>
{t("import.csv_back")}
</Button>
</div>
<div className="border rounded-md overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="bg-muted">
{csvData.headers.map((h, i) => (
<th key={i} className="px-2 py-1.5 text-start font-medium text-muted-foreground whitespace-nowrap">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{previewRows.map((row, ri) => (
<tr key={ri} className="border-t border-border">
{row.map((cell, ci) => (
<td key={ci} className="px-2 py-1.5 truncate max-w-[150px]">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={applyCsvMapping}>
{t("import.csv_load_all")}
</Button>
</div>
</div>
);
};
return (
<div className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
@@ -119,12 +348,12 @@ export function ContactImportDialog({
{t("import.close")}
</Button>
</div>
) : parsed.length === 0 ? (
) : fileType === null ? (
<>
<input
ref={fileRef}
type="file"
accept=".vcf,.vcard"
accept={ALLOWED_ACCEPT}
onChange={handleFileChange}
className="hidden"
/>
@@ -141,7 +370,7 @@ export function ContactImportDialog({
>
<Upload className="w-8 h-8" />
<p className="text-sm font-medium">{t("import.drop_hint")}</p>
<p className="text-xs">{t("import.file_types")}</p>
<p className="text-xs">{t("import.file_types_csv")}</p>
</button>
{error && (
@@ -151,6 +380,10 @@ export function ContactImportDialog({
</div>
)}
</>
) : fileType === "csv" && csvData && !showPreview ? (
renderCsvMapping()
) : fileType === "csv" && csvData && showPreview ? (
renderCsvPreview()
) : (
<>
{error && (
@@ -217,7 +450,23 @@ export function ContactImportDialog({
)}
</div>
{parsed.length > 0 && result === null && (
{parsed.length > 0 && result === null && fileType !== "csv" && (
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
<p className="text-sm text-muted-foreground">
{t("import.selected", { count: selected.size })}
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose} disabled={isImporting}>
{t("form.cancel")}
</Button>
<Button onClick={handleImport} disabled={isImporting || selected.size === 0}>
{isImporting ? t("import.importing") : t("import.import_button")}
</Button>
</div>
</div>
)}
{fileType === "csv" && showPreview && parsed.length > 0 && result === null && (
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
<p className="text-sm text-muted-foreground">
{t("import.selected", { count: selected.size })}
+69 -3
View File
@@ -1,13 +1,14 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo, useState, useCallback } from "react";
import { useTranslations, useLocale } from "next-intl";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu } from "lucide-react";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, Pencil } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
import { ContactContextMenu } from "./contact-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
import { cn } from "@/lib/utils";
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
@@ -142,6 +143,63 @@ export function ContactList({
const density = useSettingsStore((state) => state.density);
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
// Radial menu state
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
const [radialMenuContact, setRadialMenuContact] = useState<ContactCard | null>(null);
const openRadialMenu = useCallback((e: React.MouseEvent, contact: ContactCard) => {
e.preventDefault();
setRadialMenuPos({ x: e.clientX, y: e.clientY });
setRadialMenuContact(contact);
setRadialMenuOpen(true);
}, []);
const closeRadialMenu = useCallback(() => {
setRadialMenuOpen(false);
}, []);
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
if (!radialMenuContact) return [];
const c = radialMenuContact;
const items: RadialMenuItem[] = [];
items.push({
id: "edit",
icon: <Pencil className="w-5 h-5" />,
label: t("edit"),
onClick: () => { onEditContact(c.id); },
});
items.push({
id: "delete",
icon: <Trash2 className="w-5 h-5" />,
label: t("delete"),
onClick: () => { onDeleteContact(c); },
destructive: true,
});
if (c.emails && Object.keys(c.emails).length > 0) {
const contactEmails = c.emails;
items.push({
id: "send-email",
icon: <Mail className="w-5 h-5" />,
label: t("send_email"),
onClick: () => {
const values = Object.values(contactEmails);
if (values[0]?.address) {
window.location.href = `mailto:${values[0].address}`;
}
},
});
}
items.push({
id: "export",
icon: <Download className="w-5 h-5" />,
label: t("export"),
onClick: () => { onBulkExport(); },
});
return items;
}, [radialMenuContact, t, onEditContact, onDeleteContact, onBulkExport]);
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
const activeFilters = countActiveFilters(filters);
@@ -571,7 +629,7 @@ export function ContactList({
e.stopPropagation();
onToggleSelection(contact.id);
}}
onContextMenu={(e, c) => openContextMenu(e, c)}
onContextMenu={(e, c) => { openContextMenu(e, c); openRadialMenu(e, c); }}
/>
);
return groupByLetter ? (
@@ -592,6 +650,14 @@ export function ContactList({
)}
</div>
{/* Radial Action Menu */}
<RadialMenu
items={radialMenuItems}
isOpen={radialMenuOpen}
position={radialMenuPos}
onClose={closeRadialMenu}
/>
{contextMenu.data && (
<ContactContextMenu
contact={contextMenu.data}
+111 -12
View File
@@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users } from "lucide-react";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users, PenLine } from "lucide-react";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
@@ -24,6 +24,7 @@ import { useIdentityStore } from "@/stores/identity-store";
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
import { useAccountStore } from "@/stores/account-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useSignatureStore } from "@/stores/signature-store";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { Avatar } from "@/components/ui/avatar";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
@@ -298,6 +299,34 @@ export function EmailComposer({
const { isFeatureEnabled } = usePolicyStore();
const templatesEnabled = isFeatureEnabled('templatesEnabled');
const {
signatures,
defaultSignatureId,
replySignatureId,
getSignatureById,
getIdentityDefaultSignatureId,
getIdentityReplySignatureId,
} = useSignatureStore();
const resolveStoreSignatureId = (): string | null => {
const perIdentityId = selectedIdentityId || initialData?.selectedIdentityId || null;
if (mode === 'compose') {
if (perIdentityId) {
const id = getIdentityDefaultSignatureId(perIdentityId);
if (id) return id;
}
return defaultSignatureId;
}
if (perIdentityId) {
const id = getIdentityReplySignatureId(perIdentityId);
if (id) return id;
}
return replySignatureId ?? defaultSignatureId;
};
const [selectedSignatureId, setSelectedSignatureId] = useState<string | null>(resolveStoreSignatureId);
const selectedSignature = selectedSignatureId ? getSignatureById(selectedSignatureId) ?? null : null;
// The signature identity used when embedding the signature into the initial
// body for "above quote" mode. Mirrors the signatureIdentity derivation
// below, but uses initialData (or primary) since selectedIdentityId state
@@ -509,17 +538,19 @@ export function EmailComposer({
// requests with the same draftId. See bug #303.
const inflightSaveRef = useRef<Promise<string | null> | null>(null);
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
if (mode === 'forward' && replyTo?.attachments?.length) {
return replyTo.attachments
if (replyTo?.attachments?.length) {
let atts = replyTo.attachments;
if (mode === 'forward') {
// Skip inline cid-referenced images - they're embedded in the forwarded HTML body
// (matches the viewer's hideInlineImageAttachments logic).
.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
.map(att => ({
name: att.name || 'attachment',
type: att.type || 'application/octet-stream',
size: att.size,
blobId: att.blobId,
}));
atts = atts.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')));
}
return atts.map(att => ({
name: att.name || 'attachment',
type: att.type || 'application/octet-stream',
size: att.size,
blobId: att.blobId,
}));
}
return [];
});
@@ -592,6 +623,7 @@ export function EmailComposer({
// when the user switches identity in "above quote" mode without rebuilding
// the whole body (which would lose user edits to the surrounding draft).
const editorRef = useRef<Editor | null>(null);
const [editorReady, setEditorReady] = useState(false);
const prevSignatureIdentityIdRef = useRef<string | null | undefined>(signatureIdentity?.id);
const prevSignatureSeparatorRef = useRef<boolean>(signatureSeparatorEnabled);
@@ -668,6 +700,28 @@ export function EmailComposer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
const sigInsertedRef = useRef(false);
useEffect(() => {
if (plainTextMode) return;
const editor = editorRef.current;
if (!editor) return;
if (!selectedSignatureId) return;
if (sigInsertedRef.current) return;
const sig = getSignatureById(selectedSignatureId);
if (!sig) return;
const currentHtml = serializeEditorContent(editor);
if (currentHtml.includes(sig.body)) {
sigInsertedRef.current = true;
return;
}
sigInsertedRef.current = true;
if (mode === 'compose') {
editor.chain().focus('end').insertContent(`<p></p>${sig.body}`).run();
} else if ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') {
editor.chain().focus('start').insertContent(sig.body).run();
}
}, [selectedSignatureId, plainTextMode, mode, signaturePosition, getSignatureById, editorReady]);
useEffect(() => {
const handleClickOutsideSendMenu = (event: MouseEvent) => {
if (!sendMenuRef.current?.contains(event.target as Node)) {
@@ -2498,7 +2552,7 @@ export function EmailComposer({
onImageUpload={handleImageUpload}
placeholder={t('body_placeholder')}
hasError={validationErrors.body}
onEditorReady={(ed) => { editorRef.current = ed; }}
onEditorReady={(ed) => { editorRef.current = ed; setEditorReady(true); }}
/>
</div>
)}
@@ -2657,8 +2711,53 @@ export function EmailComposer({
<PluginSlot name="composer-toolbar" />
</div>
{/* Right side - Discard + Send (desktop) */}
{/* Right side - Signature selector + Discard + Send (desktop) */}
<div className="flex items-center gap-2">
{signatures.length > 0 && (
<div className="relative hidden md:inline-flex">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
if (!editorRef.current) return;
const sig = selectedSignature;
if (sig) {
editorRef.current.chain().focus().insertContent(sig.body).run();
}
}}
title={t('insert_signature')}
className="h-8 px-2 text-xs gap-1"
disabled={!selectedSignature}
>
<PenLine className="w-4 h-4" />
{selectedSignature?.name ?? t('no_signature')}
</Button>
<select
value={selectedSignatureId ?? ''}
onChange={(e) => {
const id = e.target.value;
setSelectedSignatureId(id || null);
if (id && editorRef.current) {
const sig = getSignatureById(id);
if (sig) {
editorRef.current.chain().focus().insertContent(sig.body).run();
}
}
}}
className="absolute inset-0 opacity-0 cursor-pointer"
title={t('select_signature')}
aria-label={t('select_signature')}
>
<option value="">{t('no_signature')}</option>
{signatures.map((sig) => (
<option key={sig.id} value={sig.id}>
{sig.name}
</option>
))}
</select>
</div>
)}
<button
type="button"
onClick={handleClose}
+106 -1
View File
@@ -15,6 +15,8 @@ import { useUIStore } from "@/stores/ui-store";
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
import { Reply, ReplyAll, Forward, Star, Archive, FolderOpen } from "lucide-react";
import { useTranslations } from "next-intl";
import { useVirtualizer } from "@tanstack/react-virtual";
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
@@ -141,6 +143,101 @@ export function EmailList({
const contextMenuEmail = contextMenu.data
? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data
: null;
// Radial menu state
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
const [radialMenuEmail, setRadialMenuEmail] = useState<Email | null>(null);
const openRadialMenu = useCallback((e: React.MouseEvent, email: Email) => {
e.preventDefault();
setRadialMenuPos({ x: e.clientX, y: e.clientY });
setRadialMenuEmail(email);
setRadialMenuOpen(true);
}, []);
const closeRadialMenu = useCallback(() => {
setRadialMenuOpen(false);
}, []);
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
if (!radialMenuEmail) return [];
const email = radialMenuEmail;
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const act = (fn?: (email: Email) => void) => fn ? () => { fn(email); } : undefined;
const items: RadialMenuItem[] = [];
if (onReply) {
items.push({
id: "reply",
icon: <Reply className="w-5 h-5" />,
label: t("../context_menu.reply"),
onClick: () => { act(onReply)!(); },
});
}
if (onReplyAll) {
items.push({
id: "reply-all",
icon: <ReplyAll className="w-5 h-5" />,
label: t("../context_menu.reply_all"),
onClick: () => { act(onReplyAll)!(); },
});
}
if (onForward) {
items.push({
id: "forward",
icon: <Forward className="w-5 h-5" />,
label: t("../context_menu.forward"),
onClick: () => { act(onForward)!(); },
});
}
if (onToggleStar) {
items.push({
id: "star",
icon: <Star className="w-5 h-5" fill={isStarred ? "currentColor" : "none"} />,
label: isStarred ? t("../context_menu.unstar") : t("../context_menu.star"),
onClick: () => { act(onToggleStar)!(); },
});
}
if (onMarkAsRead) {
items.push({
id: "mark-read",
icon: isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />,
label: isUnread ? t("../context_menu.mark_read") : t("../context_menu.mark_unread"),
onClick: () => { onMarkAsRead(email, !isUnread); },
});
}
if (onArchive) {
items.push({
id: "archive",
icon: <Archive className="w-5 h-5" />,
label: t("../context_menu.archive"),
onClick: () => { act(onArchive)!(); },
});
}
if (onDelete) {
items.push({
id: "delete",
icon: <Trash2 className="w-5 h-5" />,
label: t("../context_menu.delete"),
onClick: () => { act(onDelete)!(); },
destructive: true,
});
}
if (onMoveToMailbox) {
items.push({
id: "move",
icon: <FolderOpen className="w-5 h-5" />,
label: t("../context_menu.move_to"),
onClick: () => { openContextMenu({ preventDefault: () => {}, stopPropagation: () => {}, clientX: radialMenuPos.x, clientY: radialMenuPos.y } as React.MouseEvent, email); },
});
}
return items;
}, [radialMenuEmail, radialMenuPos, t, onReply, onReplyAll, onForward, onToggleStar, onMarkAsRead, onArchive, onDelete, onMoveToMailbox, openContextMenu]);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const [isProcessing, setIsProcessing] = useState(false);
@@ -549,7 +646,7 @@ export function EmailList({
onEmailSelect?.(email);
}}
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
onContextMenu={openContextMenu}
onContextMenu={(e, email) => { openContextMenu(e, email); openRadialMenu(e, email); }}
onOpenConversation={onOpenConversation}
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
@@ -581,6 +678,14 @@ export function EmailList({
)}
</div>
{/* Radial Action Menu */}
<RadialMenu
items={radialMenuItems}
isOpen={radialMenuOpen}
position={radialMenuPos}
onClose={closeRadialMenu}
/>
{/* Context Menu */}
{contextMenuEmail && (
<EmailContextMenu
+48
View File
@@ -76,6 +76,7 @@ import {
PlayCircle,
PenSquare,
CalendarClock,
CalendarPlus,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
@@ -88,6 +89,8 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useThemeStore } from "@/stores/theme-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { UnsubscribeBanner } from "./unsubscribe-banner";
@@ -703,6 +706,9 @@ export function EmailViewer({
const isScheduled = email?.isScheduled === true;
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
const createAppointmentVisible = !isScheduled && !isDraft && calendarEnabled && !!email;
// Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection();
@@ -1029,6 +1035,34 @@ export function EmailViewer({
const { isMobile: isMobileDevice } = useDeviceDetection();
const router = useRouter();
const handleCreateAppointment = useCallback(() => {
if (!email) return;
const subject = email.subject ? `Re: ${email.subject}` : "";
const body = email.htmlBody?.[0]?.partId
? email.bodyValues?.[email.htmlBody[0].partId]?.value || ""
: "";
const participants: { name?: string; email: string }[] = [];
const seen = new Set<string>();
const addParticipant = (p?: { name?: string; email?: string }) => {
if (!p?.email) return;
const normalized = p.email.toLowerCase();
if (!seen.has(normalized)) {
seen.add(normalized);
participants.push({ name: p.name, email: p.email });
}
};
if (email.from) email.from.forEach(addParticipant);
if (email.to) email.to.forEach(addParticipant);
if (email.cc) email.cc.forEach(addParticipant);
useCalendarStore.getState().setNewEventPrefill({
title: subject,
description: body,
participants,
date: email.receivedAt,
});
router.push('/calendar');
}, [email, router]);
const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => {
if (isMobileDevice) {
// No room for a sidebar on mobile - send the user to the contacts page
@@ -2909,6 +2943,20 @@ export function EmailViewer({
<Forward className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('forward')}</span>}
</Button>
{createAppointmentVisible && (
<Button
variant="ghost"
size="sm"
onClick={handleCreateAppointment}
data-overflow-item
data-overflow-priority="3.5"
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('create_appointment')}
>
<CalendarPlus className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('create_appointment')}</span>}
</Button>
)}
</>)}
<PluginSlot name="toolbar-actions" />
</div>
+174 -23
View File
@@ -11,7 +11,7 @@ import {
AlertCircle, Star, Clock, FolderUp,
FileArchive, FileSpreadsheet, Presentation, FileCode,
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
Menu, Users, Share2,
Menu, Users, Share2, MailPlus, Paperclip, ExternalLink,
} from "lucide-react";
import { useIsDesktop } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
@@ -27,6 +27,7 @@ import { Avatar } from "@/components/ui/avatar";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
import type { FileResource } from "@/stores/file-store";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { FileNodeRights } from "@/lib/jmap/types";
@@ -106,6 +107,8 @@ interface FileBrowserProps {
sharingEnabled?: boolean;
/** Add/update/remove a principal's share on a node. Set null rights to revoke. */
onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
/** Send selected files as email attachments - opens the composer with files pre-attached. */
onSendAsAttachment?: (names: string[]) => void;
}
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
@@ -205,6 +208,14 @@ function isDatabaseFile(name: string): boolean {
return DATABASE_EXTENSIONS.has(ext);
}
const OFFICE_EXTENSIONS = new Set([
"docx", "xlsx", "pptx", "odt", "ods", "odp", "doc", "xls", "ppt",
]);
function isOfficeFile(name: string): boolean {
const ext = name.split(".").pop()?.toLowerCase() || "";
return OFFICE_EXTENSIONS.has(ext);
}
function isPreviewable(name: string): boolean {
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
}
@@ -384,6 +395,7 @@ export function FileBrowser({
ownAccountId,
sharingEnabled,
onShare,
onSendAsAttachment,
}: FileBrowserProps) {
const t = useTranslations("files");
const [showNewFolder, setShowNewFolder] = useState(false);
@@ -401,6 +413,60 @@ export function FileBrowser({
[sharingEnabled, onShare, client]);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null);
// Radial menu state
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
const [radialMenuResourceName, setRadialMenuResourceName] = useState<string | null>(null);
const closeRadialMenu = useCallback(() => {
setRadialMenuOpen(false);
}, []);
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
if (!radialMenuResourceName) return [];
const name = radialMenuResourceName;
const resource = resources.find((r) => r.name === name);
const items: RadialMenuItem[] = [];
items.push({
id: "rename",
icon: <Pencil className="w-5 h-5" />,
label: t("rename"),
onClick: () => { setRenameTarget(name); },
});
items.push({
id: "delete",
icon: <Trash2 className="w-5 h-5" />,
label: t("delete"),
onClick: () => { onDelete(name); },
destructive: true,
});
if (resource && !resource.isDirectory) {
items.push({
id: "download",
icon: <Download className="w-5 h-5" />,
label: t("download"),
onClick: () => { onDownload(name); },
});
}
if (canShare(resource)) {
items.push({
id: "share",
icon: <Share2 className="w-5 h-5" />,
label: t("share"),
onClick: () => { if (resource?.id) setShareTargetId(resource.id); },
});
}
if (resource && !resource.isDirectory) {
items.push({
id: "send-as-attachment",
icon: <Paperclip className="w-5 h-5" />,
label: t("send_as_attachment"),
onClick: () => {},
});
}
return items;
}, [radialMenuResourceName, resources, t, onDelete, onDownload, canShare]);
const [showNewTextFile, setShowNewTextFile] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -765,6 +831,9 @@ export function FileBrowser({
const handleContextMenu = (e: React.MouseEvent, name: string) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, name });
setRadialMenuPos({ x: e.clientX, y: e.clientY });
setRadialMenuResourceName(name);
setRadialMenuOpen(true);
};
// Adjust context menu position to stay within viewport
@@ -974,28 +1043,76 @@ export function FileBrowser({
{/* Action buttons */}
<div className="flex items-center gap-1 shrink-0">
{selectedResources.size > 1 && (
<>
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => onBatchDownload([...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory))}
>
<Download className="w-4 h-4 me-1" />
{t("download")} ({[...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory).length})
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive hover:text-destructive"
onClick={() => onBatchDelete([...selectedResources])}
>
<Trash2 className="w-4 h-4 me-1" />
{t("delete")} ({selectedResources.size})
</Button>
</>
)}
{selectedResources.size > 0 && (() => {
const fileNames = [...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory);
const hasFiles = fileNames.length > 0;
const showBatch = selectedResources.size > 1;
if (!showBatch && !hasFiles) return null;
return (
<>
{showBatch && (
<>
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => onBatchDownload(fileNames)}
>
<Download className="w-4 h-4 me-1" />
{t("download")} ({fileNames.length})
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive hover:text-destructive"
onClick={() => onBatchDelete([...selectedResources])}
>
<Trash2 className="w-4 h-4 me-1" />
{t("delete")} ({selectedResources.size})
</Button>
</>
)}
{hasFiles && onSendAsAttachment && (
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => onSendAsAttachment(fileNames)}
>
<MailPlus className="w-4 h-4 me-1" />
{t("send_as_attachment")} {fileNames.length > 1 && `(${fileNames.length})`}
</Button>
)}
{!showBatch && hasFiles && fileNames.length === 1 && isOfficeFile(fileNames[0]) && (
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={async () => {
const file = resources.find((r) => r.name === fileNames[0]);
if (!file) return;
try {
const res = await fetch("/api/collabora/edit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileId: file.id, fileName: file.name }),
});
if (res.ok) {
const { url } = await res.json();
window.open(url, "_blank", "noopener,noreferrer");
}
} catch (err) {
console.error("Collabora edit failed:", err);
}
}}
>
<Pencil className="w-4 h-4 me-1" />
Edit with Collabora
</Button>
)}
</>
);
})()}
{clipboard && (
<Button
variant="ghost"
@@ -1669,6 +1786,14 @@ export function FileBrowser({
</table>
)}
{/* Radial Action Menu */}
<RadialMenu
items={radialMenuItems}
isOpen={radialMenuOpen}
position={radialMenuPos}
onClose={closeRadialMenu}
/>
{/* Context menu */}
{contextMenu && (
<div
@@ -1707,6 +1832,32 @@ export function FileBrowser({
{t("download")}
</button>
)}
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && isOfficeFile(contextMenu.name) && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={async () => {
const file = resources.find((r) => r.name === contextMenu.name);
if (!file) { setContextMenu(null); return; }
try {
const res = await fetch("/api/collabora/edit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileId: file.id, fileName: file.name }),
});
if (res.ok) {
const { url } = await res.json();
window.open(url, "_blank", "noopener,noreferrer");
}
} catch (err) {
console.error("Collabora edit failed:", err);
}
setContextMenu(null);
}}
>
<ExternalLink className="w-4 h-4" />
Edit with Collabora
</button>
)}
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
+65
View File
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input';
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
import { useSignatureStore } from '@/stores/signature-store';
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
const SIGNATURE_MAX_BYTES = 2047;
@@ -73,6 +74,15 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const {
signatures,
identitySignatureMap,
setIdentitySignature,
} = useSignatureStore();
const identitySigMapping = identity?.id ? (identitySignatureMap[identity.id] ?? {}) : {};
const [sigDefaultId, setSigDefaultId] = useState<string>(identitySigMapping.defaultId ?? '');
const [sigReplyId, setSigReplyId] = useState<string>(identitySigMapping.replyId ?? '');
const parseEmailList = (input: string): EmailAddress[] | undefined => {
if (!input.trim()) return undefined;
@@ -134,6 +144,10 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
};
await onSave(sanitizedData);
if (identity?.id) {
setIdentitySignature(identity.id, 'default', sigDefaultId || null);
setIdentitySignature(identity.id, 'reply', sigReplyId || null);
}
} finally {
setIsSubmitting(false);
}
@@ -266,6 +280,57 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
)}
</div>
{/* Signature Store Mapping (per-identity) */}
{isEditing && signatures.length > 0 && (
<div className="border border-border rounded-md p-4 space-y-3 bg-muted/30">
<p className="text-sm font-medium text-foreground">{t('signature_store_mapping')}</p>
<div>
<label htmlFor="identity-sig-default" className="block text-xs text-muted-foreground mb-1">
{t('signature_store_default')}
</label>
<select
id="identity-sig-default"
value={sigDefaultId}
onChange={(e) => {
setSigDefaultId(e.target.value);
if (identity?.id) {
setIdentitySignature(identity.id, 'default', e.target.value || null);
}
}}
disabled={isSubmitting}
className="w-full px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">{t('use_global_default')}</option>
{signatures.map((sig) => (
<option key={sig.id} value={sig.id}>{sig.name}</option>
))}
</select>
</div>
<div>
<label htmlFor="identity-sig-reply" className="block text-xs text-muted-foreground mb-1">
{t('signature_store_reply')}
</label>
<select
id="identity-sig-reply"
value={sigReplyId}
onChange={(e) => {
setSigReplyId(e.target.value);
if (identity?.id) {
setIdentitySignature(identity.id, 'reply', e.target.value || null);
}
}}
disabled={isSubmitting}
className="w-full px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">{t('use_global_default')}</option>
{signatures.map((sig) => (
<option key={sig.id} value={sig.id}>{sig.name}</option>
))}
</select>
</div>
</div>
)}
{/* Text Signature */}
<div>
<label htmlFor="identity-text-sig" className="block text-sm font-medium mb-1">
@@ -21,6 +21,7 @@ import {
FolderX,
RefreshCw,
Upload,
Share2,
} from "lucide-react";
interface Position {
@@ -86,6 +87,7 @@ interface MailboxContextMenuProps {
onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void;
onImportEmail?: (mailboxId: string) => void;
onShareFolder?: (mailboxId: string) => void;
onRefresh?: () => void;
}
@@ -105,6 +107,7 @@ export function MailboxContextMenu({
onRenameFolder,
onDeleteFolder,
onImportEmail,
onShareFolder,
onRefresh,
}: MailboxContextMenuProps) {
const t = useTranslations("mailbox_context_menu");
@@ -191,6 +194,12 @@ export function MailboxContextMenu({
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
disabled={!onRenameFolder || !canRename}
/>
<ContextMenuItem
icon={Share2}
label={t("share_folder")}
onClick={() => handleAction(() => onShareFolder?.(mailbox.id))}
disabled={!onShareFolder || mailbox.isShared}
/>
<ContextMenuSeparator />
+31
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo, ReactNode } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { MiniCalendarDashlet } from "@/components/calendar/mini-calendar-dashlet";
import { Button } from "@/components/ui/button";
import {
Inbox,
@@ -62,6 +63,7 @@ import { useUIStore } from "@/stores/ui-store";
import { useAuthStore } from "@/stores/auth-store";
import { useVacationStore } from "@/stores/vacation-store";
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
@@ -88,6 +90,7 @@ interface SidebarProps {
onDeleteFolder?: (mailboxId: string) => void;
onImportEmail?: (mailboxId: string) => void;
onRefreshMailboxes?: () => void;
onShareFolder?: (mailboxId: string) => void;
scheduledTotal?: number;
showScheduledMailbox?: boolean;
/** True when the unified view spans multiple login accounts (cross-account).
@@ -777,6 +780,7 @@ export function Sidebar({
onDeleteFolder,
onImportEmail,
onRefreshMailboxes,
onShareFolder,
scheduledTotal = 0,
showScheduledMailbox = false,
crossAccountActive = false,
@@ -808,6 +812,12 @@ export function Sidebar({
return stored !== null ? JSON.parse(stored) : true;
} catch { return true; }
});
const [calendarDashletExpanded, setCalendarDashletExpanded] = useState(() => {
try {
const stored = localStorage.getItem('sidebarCalendarDashletExpanded');
return stored !== null ? JSON.parse(stored) : true;
} catch { return true; }
});
const [unifiedExpanded, setUnifiedExpanded] = useState(() => {
try {
const stored = localStorage.getItem('sidebarUnifiedExpanded');
@@ -840,6 +850,7 @@ export function Sidebar({
const emailKeywords = useSettingsStore(s => s.emailKeywords);
const nestedTags = useSettingsStore(s => s.nestedTags);
const isEmbedded = useIsEmbedded();
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
// own AccountSwitcher would be a redundant second account UI in the same
// pane.
@@ -1054,6 +1065,13 @@ export function Sidebar({
return next;
});
};
const toggleCalendarDashlet = () => {
setCalendarDashletExpanded((prev: boolean) => {
const next = !prev;
try { localStorage.setItem('sidebarCalendarDashletExpanded', JSON.stringify(next)); } catch { /* */ }
return next;
});
};
const toggleShared = () => {
setSharedExpanded((prev: boolean) => {
const next = !prev;
@@ -1405,6 +1423,18 @@ export function Sidebar({
</div>
)}
{!isCollapsed && calendarEnabled && (
<div>
<SidebarSectionHeader
label={t("calendar")}
expanded={calendarDashletExpanded}
onToggle={toggleCalendarDashlet}
isCollapsed={isCollapsed}
/>
{calendarDashletExpanded && <MiniCalendarDashlet />}
</div>
)}
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
</div>
@@ -1424,6 +1454,7 @@ export function Sidebar({
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
onImportEmail={onImportEmail}
onShareFolder={onShareFolder}
onRefresh={onRefreshMailboxes}
/>
</div>
@@ -18,6 +18,7 @@ export function ContactsSettings() {
const { client } = useAuthStore();
const {
contacts,
addressBooks,
supportsSync,
importContacts,
} = useContactStore();
@@ -46,6 +47,7 @@ export function ContactsSettings() {
<div className="border border-border rounded-lg overflow-hidden" style={{ minHeight: 400 }}>
<ContactImportDialog
existingContacts={contacts}
addressBooks={addressBooks}
onImport={handleImport}
onClose={() => setShowImport(false)}
/>
+245
View File
@@ -0,0 +1,245 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Upload, FolderOpen, Download, AlertTriangle, Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsSection, SettingItem, RadioGroup, Select } from "./settings-section";
import { importEmails, type ConflictResolution, type ImportProgress, type ImportResult } from "@/lib/email-import";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { EML_IMPORT_ACCEPT } from "@/lib/eml-import";
import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils";
export function ImportSettings() {
const t = useTranslations("settings.importer");
const { client } = useAuthStore();
const { mailboxes } = useEmailStore();
const fileRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<File[]>([]);
const [destination, setDestination] = useState("");
const [conflict, setConflict] = useState<ConflictResolution>("skip");
const [progress, setProgress] = useState<ImportProgress | null>(null);
const [result, setResult] = useState<ImportResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
if (mailboxes.length > 0 && !destination) {
const inbox = mailboxes.find((m) => m.role === "inbox") || mailboxes[0];
if (inbox) setDestination(inbox.id);
}
}, [mailboxes, destination]);
const folderOptions = mailboxes.map((m) => ({
value: m.id,
label: m.name,
}));
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files;
if (!selected || selected.length === 0) return;
setError(null);
setResult(null);
setProgress(null);
setFiles(Array.from(selected));
}, []);
const handleImport = useCallback(async () => {
if (!client || files.length === 0 || !destination) return;
setImporting(true);
setError(null);
setResult(null);
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await importEmails({
client,
files,
destinationMailboxId: destination,
conflictResolution: conflict,
onProgress: (p) => setProgress({ ...p }),
signal: controller.signal,
});
setResult(res);
if (res.imported > 0) {
toast.success(t("success", { count: res.imported }));
}
} catch (err) {
if (!controller.signal.aborted) {
const msg = err instanceof Error ? err.message : t("fail");
setError(msg);
toast.error(msg);
}
} finally {
setImporting(false);
abortRef.current = null;
}
}, [client, files, destination, conflict, t]);
const handleCancel = () => {
abortRef.current?.abort();
setImporting(false);
};
const reset = () => {
setFiles([]);
setResult(null);
setProgress(null);
setError(null);
if (fileRef.current) fileRef.current.value = "";
};
const progressPercent = progress && progress.total > 0
? Math.round((progress.processed / progress.total) * 100)
: 0;
return (
<SettingsSection
title={t("title")}
description={t("description")}
>
<SettingItem
label={t("file_label")}
description={t("file_description")}
>
<div className="flex items-center gap-2">
<input
ref={fileRef}
type="file"
accept={EML_IMPORT_ACCEPT}
multiple
onChange={handleFileChange}
className="hidden"
/>
<Button
variant="outline"
size="sm"
onClick={() => fileRef.current?.click()}
disabled={importing}
>
<Upload className="w-4 h-4 me-2" />
{files.length > 0
? t("files_selected", { count: files.length })
: t("choose_files")}
</Button>
{files.length > 0 && !importing && (
<Button variant="ghost" size="sm" onClick={reset}>
<X className="w-4 h-4" />
</Button>
)}
</div>
</SettingItem>
<SettingItem
label={t("folder_label")}
description={t("folder_description")}
>
<Select
value={destination}
onChange={setDestination}
options={folderOptions}
disabled={importing || folderOptions.length === 0}
/>
</SettingItem>
<SettingItem
label={t("conflict_label")}
description={t("conflict_description")}
>
<RadioGroup
value={conflict}
onChange={(v) => setConflict(v as ConflictResolution)}
options={[
{ value: "skip", label: t("conflict_skip") },
{ value: "replace", label: t("conflict_replace") },
{ value: "copy", label: t("conflict_copy") },
]}
/>
</SettingItem>
{files.length > 0 && !result && (
<SettingItem label={t("action_label")} description="">
<Button
onClick={handleImport}
disabled={importing || !destination}
>
{importing ? t("importing") : t("start_import", { count: files.length })}
</Button>
</SettingItem>
)}
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded flex items-center gap-2">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{progress && importing && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{progress.currentFile}</span>
<span>{progressPercent}%</span>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{t("progress_imported", { count: progress.imported })}</span>
<span>{t("progress_skipped", { count: progress.skipped })}</span>
<span>{t("progress_failed", { count: progress.failed })}</span>
</div>
<div className="flex justify-center">
<Button variant="outline" size="sm" onClick={handleCancel}>
{t("cancel")}
</Button>
</div>
</div>
)}
{result && !importing && (
<div className={cn(
"rounded-lg p-4 space-y-3",
result.failed > 0
? "bg-warning/10 border border-warning/30"
: "bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800"
)}>
<div className="flex items-center gap-2">
<Check className="w-5 h-5 text-green-600 dark:text-green-400" />
<span className="font-medium text-sm">{t("import_complete")}</span>
</div>
<div className="text-sm space-y-1">
<p>{t("summary_imported", { count: result.imported })}</p>
<p>{t("summary_skipped", { count: result.skipped })}</p>
<p>{t("summary_failed", { count: result.failed })}</p>
</div>
{result.errors.length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
{t("error_details", { count: result.errors.length })}
</summary>
<ul className="mt-2 space-y-1 ps-4 list-disc">
{result.errors.map((e, i) => (
<li key={i} className="text-red-600 dark:text-red-400">
<span className="font-medium">{e.file}</span>: {e.error}
</li>
))}
</ul>
</details>
)}
<Button variant="outline" size="sm" onClick={reset}>
{t("import_more")}
</Button>
</div>
)}
</SettingsSection>
);
}
+279
View File
@@ -0,0 +1,279 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import {
Loader2,
RefreshCw,
Check,
X,
Folder,
Calendar,
BookUser,
HardDrive,
Trash2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/stores/auth-store";
import { useSharingStore, type SharedResourceKind, type SharedFolder } from "@/stores/sharing-store";
const ICON_CLASS = "w-4 h-4 shrink-0";
function KindIcon({ kind }: { kind: SharedResourceKind }) {
switch (kind) {
case "mailbox":
return <Folder className={cn(ICON_CLASS, "text-blue-600/80")} />;
case "calendar":
return <Calendar className={cn(ICON_CLASS, "text-emerald-600/80")} />;
case "addressBook":
return <BookUser className={cn(ICON_CLASS, "text-violet-600/80")} />;
case "file":
return <HardDrive className={cn(ICON_CLASS, "text-amber-600/80")} />;
}
}
function KindLabel({ kind }: { kind: SharedResourceKind }) {
switch (kind) {
case "mailbox":
return "Mail";
case "calendar":
return "Calendar";
case "addressBook":
return "Contacts";
case "file":
return "Files";
}
}
export function SharingSettings() {
const t = useTranslations("settings");
const tSharing = useTranslations("sharing");
const client = useAuthStore((s) => s.client);
const {
sharedByMe,
sharedWithMe,
loading,
fetchShares,
revokeShare,
changeRole,
acceptShare,
declineShare,
} = useSharingStore();
const [activeTab, setActiveTab] = useState<"byMe" | "withMe">("byMe");
const handleRefresh = useCallback(() => {
if (client) fetchShares(client);
}, [client, fetchShares]);
useEffect(() => {
if (client) handleRefresh();
}, [client, handleRefresh]);
const handleRevoke = async (share: SharedFolder) => {
if (!client) return;
await revokeShare(
client,
share.resourceId,
share.resourceKind,
share.principalId,
share.accountId,
);
};
const handleChangeRole = async (share: SharedFolder, role: string) => {
if (!client) return;
await changeRole(
client,
share.resourceId,
share.resourceKind,
share.principalId,
role,
share.accountId,
);
};
const handleAccept = async (share: SharedFolder) => {
if (!client) return;
await acceptShare(client, share);
};
const handleDecline = async (share: SharedFolder) => {
if (!client) return;
await declineShare(client, share);
};
return (
<div>
<div className="flex items-center gap-1 border-b border-border mb-4">
<button
onClick={() => setActiveTab("byMe")}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "byMe"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{tSharing("tab_shared_by_me")}
</button>
<button
onClick={() => setActiveTab("withMe")}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "withMe"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{tSharing("tab_shared_with_me")}
</button>
<div className="flex-1" />
<button
onClick={handleRefresh}
disabled={loading}
className="p-2 rounded-md hover:bg-muted text-muted-foreground disabled:opacity-50 transition-colors"
title={t("refresh")}
>
<RefreshCw
className={cn("w-4 h-4", loading && "animate-spin")}
/>
</button>
</div>
{loading && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin me-2" />
{t("loading")}
</div>
)}
{!loading && activeTab === "byMe" && (
<>
{sharedByMe.length === 0 ? (
<div className="text-sm text-muted-foreground py-8 text-center">
{tSharing("no_shares_by_me")}
</div>
) : (
<div className="space-y-1">
{sharedByMe.map((share) => (
<div
key={share.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-md border border-border bg-card"
>
<KindIcon kind={share.resourceKind} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{share.resourceName}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<KindLabel kind={share.resourceKind} />
<span className="mx-1 opacity-40">|</span>
<Avatar
name={share.principalName}
email={share.principalEmail ?? undefined}
size="sm"
className="shrink-0 me-1"
/>
<span className="truncate">{share.principalName}</span>
</div>
</div>
<select
value={share.role}
onChange={(e) => handleChangeRole(share, e.target.value)}
className="appearance-none rounded-md border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="read">
{tSharing("preset.read")}
</option>
<option value="readWrite">
{tSharing("preset.readWrite")}
</option>
<option value="manager">
{tSharing("preset.manager")}
</option>
</select>
<button
onClick={() => handleRevoke(share)}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={tSharing("remove")}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</>
)}
{!loading && activeTab === "withMe" && (
<>
{sharedWithMe.length === 0 ? (
<div className="text-sm text-muted-foreground py-8 text-center">
{tSharing("no_shares_with_me")}
</div>
) : (
<div className="space-y-1">
{sharedWithMe.map((share) => (
<div
key={share.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-md border border-border bg-card"
>
<KindIcon kind={share.resourceKind} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{share.resourceName}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<KindLabel kind={share.resourceKind} />
<span className="mx-1 opacity-40">|</span>
<span className="truncate">
{tSharing("shared_by")}: {share.principalName}
</span>
</div>
</div>
<span className="text-xs bg-muted rounded px-2 py-0.5 text-muted-foreground">
{tSharing(`preset.${share.role}`)}
</span>
{share.pending ? (
<div className="flex items-center gap-1">
<Button
size="sm"
variant="default"
onClick={() => handleAccept(share)}
className="h-7 px-2 text-xs"
>
<Check className="w-3 h-3 me-1" />
{tSharing("accept")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDecline(share)}
className="h-7 px-2 text-xs"
>
<X className="w-3 h-3 me-1" />
{tSharing("decline")}
</Button>
</div>
) : (
<Button
size="sm"
variant="ghost"
onClick={() => handleDecline(share)}
className="h-7 px-2 text-xs text-muted-foreground hover:text-destructive"
>
{tSharing("remove")}
</Button>
)}
</div>
))}
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,399 @@
'use client';
import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Paragraph from '@tiptap/extension-paragraph';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import TextAlign from '@tiptap/extension-text-align';
import { TextStyle } from '@tiptap/extension-text-style';
import Color from '@tiptap/extension-color';
import { useFocusTrap } from '@/hooks/use-focus-trap';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { htmlToPlainText } from '@/lib/html-to-text';
import type { Signature } from '@/stores/signature-store';
import {
Bold,
Italic,
Underline as UnderlineIcon,
Strikethrough,
List,
ListOrdered,
AlignLeft,
AlignCenter,
AlignRight,
Link as LinkIcon,
Baseline,
X,
} from 'lucide-react';
interface SignatureEditorModalProps {
signature?: Signature | null;
onSave: (data: { name: string; body: string; plainText: string }) => void;
onClose: () => void;
}
const StyledParagraph = Paragraph.extend({
addAttributes() {
return {
...this.parent?.(),
style: {
default: null as string | null,
parseHTML: (el: HTMLElement) => el.getAttribute('style'),
renderHTML: (attrs: Record<string, string | null>) =>
attrs.style ? { style: attrs.style } : {},
},
class: {
default: null as string | null,
parseHTML: (el: HTMLElement) => el.getAttribute('class'),
renderHTML: (attrs: Record<string, string | null>) =>
attrs.class ? { class: attrs.class } : {},
},
};
},
});
const TEXT_COLORS = [
'#000000', '#5f6368', '#9aa0a6', '#c5221f', '#e8710a', '#f9ab00', '#188038', '#1967d2',
'#7627bb', '#c2185b', '#795548', '#fa5252', '#fd7e14', '#40c057', '#4dabf7', '#e64980',
];
function ToolbarButton({
active,
onClick,
children,
title,
disabled,
}: {
active?: boolean;
onClick: () => void;
children: React.ReactNode;
title: string;
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={cn(
'p-1.5 rounded hover:bg-accent transition-colors',
active && 'bg-accent text-accent-foreground',
disabled && 'opacity-40 cursor-not-allowed'
)}
>
{children}
</button>
);
}
function ToolbarSeparator() {
return <div className="w-px h-5 bg-border mx-0.5" />;
}
export function SignatureEditorModal({
signature,
onSave,
onClose,
}: SignatureEditorModalProps) {
const t = useTranslations('signatures');
const tCommon = useTranslations('common');
const isEditing = !!signature;
const [name, setName] = useState(signature?.name ?? '');
const [nameError, setNameError] = useState('');
const [showPreview, setShowPreview] = useState(false);
const [colorMenuOpen, setColorMenuOpen] = useState(false);
const dialogRef = useFocusTrap({
isActive: true,
onEscape: onClose,
restoreFocus: true,
});
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: false,
paragraph: false,
link: false,
underline: false,
codeBlock: false,
}),
StyledParagraph,
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: { rel: 'noopener noreferrer nofollow' },
}),
TextAlign.configure({
types: ['paragraph'],
}),
TextStyle,
Color,
],
content: signature?.body ?? '<p></p>',
editorProps: {
attributes: {
class: 'tiptap min-h-[120px] px-3 py-2 text-sm text-foreground focus:outline-none',
},
},
immediatelyRender: false,
});
const addLink = useCallback(() => {
if (!editor) return;
const previousUrl = editor.getAttributes('link').href;
const url = window.prompt('URL', previousUrl);
if (url === null) return;
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}, [editor]);
const handleSave = () => {
const trimmedName = name.trim();
if (!trimmedName) {
setNameError(t('name_required'));
return;
}
const html = editor?.getHTML() ?? '<p></p>';
const plainText = htmlToPlainText(html);
onSave({ name: trimmedName, body: html, plainText });
};
const bodyHtml = editor?.getHTML() ?? '';
const bodyPlainText = htmlToPlainText(bodyHtml);
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-start justify-center z-[60] p-4 pt-[10vh] animate-in fade-in duration-150">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-2xl animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">
{isEditing ? t('edit_signature') : t('new_signature')}
</h2>
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
<X className="w-4 h-4" />
</Button>
</div>
<div className="p-6 space-y-4 max-h-[70vh] overflow-y-auto">
<div>
<label htmlFor="sig-name" className="block text-sm font-medium mb-1">
{t('name_label')}
</label>
<Input
id="sig-name"
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
if (nameError) setNameError('');
}}
placeholder={t('name_placeholder')}
className={cn(nameError && 'border-destructive')}
aria-invalid={!!nameError}
aria-describedby={nameError ? 'sig-name-error' : undefined}
/>
{nameError && (
<p id="sig-name-error" className="text-sm text-destructive mt-1" role="alert">
{nameError}
</p>
)}
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">{t('editor_label')}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowPreview(!showPreview)}
className="h-7 text-xs"
>
{showPreview ? t('show_editor') : t('show_preview')}
</Button>
</div>
{showPreview ? (
<div className="border border-border rounded-md bg-muted/30 p-4 min-h-[200px]">
<div className="text-xs text-muted-foreground mb-2 font-medium">
{t('html_preview_label')}
</div>
<div
className="text-sm text-foreground [&_a]:text-primary [&_a]:underline-offset-2"
dangerouslySetInnerHTML={{ __html: bodyHtml }}
/>
<div className="mt-4 pt-4 border-t border-border">
<div className="text-xs text-muted-foreground mb-2 font-medium">
{t('plain_text_preview_label')}
</div>
<pre className="text-sm text-foreground whitespace-pre-wrap font-sans">
{bodyPlainText}
</pre>
</div>
</div>
) : (
<div className={cn('flex flex-col border border-border rounded-md overflow-hidden')}>
<div className="flex flex-wrap items-center gap-0.5 px-3 py-1.5 border-b border-border/50 bg-muted/30">
<ToolbarButton
active={editor?.isActive('bold')}
onClick={() => editor?.chain().focus().toggleBold().run()}
title={t('toolbar.bold')}
>
<Bold className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
active={editor?.isActive('italic')}
onClick={() => editor?.chain().focus().toggleItalic().run()}
title={t('toolbar.italic')}
>
<Italic className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
active={editor?.isActive('underline')}
onClick={() => editor?.chain().focus().toggleUnderline().run()}
title={t('toolbar.underline')}
>
<UnderlineIcon className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
active={editor?.isActive('strike')}
onClick={() => editor?.chain().focus().toggleStrike().run()}
title={t('toolbar.strikethrough')}
>
<Strikethrough className="w-4 h-4" />
</ToolbarButton>
<div className="relative">
<ToolbarButton
active={!!editor?.getAttributes('textStyle').color}
onClick={() => setColorMenuOpen((v) => !v)}
title={t('toolbar.text_color')}
>
<Baseline
className="w-4 h-4"
style={{ color: editor?.getAttributes('textStyle').color || undefined }}
/>
</ToolbarButton>
{colorMenuOpen && (
<div className="absolute z-50 top-full start-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2">
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: 'repeat(8, 1fr)' }}
>
{TEXT_COLORS.map((color) => (
<button
key={color}
type="button"
title={color}
onClick={() => {
editor?.chain().focus().setColor(color).run();
setColorMenuOpen(false);
}}
className={cn(
'w-4 h-4 border border-border/60 rounded-[2px] transition-transform hover:scale-110',
editor?.getAttributes('textStyle').color === color &&
'ring-1 ring-ring ring-offset-1'
)}
style={{ backgroundColor: color }}
/>
))}
</div>
<div className="h-px bg-border my-1.5" />
<button
type="button"
className="flex items-center gap-2 px-2 py-1 text-sm rounded hover:bg-accent text-start w-full"
onClick={() => {
editor?.chain().focus().unsetColor().run();
setColorMenuOpen(false);
}}
>
{t('toolbar.remove_color')}
</button>
</div>
)}
</div>
<ToolbarSeparator />
<ToolbarButton
active={editor?.isActive('bulletList')}
onClick={() => editor?.chain().focus().toggleBulletList().run()}
title={t('toolbar.bullet_list')}
>
<List className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
active={editor?.isActive('orderedList')}
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
title={t('toolbar.ordered_list')}
>
<ListOrdered className="w-4 h-4" />
</ToolbarButton>
<ToolbarSeparator />
<ToolbarButton
active={editor?.isActive({ textAlign: 'left' })}
onClick={() => editor?.chain().focus().setTextAlign('left').run()}
title={t('toolbar.align_left')}
>
<AlignLeft className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
active={editor?.isActive({ textAlign: 'center' })}
onClick={() => editor?.chain().focus().setTextAlign('center').run()}
title={t('toolbar.align_center')}
>
<AlignCenter className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
active={editor?.isActive({ textAlign: 'right' })}
onClick={() => editor?.chain().focus().setTextAlign('right').run()}
title={t('toolbar.align_right')}
>
<AlignRight className="w-4 h-4" />
</ToolbarButton>
<ToolbarSeparator />
<ToolbarButton
active={editor?.isActive('link')}
onClick={addLink}
title={t('toolbar.link')}
>
<LinkIcon className="w-4 h-4" />
</ToolbarButton>
</div>
<EditorContent editor={editor} />
</div>
)}
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={onClose}>
{tCommon('cancel')}
</Button>
<Button onClick={handleSave}>
{tCommon('save')}
</Button>
</div>
</div>
</div>
);
}
+273
View File
@@ -0,0 +1,273 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { SettingsSection, SettingItem, Select } from './settings-section';
import { SignatureEditorModal } from './signature-editor-modal';
import { useSignatureStore, type Signature } from '@/stores/signature-store';
import { useIdentityStore } from '@/stores/identity-store';
import { truncateText } from '@/lib/utils';
import {
Plus,
Pencil,
Copy,
Trash2,
ChevronRight,
} from 'lucide-react';
export function SignatureSettings() {
const t = useTranslations('signatures');
const tCommon = useTranslations('common');
const {
signatures,
defaultSignatureId,
replySignatureId,
identitySignatureMap,
addSignature,
updateSignature,
deleteSignature,
duplicateSignature,
setDefaultSignatureId,
setReplySignatureId,
setIdentitySignature,
} = useSignatureStore();
const identities = useIdentityStore((s) => s.identities);
const [editingSignature, setEditingSignature] = useState<Signature | null>(null);
const [showEditor, setShowEditor] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Signature | null>(null);
const handleAdd = () => {
setEditingSignature(null);
setShowEditor(true);
};
const handleEdit = (sig: Signature) => {
setEditingSignature(sig);
setShowEditor(true);
};
const handleDuplicate = (id: string) => {
duplicateSignature(id);
};
const handleDeleteConfirm = () => {
if (deleteTarget) {
deleteSignature(deleteTarget.id);
setDeleteTarget(null);
}
};
const handleSave = (data: { name: string; body: string; plainText: string }) => {
if (editingSignature) {
updateSignature(editingSignature.id, data);
} else {
addSignature(data);
}
setShowEditor(false);
setEditingSignature(null);
};
const signatureOptions = [
{ value: '', label: t('no_signature') },
...signatures.map((sig) => ({ value: sig.id, label: sig.name })),
];
return (
<>
<SettingsSection title={t('title')} description={t('description')}>
<SettingItem
label={t('default_signature.label')}
description={t('default_signature.description')}
>
<div className="flex items-center gap-2">
<Select
value={defaultSignatureId ?? ''}
onChange={(value) => setDefaultSignatureId(value || null)}
options={signatureOptions}
ariaLabel={t('default_signature.label')}
/>
</div>
</SettingItem>
<SettingItem
label={t('reply_signature.label')}
description={t('reply_signature.description')}
>
<div className="flex items-center gap-2">
<Select
value={replySignatureId ?? ''}
onChange={(value) => setReplySignatureId(value || null)}
options={signatureOptions}
ariaLabel={t('reply_signature.label')}
/>
</div>
</SettingItem>
{identities.length > 0 && (
<SettingItem
label={t('per_identity_signatures.label')}
description={t('per_identity_signatures.description')}
>
<div className="space-y-2 max-w-xs">
{identities.map((identity) => {
const mapping = identitySignatureMap[identity.id] ?? {};
const identitySigOptions = [
{ value: '', label: t('use_global_default') },
...signatures.map((sig) => ({ value: sig.id, label: sig.name })),
];
return (
<div key={identity.id} className="border border-border rounded-md p-3 space-y-2">
<span className="text-sm font-medium block truncate">
{identity.name ? `${identity.name} <${identity.email}>` : identity.email}
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-16 shrink-0">
{t('default')}
</span>
<select
value={mapping.defaultId ?? ''}
onChange={(e) =>
setIdentitySignature(identity.id, 'default', e.target.value || null)
}
className="flex-1 px-2 py-1 text-xs rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
{identitySigOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-16 shrink-0">
{t('reply')}
</span>
<select
value={mapping.replyId ?? ''}
onChange={(e) =>
setIdentitySignature(identity.id, 'reply', e.target.value || null)
}
className="flex-1 px-2 py-1 text-xs rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
{identitySigOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
);
})}
</div>
</SettingItem>
)}
<div className="pt-2">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-foreground">
{t('your_signatures', { count: signatures.length })}
</h4>
<Button size="sm" onClick={handleAdd}>
<Plus className="w-4 h-4 me-1" />
{t('add_signature')}
</Button>
</div>
{signatures.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
{t('no_signatures')}
</p>
) : (
<div className="border border-border rounded-md divide-y divide-border">
{signatures.map((sig) => (
<div
key={sig.id}
className="flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors"
>
<button
type="button"
className="flex-1 flex items-center gap-3 min-w-0 text-start"
onClick={() => handleEdit(sig)}
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground truncate">
{sig.name}
</div>
<div className="text-xs text-muted-foreground truncate mt-0.5">
{truncateText(sig.plainText, 80)}
</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
</button>
<div className="flex items-center gap-0.5 ml-2 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDuplicate(sig.id);
}}
title={t('duplicate')}
className="h-8 w-8 p-0"
>
<Copy className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleEdit(sig);
}}
title={tCommon('edit')}
className="h-8 w-8 p-0"
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(sig);
}}
title={tCommon('delete')}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</SettingsSection>
{showEditor && (
<SignatureEditorModal
signature={editingSignature}
onSave={handleSave}
onClose={() => {
setShowEditor(false);
setEditingSignature(null);
}}
/>
)}
<ConfirmDialog
isOpen={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDeleteConfirm}
title={t('delete_title')}
message={t('delete_message', { name: deleteTarget?.name ?? '' })}
variant="destructive"
confirmText={tCommon('delete')}
/>
</>
);
}
+383
View File
@@ -0,0 +1,383 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import {
X,
Loader2,
UserPlus,
Trash2,
Users,
ChevronDown,
} from "lucide-react";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Principal } from "@/lib/jmap/types";
import { useSharingStore, type SharedResourceKind } from "@/stores/sharing-store";
export interface ShareFolderDialogProps {
client: IJMAPClient;
resourceId: string;
resourceName: string;
resourceKind: SharedResourceKind;
onClose: () => void;
}
const PRESET_OPTIONS: Record<SharedResourceKind, readonly string[]> = {
mailbox: ["read", "readWrite", "manager"],
calendar: ["read", "readWrite", "manager"],
addressBook: ["read", "readWrite", "manager"],
file: ["read", "readWrite", "manager"],
};
export function ShareFolderDialog({
client,
resourceId,
resourceName,
resourceKind,
onClose,
}: ShareFolderDialogProps) {
const t = useTranslations("sharing");
const tCommon = useTranslations("common");
const modalRef = useRef<HTMLDivElement>(null);
const sharedByMe = useSharingStore((s) => s.sharedByMe);
const loadPrincipals = useSharingStore((s) => s.loadPrincipals);
const shareFolder = useSharingStore((s) => s.shareFolder);
const revokeShare = useSharingStore((s) => s.revokeShare);
const changeRole = useSharingStore((s) => s.changeRole);
const [allPrincipals, setAllPrincipals] = useState<Principal[]>([]);
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
const [search, setSearch] = useState("");
const [savingId, setSavingId] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [message, setMessage] = useState("");
useEffect(() => {
let cancelled = false;
setLoadingPrincipals(true);
loadPrincipals(client)
.then((list) => {
if (cancelled) return;
setAllPrincipals(list);
setLoadingPrincipals(false);
})
.catch(() => {
if (!cancelled) setLoadingPrincipals(false);
});
return () => {
cancelled = true;
};
}, [client, loadPrincipals]);
const ownAccountId = client.getAccountId();
const allPrincipalsById = useMemo(() => {
const map = new Map<string, Principal>();
for (const p of allPrincipals) map.set(p.id, p);
return map;
}, [allPrincipals]);
const currentShares = sharedByMe.filter(
(f) => f.resourceId === resourceId && f.resourceKind === resourceKind,
);
const principals = useMemo(() => {
const existing = new Set(currentShares.map((s) => s.principalId));
return allPrincipals.filter(
(p) => p.id !== ownAccountId && !existing.has(p.id),
);
}, [allPrincipals, ownAccountId, currentShares]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const handleRemove = async (principalId: string) => {
setSavingId(principalId);
try {
await revokeShare(client, resourceId, resourceKind, principalId);
} catch {
/* error toast comes from store */
} finally {
setSavingId(null);
}
};
const handleChangeRole = async (principalId: string, role: string) => {
setSavingId(principalId);
try {
await changeRole(client, resourceId, resourceKind, principalId, role);
} catch {
/* error toast comes from store */
} finally {
setSavingId(null);
}
};
const handleAdd = async (principal: Principal) => {
setSavingId(principal.id);
try {
await shareFolder(
client,
resourceId,
resourceName,
resourceKind,
principal.id,
"read",
message || undefined,
);
setShowAdd(false);
setSearch("");
setMessage("");
} catch {
/* error toast comes from store */
} finally {
setSavingId(null);
}
};
const filteredPrincipals = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return principals;
return principals.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.email?.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q),
);
}, [principals, search]);
const presetOptions = PRESET_OPTIONS[resourceKind];
const kindLabels: Record<SharedResourceKind, string> = {
mailbox: "Mail folder",
calendar: "Calendar",
addressBook: "Address book",
file: "File folder",
};
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", { name: resourceName })}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 animate-in zoom-in-95 duration-200 max-h-[85vh] flex flex-col"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-primary" />
<div>
<h2 className="text-lg font-semibold">
{t("title", { name: resourceName })}
</h2>
<p className="text-xs text-muted-foreground">
{kindLabels[resourceKind]}
</p>
</div>
</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 overflow-y-auto">
<p className="text-sm text-muted-foreground">
{t("description")}
</p>
{currentShares.length === 0 && !showAdd && (
<div className="text-sm text-muted-foreground italic py-4 text-center">
{t("no_shares")}
</div>
)}
{currentShares.length > 0 && (
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
{currentShares.map((share) => {
const principal = allPrincipalsById.get(share.principalId);
return (
<li
key={share.id}
className="flex items-center gap-3 px-3 py-2.5"
>
<Avatar
name={principal?.name}
email={principal?.email ?? undefined}
size="sm"
className="shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{principal?.name ||
principal?.email ||
share.principalId}
</div>
{principal?.description && (
<div className="text-xs text-muted-foreground truncate">
{principal.description}
</div>
)}
</div>
<div className="relative">
<select
value={share.role}
onChange={(e) =>
handleChangeRole(share.principalId, e.target.value)
}
disabled={savingId === share.principalId}
className="appearance-none rounded-md border border-input bg-background ps-3 pe-8 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
>
{presetOptions.map((p) => (
<option key={p} value={p}>
{t(`preset.${p}`)}
</option>
))}
{share.role === "custom" && (
<option value="custom">
{t("preset.custom")}
</option>
)}
</select>
<ChevronDown className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground" />
</div>
<button
onClick={() => handleRemove(share.principalId)}
disabled={savingId === share.principalId}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
aria-label={t("remove")}
title={t("remove")}
>
{savingId === share.principalId ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
</li>
);
})}
</ul>
)}
{!showAdd && (
<Button
variant="outline"
onClick={() => setShowAdd(true)}
className="w-full"
>
<UserPlus className="w-4 h-4 me-2" />
{t("add_person")}
</Button>
)}
{showAdd && (
<div className="space-y-2 border border-border rounded-md p-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("search_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"
autoFocus
/>
<div className="max-h-48 overflow-y-auto -mx-1">
{loadingPrincipals && (
<div className="flex items-center justify-center py-4 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin me-2" />
{t("loading_principals")}
</div>
)}
{!loadingPrincipals &&
filteredPrincipals.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-3">
{search.trim()
? t("no_match")
: t("no_principals")}
</div>
)}
{!loadingPrincipals &&
filteredPrincipals.map((p) => (
<button
key={p.id}
onClick={() => handleAdd(p)}
disabled={savingId === p.id}
className="w-full text-start px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
>
<div className="flex items-center gap-2">
<Avatar
name={p.name}
email={p.email ?? undefined}
size="sm"
className="shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate flex items-center gap-2">
{p.name}
{p.type === "group" && (
<span className="text-[10px] uppercase font-normal text-muted-foreground bg-muted rounded px-1 py-0.5">
{t("group")}
</span>
)}
</div>
{p.email && p.email !== p.name && (
<div className="text-xs text-muted-foreground truncate">
{p.email}
</div>
)}
</div>
{savingId === p.id && (
<Loader2 className="w-4 h-4 animate-spin" />
)}
</div>
</button>
))}
</div>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Optional message…"
rows={2}
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 resize-none"
/>
<div className="flex justify-end pt-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowAdd(false);
setSearch("");
setMessage("");
}}
>
{tCommon("cancel")}
</Button>
</div>
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button onClick={onClose}>{tCommon("close")}</Button>
</div>
</div>
</div>
);
}
+216
View File
@@ -0,0 +1,216 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface RadialMenuItem {
id: string;
icon: React.ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
destructive?: boolean;
}
interface RadialMenuProps {
items: RadialMenuItem[];
isOpen: boolean;
position: { x: number; y: number };
onClose: () => void;
size?: number;
}
export function RadialMenu({
items,
isOpen,
position,
onClose,
size = 200,
}: RadialMenuProps) {
const [mounted, setMounted] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const [animatingIn, setAnimatingIn] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isOpen) {
requestAnimationFrame(() => requestAnimationFrame(() => setAnimatingIn(true)));
} else {
setAnimatingIn(false);
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
setActiveIndex(-1);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
return;
}
if (e.key === "Enter" && activeIndex >= 0 && activeIndex < items.length) {
e.preventDefault();
const item = items[activeIndex];
if (!item.disabled) {
item.onClick();
onClose();
}
return;
}
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((prev) => {
let next = prev + 1;
if (next >= items.length) next = 0;
let loops = 0;
while (items[next]?.disabled && loops < items.length) {
next = next + 1 >= items.length ? 0 : next + 1;
loops++;
}
return next;
});
return;
}
if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((prev) => {
let next = prev - 1;
if (next < 0) next = items.length - 1;
let loops = 0;
while (items[next]?.disabled && loops < items.length) {
next = next - 1 < 0 ? items.length - 1 : next - 1;
loops++;
}
return next;
});
return;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, activeIndex, items, onClose]);
const radius = size / 2 - 28;
const center = size / 2;
if (!mounted) return null;
return createPortal(
<>
<div
className={cn(
"fixed inset-0 z-[9998] bg-black/20 cursor-pointer transition-opacity duration-200",
animatingIn ? "opacity-100" : "opacity-0 pointer-events-none"
)}
onClick={onClose}
/>
<div
ref={menuRef}
className="fixed z-[9999]"
style={{
left: position.x - center,
top: position.y - center,
width: size,
height: size,
}}
role="menu"
aria-label="Action menu"
>
<div
className={cn(
"absolute rounded-full flex items-center justify-center transition-all duration-200 ease-out will-change-transform",
animatingIn ? "scale-100 opacity-100" : "scale-0 opacity-0"
)}
style={{
left: center - 24,
top: center - 24,
width: 48,
height: 48,
}}
>
<button
className="w-12 h-12 rounded-full bg-background border border-border shadow-lg flex items-center justify-center hover:bg-muted transition-colors cursor-pointer"
onClick={onClose}
aria-label="Close menu"
>
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{items.map((item, index) => {
const angle = (index / items.length) * 2 * Math.PI - Math.PI / 2;
const x = center + radius * Math.cos(angle);
const y = center + radius * Math.sin(angle);
const itemSize = 40;
return (
<div
key={item.id}
className={cn(
"absolute transition-all duration-200 ease-out will-change-transform",
animatingIn ? "scale-100 opacity-100" : "scale-0 opacity-0"
)}
style={{
left: x - itemSize / 2,
top: y - itemSize / 2,
width: itemSize,
height: itemSize,
transitionDelay: animatingIn ? `${index * 35}ms` : "0ms",
}}
>
<button
className={cn(
"group relative flex items-center justify-center w-full h-full rounded-full shadow-lg border border-border transition-all duration-150 cursor-pointer focus:outline-none",
item.disabled
? "opacity-30 cursor-not-allowed bg-muted"
: item.destructive
? "bg-destructive/10 text-destructive hover:scale-125 hover:bg-destructive hover:text-destructive-foreground hover:border-destructive"
: "bg-background text-foreground hover:scale-125 hover:bg-primary hover:text-primary-foreground hover:border-primary",
activeIndex === index && !item.disabled && "scale-125 ring-2 ring-primary"
)}
disabled={item.disabled}
onClick={(e) => {
e.stopPropagation();
if (item.disabled) return;
item.onClick();
onClose();
}}
onMouseEnter={() => setActiveIndex(index)}
onMouseLeave={() => setActiveIndex(-1)}
onFocus={() => setActiveIndex(index)}
onBlur={() => setActiveIndex(-1)}
role="menuitem"
aria-label={item.label}
tabIndex={activeIndex === index ? 0 : -1}
>
<span className="w-5 h-5 flex items-center justify-center [&>svg]:w-full [&>svg]:h-full">
{item.icon}
</span>
<span
className={cn(
"absolute -bottom-7 left-1/2 -translate-x-1/2 whitespace-nowrap text-[11px] font-medium leading-tight text-foreground bg-background/95 px-1.5 py-0.5 rounded shadow-sm border border-border/50",
"opacity-0 group-hover:opacity-100 transition-opacity duration-100 pointer-events-none",
activeIndex === index && !item.disabled && "opacity-100"
)}
>
{item.label}
</span>
</button>
</div>
);
})}
</div>
</>,
document.body
);
}