Files
SRCmail/components/email/calendar-invitation-banner.tsx
T
Linus Rath cf02f587de feat: resizable columns, nav rail overhaul, multi-select & drag-drop, UI polish
Resizable Columns
- Add ResizeHandle component with mouse drag, keyboard (Arrow keys),
  and double-click to reset to default width
- Add sidebarWidth/emailListWidth to ui-store with clamping + persistence
- Wire resize handles between sidebar/email-list panels on desktop

Navigation Rail Overhaul
- Move StorageQuotaCircle, push-status, sign-out from Sidebar to NavigationRail
- Interactive SVG ring with popover breakdown (used/free/total)
- Sidebar collapse state lifted to ui-store
- Show total email count per mailbox alongside unread badge

Email Multi-Selection & Drag-and-Drop
- Ctrl+Click (toggle) and Shift+Click (range) on all list items
- Add selectRangeEmails and lastSelectedEmailId to email-store
- Enable drag-and-drop on thread items and thread headers
- useEmailDrag accepts optional threadEmails for full-thread drag

Email Viewer Layout
- Remove card wrapper for cleaner full-width reading
- Always render HTML body when available
- Adjust skeleton loader to match flat layout

Modal & UI Polish
- Standardise backdrops, close buttons, padding, border-radius, transitions
- Migrate template-string classNames to cn() in settings
- Unify focus-ring token to ring-ring on form controls

i18n
- Add storage_used/free/total keys to all 8 locales

Dev Mock JMAP Server (new, gated by DEV_MOCK_JMAP=true)
- Session, Mailbox/Email/Thread/Identity CRUD, back-references, upload
- GET /download with Content-Disposition, GET /eventsource SSE

Tests (46 new)
- ui-store (13), email-selection (10), resize-handle (9), mock-server (14)
2026-03-09 16:46:25 +01:00

390 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Calendar,
CalendarCheck,
CalendarX,
Clock,
MapPin,
Users,
Loader2,
Check,
HelpCircle,
X,
AlertCircle,
ChevronDown,
} from 'lucide-react';
import { useTranslations, useFormatter } from 'next-intl';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
import type { Email, CalendarEvent } from '@/lib/jmap/types';
import {
findCalendarAttachment,
getInvitationMethod,
formatEventSummary,
findParticipantByEmail,
} from '@/lib/calendar-invitation';
import { cn } from '@/lib/utils';
import { sanitizeColor } from '@/components/calendar/event-card';
interface CalendarInvitationBannerProps {
email: Email;
}
type BannerState = 'loading' | 'parsed' | 'rsvp-sent' | 'imported' | 'error';
export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProps) {
const t = useTranslations('email_viewer.calendar_invitation');
const format = useFormatter();
const client = useAuthStore((s) => s.client);
const currentUserEmail = useAuthStore((s) => s.primaryIdentity?.email);
const { calendars, supportsCalendar, importEvents, rsvpEvent, events: storeEvents } = useCalendarStore();
const [state, setState] = useState<BannerState>('loading');
const [parsedEvent, setParsedEvent] = useState<Partial<CalendarEvent> | null>(null);
const [rsvpStatus, setRsvpStatus] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
const attachment = findCalendarAttachment(email);
const parseEvent = useCallback(async () => {
if (!client || !attachment) return;
setState('loading');
try {
const events = await client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId);
if (events.length > 0) {
const parsed = events[0];
setParsedEvent(parsed);
if (parsed.uid && supportsCalendar) {
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
if (!storeHasIt) {
try {
const serverEvents = await client.queryCalendarEvents({});
const matching = serverEvents.filter((e) => e.uid === parsed.uid);
if (matching.length > 0) {
useCalendarStore.setState((s) => {
const existingIds = new Set(s.events.map((e) => e.id));
const newEvents = matching.filter((e) => !existingIds.has(e.id));
return newEvents.length > 0 ? { events: [...s.events, ...newEvents] } : s;
});
}
} catch { /* ignore lookup failure */ }
}
}
setState('parsed');
} else {
setState('error');
}
} catch {
setState('error');
}
}, [client, attachment, supportsCalendar]);
useEffect(() => {
if (attachment) {
parseEvent();
}
}, [email.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (calendars.length > 0 && !selectedCalendarId) {
const defaultCal = calendars.find((c) => c.isDefault) || calendars[0];
setSelectedCalendarId(defaultCal.id);
}
}, [calendars, selectedCalendarId]);
if (!attachment) return null;
const method = parsedEvent ? getInvitationMethod(parsedEvent) : 'unknown';
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
const isCancellation = method === 'cancel';
const existingEvent = parsedEvent?.uid
? storeEvents.find((e) => e.uid === parsedEvent.uid)
: null;
const myParticipantParsed = parsedEvent && currentUserEmail
? findParticipantByEmail(parsedEvent, currentUserEmail)
: null;
const myParticipantServer = existingEvent && currentUserEmail
? findParticipantByEmail(existingEvent, currentUserEmail)
: null;
const myParticipant = myParticipantServer || myParticipantParsed;
const currentRsvp = rsvpStatus
|| myParticipantServer?.participant.participationStatus
|| myParticipantParsed?.participant.participationStatus
|| null;
const handleRsvp = async (status: 'accepted' | 'tentative' | 'declined') => {
if (!client || !parsedEvent || isProcessing) return;
const calId = selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id;
setIsProcessing(true);
try {
if (existingEvent && myParticipant) {
await rsvpEvent(client, existingEvent.id, myParticipant.id, status);
setRsvpStatus(status);
setState('rsvp-sent');
} else if (calId) {
const imported = await importEvents(client, [parsedEvent], calId);
if (imported > 0) {
const newEvent = useCalendarStore.getState().events.find(
(e) => e.uid === parsedEvent.uid
);
const participant = myParticipant
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
if (newEvent && participant) {
await rsvpEvent(client, newEvent.id, participant.id, status);
}
setRsvpStatus(status);
setState('rsvp-sent');
} else {
setState('error');
}
} else {
setState('error');
}
} catch {
setState('error');
} finally {
setIsProcessing(false);
}
};
const handleImport = async (calendarId?: string) => {
const calId = calendarId || selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id;
if (!client || !parsedEvent || !calId || isProcessing) {
if (!calId) setState('error');
return;
}
setIsProcessing(true);
try {
const count = await importEvents(client, [parsedEvent], calId);
if (count > 0) {
setState('imported');
} else {
setState('error');
}
} catch {
setState('error');
} finally {
setIsProcessing(false);
}
};
const formatDateTime = (dateStr: string | null) => {
if (!dateStr) return '';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
return format.dateTime(date, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
};
if (state === 'loading') {
return (
<div className="flex items-center gap-2">
<Calendar className="w-3.5 h-3.5 text-primary" />
<Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">{t('loading')}</span>
</div>
);
}
if (state === 'error') {
return (
<div className="flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">{t('parse_error')}</span>
</div>
);
}
if (state === 'imported') {
return (
<div className="flex items-center gap-2">
<CalendarCheck className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
<span className="text-sm text-muted-foreground">{t('added')}</span>
</div>
);
}
if (state === 'rsvp-sent') {
return (
<div className="flex items-center gap-2">
<CalendarCheck className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
<span className="text-sm text-muted-foreground">{t('rsvp_sent')}</span>
</div>
);
}
return (
<div className="flex flex-col gap-2">
{/* Event info row */}
<div className="flex items-start gap-2 flex-wrap">
{isCancellation ? (
<CalendarX className="w-4 h-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
) : (
<Calendar className="w-4 h-4 text-primary mt-0.5 flex-shrink-0" />
)}
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className={cn(
"text-sm font-medium",
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
)}>
{isCancellation ? t('cancelled_title') : t('title')}
{summary?.title && `: ${summary.title}`}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
{summary?.start && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatDateTime(summary.start)}
{summary.end && ` ${formatDateTime(summary.end)}`}
</span>
)}
{summary?.location && (
<span className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
{summary.location}
</span>
)}
{summary?.organizer && (
<span>{t('organizer', { name: summary.organizer })}</span>
)}
{summary && summary.attendeeCount > 0 && (
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{t('attendees', { count: summary.attendeeCount })}
</span>
)}
</div>
</div>
</div>
{/* Action row */}
{!isCancellation && (
<div className="flex items-center gap-2 ml-6 flex-wrap">
{supportsCalendar && myParticipant && (
<>
<button
onClick={() => handleRsvp('accepted')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'accepted'}
className={cn(
"flex items-center gap-1 text-sm px-2 py-0.5 rounded-md transition-colors duration-150 min-h-[44px] md:min-h-0 disabled:opacity-50",
currentRsvp === 'accepted'
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
<Check className="w-3.5 h-3.5" />
{t('accept')}
</button>
<button
onClick={() => handleRsvp('tentative')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'tentative'}
className={cn(
"flex items-center gap-1 text-sm px-2 py-0.5 rounded-md transition-colors duration-150 min-h-[44px] md:min-h-0 disabled:opacity-50",
currentRsvp === 'tentative'
? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
<HelpCircle className="w-3.5 h-3.5" />
{t('maybe')}
</button>
<button
onClick={() => handleRsvp('declined')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'declined'}
className={cn(
"flex items-center gap-1 text-sm px-2 py-0.5 rounded-md transition-colors duration-150 min-h-[44px] md:min-h-0 disabled:opacity-50",
currentRsvp === 'declined'
? "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
<X className="w-3.5 h-3.5" />
{t('decline')}
</button>
<div className="w-px h-4 bg-border" />
</>
)}
{supportsCalendar && existingEvent && !myParticipant && (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<CalendarCheck className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
{t('already_in_calendar')}
</span>
)}
{supportsCalendar && !existingEvent && (
<div className="relative">
<button
onClick={() => {
if (calendars.length <= 1) {
handleImport();
} else {
setShowCalendarPicker(!showCalendarPicker);
}
}}
disabled={isProcessing}
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground hover:bg-muted px-2 py-0.5 rounded-md transition-colors duration-150 min-h-[44px] md:min-h-0 disabled:opacity-50"
>
<CalendarCheck className="w-3.5 h-3.5" />
{t('add_to_calendar')}
{calendars.length > 1 && <ChevronDown className="w-3 h-3" />}
</button>
{showCalendarPicker && calendars.length > 1 && (
<div className="absolute left-0 top-full mt-1 w-52 bg-background rounded-md shadow-lg border border-border z-10 py-1">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{t('select_calendar')}
</div>
{calendars.map((cal) => (
<button
key={cal.id}
onClick={() => {
setShowCalendarPicker(false);
handleImport(cal.id);
}}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: sanitizeColor(cal.color) }}
/>
<span className="truncate text-foreground">{cal.name}</span>
</button>
))}
</div>
)}
</div>
)}
{!supportsCalendar && (
<span className="text-xs text-muted-foreground italic">{t('no_calendar')}</span>
)}
</div>
)}
</div>
);
}