feat: P2.3 Folder Sharing + P2.5 Email Import + P2.6 Contact Import + P2.7 Free/Busy

- P2.3: Folder sharing system — ShareFolderDialog, sharing-store, sharing-settings
- P2.5: Email import (.eml, .tgz, .zip) with dedup and progress
- P2.6: Contact import (vCard + CSV) with auto-mapping
- P2.7: Free/Busy view grid with color-coded slots
This commit is contained in:
Bernd Rodler
2026-08-07 13:32:33 +02:00
parent 83e29b3ef1
commit e7acf56753
21 changed files with 3321 additions and 43 deletions
+41 -1
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 } 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,
@@ -351,6 +352,7 @@ export function EventModal({
.map(p => ({ name: p.name, email: p.email }));
});
const [sendInvitations, setSendInvitations] = useState(true);
const [showFreeBusy, setShowFreeBusy] = useState(false);
const participantInputRef = useRef<ParticipantInputHandle>(null);
// Plugin transform: collect conflict warnings for the current event form.
@@ -1074,6 +1076,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", {
+296
View File
@@ -0,0 +1,296 @@
"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 FreeBusyViewProps {
participants: { name?: string; email: string }[];
startDate: Date;
endDate: Date;
onTimeSelect?: (start: Date, end: Date) => void;
}
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,
}: 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>
);
})}
</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>
);
}
+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 })}
@@ -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 />
+3
View File
@@ -90,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).
@@ -779,6 +780,7 @@ export function Sidebar({
onDeleteFolder,
onImportEmail,
onRefreshMailboxes,
onShareFolder,
scheduledTotal = 0,
showScheduledMailbox = false,
crossAccountActive = false,
@@ -1452,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>
);
}
+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>
);
}