fix: align calendar invitation icon with sender avatar column

This commit is contained in:
Linus Rath
2026-05-06 00:45:37 +02:00
parent b5e0189938
commit 2903e56cf6
18 changed files with 304 additions and 257 deletions
+287 -256
View File
@@ -37,6 +37,7 @@ import {
} from '@/lib/calendar-invitation'; } from '@/lib/calendar-invitation';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { sanitizeColor } from '@/components/calendar/event-card'; import { sanitizeColor } from '@/components/calendar/event-card';
import { RecipientPopover } from './recipient-popover';
interface InvitationChangeItem { interface InvitationChangeItem {
label: string; label: string;
@@ -327,25 +328,26 @@ function buildParticipantsForRsvp(
); );
} }
function getMethodAccentClass(method: InvitationMethod, actorStatus?: string | null): string { function getMethodIconTone(method: InvitationMethod, actorStatus?: string | null): string {
switch (method) { switch (method) {
case 'cancel': case 'cancel':
case 'declinecounter': case 'declinecounter':
return 'border-l-red-500 dark:border-l-red-400'; return 'bg-destructive/15 text-destructive';
case 'request':
case 'add':
return 'border-l-blue-500 dark:border-l-blue-400';
case 'counter': case 'counter':
return 'border-l-amber-500 dark:border-l-amber-400'; return 'bg-warning/15 text-warning';
case 'reply': case 'reply':
switch (actorStatus) { switch (actorStatus) {
case 'accepted': return 'border-l-green-500 dark:border-l-green-400'; case 'accepted': return 'bg-success/15 text-success';
case 'tentative': return 'border-l-amber-500 dark:border-l-amber-400'; case 'tentative': return 'bg-warning/15 text-warning';
case 'declined': return 'border-l-red-500 dark:border-l-red-400'; case 'declined': return 'bg-destructive/15 text-destructive';
default: return 'border-l-blue-500 dark:border-l-blue-400'; default: return 'bg-primary/15 text-primary';
} }
case 'request':
case 'add':
case 'publish':
return 'bg-primary/15 text-primary';
default: default:
return 'border-l-slate-400 dark:border-l-slate-500'; return 'bg-muted text-muted-foreground';
} }
} }
@@ -500,7 +502,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const actorName = actorSummary?.name || actorSummary?.email || t('actor_unknown'); const actorName = actorSummary?.name || actorSummary?.email || t('actor_unknown');
const actorStatus = getParticipationLabel(t, actorSummary?.participationStatus ?? null); const actorStatus = getParticipationLabel(t, actorSummary?.participationStatus ?? null);
const actorMessage = actorSummary ? getActorMessage(t, method, actorName, actorStatus) : null; const actorMessage = actorSummary ? getActorMessage(t, method, actorName, actorStatus) : null;
const actionFeedback = actionNotice;
// For REQUEST method, allow RSVP even if we can't find the user in participants: // For REQUEST method, allow RSVP even if we can't find the user in participants:
// the email was sent TO the user, so they are an attendee. handleRsvp handles // the email was sent TO the user, so they are an attendee. handleRsvp handles
// the import-then-find-participant flow for this case. // the import-then-find-participant flow for this case.
@@ -735,130 +736,151 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
} }
}; };
const accentClass = getMethodAccentClass(method, actorSummary?.participationStatus);
if (state === 'loading') { if (state === 'loading') {
return ( return (
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 flex items-center gap-2.5"> <div className="flex items-center gap-3">
<Calendar className="w-4 h-4 text-primary flex-shrink-0" /> <div className="w-10 h-10 rounded-full bg-muted text-muted-foreground flex items-center justify-center flex-shrink-0 shadow-sm">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" /> <Calendar className="w-5 h-5" />
<span className="text-sm text-muted-foreground">{t('loading')}</span> </div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>{t('loading')}</span>
</div>
</div> </div>
); );
} }
if (state === 'error') { if (state === 'error') {
return ( return (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 px-4 py-3 flex items-center gap-2.5"> <div className="flex items-center gap-3">
<AlertCircle className="w-4 h-4 text-destructive flex-shrink-0" /> <div className="w-10 h-10 rounded-full bg-destructive/15 text-destructive flex items-center justify-center flex-shrink-0 shadow-sm">
<AlertCircle className="w-5 h-5" />
</div>
<span className="text-sm text-destructive">{t('parse_error')}</span> <span className="text-sm text-destructive">{t('parse_error')}</span>
</div> </div>
); );
} }
const iconTone = getMethodIconTone(method, actorSummary?.participationStatus);
const hasStatusPills = Boolean(
existingEvent
|| userIsOrganizer
|| (participationLabel && myParticipant)
|| actionNotice
|| (parsedEvent?.status && parsedEvent.status !== 'confirmed')
);
const showActionsRow = showDetails && (
canRespond
|| (supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation)
|| canApplyProposal
|| (supportsCalendar && (existingEvent || parsedEvent))
|| !supportsCalendar
);
return ( return (
<div className={cn("rounded-lg border border-border overflow-hidden border-l-4", accentClass)}> <div className="flex items-start gap-3">
{/* Header */} {/* Avatar-style icon */}
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b border-border"> <div className={cn(
<div className="flex items-center gap-2 min-w-0"> "w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
{isCancellation ? ( iconTone,
<CalendarX className="w-4 h-4 text-destructive flex-shrink-0" /> )}>
) : ( {isCancellation ? (
<Calendar className="w-4 h-4 text-primary flex-shrink-0" /> <CalendarX className="w-5 h-5" />
)} ) : (
<span className="text-sm font-medium text-foreground truncate">{bannerTitle}</span> <Calendar className="w-5 h-5" />
</div>
{canCollapse && (
<button
type="button"
onClick={() => setIsCollapsed((prev) => !prev)}
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? t('expand') : t('collapse')}
title={isCollapsed ? t('expand') : t('collapse')}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
>
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
</button>
)} )}
</div> </div>
{/* Content */} {/* Content column */}
{showDetails && ( <div className="flex-1 min-w-0 space-y-2">
<div className="px-4 py-3 space-y-2.5"> {/* Eyebrow + title + collapse */}
<div className="lg:flex lg:gap-6"> <div className="flex items-start justify-between gap-2">
{/* Left: Event info */} <div className="min-w-0 flex-1">
<div className="lg:flex-1 space-y-2.5 min-w-0"> <div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{/* Event title */} {bannerTitle}
{summary?.title && (
<div className="flex items-start justify-between gap-2">
<h3 className={cn(
"text-base font-semibold leading-snug",
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
)}>
{summary.title}
</h3>
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap">
{t('event_updated', { sequence: parsedEvent.sequence })}
</span>
)}
</div>
)}
{/* Event details */}
<div className="lg:flex lg:items-center lg:gap-4 lg:flex-wrap space-y-1 lg:space-y-0">
{summary?.start && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
<span>
{formatDateTime(summary.start)}
{summary.end && ` ${formatDateTime(summary.end)}`}
</span>
</div>
)}
{summary?.location && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
<span>{summary.location}</span>
</div>
)}
{summary?.organizer && (
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Users className="w-3.5 h-3.5 flex-shrink-0" />
{t('organizer', { name: summary.organizer })}
</span>
)}
{summary && summary.attendeeCount > 0 && (
<span className="text-sm text-muted-foreground">{t('attendees', { count: summary.attendeeCount })}</span>
)}
</div> </div>
{summary?.title && (
<h3 className={cn(
"text-sm font-semibold leading-snug break-words",
isCancellation ? "line-through text-muted-foreground" : "text-foreground",
)}>
{summary.title}
</h3>
)}
</div> </div>
{/* Right: Info & actor messages on large screens */} <div className="flex items-center gap-1.5 flex-shrink-0">
<div className="lg:flex-shrink-0 lg:text-right lg:max-w-xs mt-2.5 lg:mt-0 space-y-1"> {parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
{bannerInfo && ( <span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground whitespace-nowrap">
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p> {t('event_updated', { sequence: parsedEvent.sequence })}
</span>
)} )}
{actorMessage && ( {canCollapse && (
<p className="text-xs text-muted-foreground">{actorMessage}</p> <button
)} type="button"
{actorSummary?.participationComment && ( onClick={() => setIsCollapsed((prev) => !prev)}
<p className="text-xs text-muted-foreground italic"> aria-expanded={!isCollapsed}
{t('actor_note', { comment: actorSummary.participationComment })} className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors"
</p> >
{isCollapsed ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
{isCollapsed ? t('expand') : t('collapse')}
</button>
)} )}
</div> </div>
</div> </div>
{/* Status badges */} {/* Meta rows */}
{(existingEvent || userIsOrganizer || (participationLabel && myParticipant) || actionFeedback || (parsedEvent?.status && parsedEvent.status !== 'confirmed')) && ( {showDetails && summary && (summary.start || summary.location || summary.attendeeCount > 0) && (
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex flex-col gap-1 text-sm text-muted-foreground sm:flex-row sm:flex-wrap sm:items-center sm:gap-x-4 sm:gap-y-1">
{summary.start && (
<span className="flex items-center gap-1.5 min-w-0">
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">
{formatDateTime(summary.start)}
{summary.end && ` ${formatDateTime(summary.end)}`}
</span>
</span>
)}
{summary.location && (
<span className="flex items-center gap-1.5 min-w-0">
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{summary.location}</span>
</span>
)}
{summary.attendeeCount > 0 && (
<span className="text-muted-foreground/80">{t('attendees', { count: summary.attendeeCount })}</span>
)}
</div>
)}
{/* Organizer row (clickable, left-aligned) */}
{showDetails && summary?.organizer && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground min-w-0">
<Users className="w-3.5 h-3.5 flex-shrink-0" />
<span className="flex-shrink-0">{t('organizer_label')}</span>
{summary.organizerEmail ? (
<RecipientPopover
name={summary.organizer}
email={summary.organizerEmail}
className="text-sm truncate"
/>
) : (
<span className="truncate text-foreground">{summary.organizer}</span>
)}
</div>
)}
{/* Status pills */}
{showDetails && hasStatusPills && (
<div className="flex flex-wrap items-center gap-1.5">
{parsedEvent?.status && parsedEvent.status !== 'confirmed' && ( {parsedEvent?.status && parsedEvent.status !== 'confirmed' && (
<span className={cn( <span className={cn(
"rounded-full px-2 py-0.5 text-[11px] font-medium", "rounded-full px-2 py-0.5 text-[11px] font-medium",
parsedEvent.status === 'cancelled' parsedEvent.status === 'cancelled'
? "bg-destructive/15 text-destructive" ? "bg-destructive/15 text-destructive"
: "bg-warning/15 text-warning" : "bg-warning/15 text-warning",
)}> )}>
{t(`event_status_${parsedEvent.status}`)} {t(`event_status_${parsedEvent.status}`)}
</span> </span>
@@ -876,21 +898,45 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
{participationLabel && myParticipant && ( {participationLabel && myParticipant && (
<span className={cn( <span className={cn(
'rounded-full px-2 py-0.5 text-[11px] font-medium', 'rounded-full px-2 py-0.5 text-[11px] font-medium',
getParticipationTone(currentRsvp) getParticipationTone(currentRsvp),
)}> )}>
{t('your_response', { status: participationLabel })} {t('your_response', { status: participationLabel })}
</span> </span>
)} )}
{actionFeedback && ( {actionNotice && (
<span className="rounded-full bg-success/15 px-2 py-0.5 text-[11px] font-medium text-success"> <span className="rounded-full bg-success/15 px-2 py-0.5 text-[11px] font-medium text-success">
{actionFeedback} {actionNotice}
</span> </span>
)} )}
</div> </div>
)} )}
{/* Info / actor messages */}
{showDetails && (bannerInfo || actorMessage || actorSummary?.participationComment) && (
<div className="space-y-0.5 text-xs text-muted-foreground">
{bannerInfo && <p className="leading-relaxed">{bannerInfo}</p>}
{actorMessage && <p>{actorMessage}</p>}
{actorSummary?.participationComment && (
<p className="italic">{t('actor_note', { comment: actorSummary.participationComment })}</p>
)}
</div>
)}
{/* Trust warning */}
{showDetails && trustMessage && trustAssessment && (
<div className={cn(
'flex items-start gap-2 text-sm rounded-md px-3 py-2 border',
trustAssessment.level === 'warning'
? 'bg-destructive/10 text-destructive border-destructive/30'
: 'bg-warning/10 text-warning border-warning/30',
)}>
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span className="flex-1">{trustMessage}</span>
</div>
)}
{/* Proposed changes */} {/* Proposed changes */}
{proposedChanges.length > 0 && ( {showDetails && proposedChanges.length > 0 && (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs"> <div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
<div className="font-medium text-foreground mb-1.5">{t('proposed_changes')}</div> <div className="font-medium text-foreground mb-1.5">{t('proposed_changes')}</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -904,169 +950,154 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
</div> </div>
)} )}
{/* Trust warning */}
{trustMessage && trustAssessment && (
<div className={cn(
'flex items-start gap-1.5 text-xs rounded-md px-3 py-2',
trustAssessment.level === 'warning'
? 'bg-destructive/10 text-destructive border border-destructive/20'
: 'bg-warning/10 text-warning border border-warning/20'
)}>
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{trustMessage}</span>
</div>
)}
{/* Action error */} {/* Action error */}
{actionError && ( {showDetails && actionError && (
<div className="flex items-start gap-1.5 text-xs text-destructive rounded-md px-3 py-2 bg-destructive/10 border border-destructive/20"> <div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-destructive/10 text-destructive border border-destructive/30">
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" /> <AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{actionError}</span> <span className="flex-1">{actionError}</span>
</div> </div>
)} )}
</div>
)}
{/* Actions */} {/* Actions */}
{showDetails && ( {showActionsRow && (
<div className="px-4 py-2.5 border-t border-border bg-muted/20 flex items-center gap-2 flex-wrap"> <div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{canRespond && ( {canRespond && (
<> <>
<button <button
onClick={() => handleRsvp('accepted')} onClick={() => handleRsvp('accepted')}
disabled={isProcessing} disabled={isProcessing}
aria-pressed={currentRsvp === 'accepted'} aria-pressed={currentRsvp === 'accepted'}
className={cn( className={cn(
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border", "inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
currentRsvp === 'accepted' currentRsvp === 'accepted'
? "bg-success/15 text-success border-success/20" ? "bg-success/15 text-success border-success/30"
: "text-muted-foreground hover:text-success border-border hover:border-success/30 hover:bg-success/10" : "text-muted-foreground hover:text-success border-border hover:border-success/30 hover:bg-success/10",
)} )}
> >
<Check className="w-3.5 h-3.5" /> <Check className="w-3.5 h-3.5" />
{t('accept')} {t('accept')}
</button> </button>
<button <button
onClick={() => handleRsvp('tentative')} onClick={() => handleRsvp('tentative')}
disabled={isProcessing} disabled={isProcessing}
aria-pressed={currentRsvp === 'tentative'} aria-pressed={currentRsvp === 'tentative'}
className={cn( className={cn(
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border", "inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
currentRsvp === 'tentative' currentRsvp === 'tentative'
? "bg-warning/15 text-warning border-warning/20" ? "bg-warning/15 text-warning border-warning/30"
: "text-muted-foreground hover:text-warning border-border hover:border-warning/30 hover:bg-warning/10" : "text-muted-foreground hover:text-warning border-border hover:border-warning/30 hover:bg-warning/10",
)} )}
> >
<HelpCircle className="w-3.5 h-3.5" /> <HelpCircle className="w-3.5 h-3.5" />
{t('maybe')} {t('maybe')}
</button> </button>
<button <button
onClick={() => handleRsvp('declined')} onClick={() => handleRsvp('declined')}
disabled={isProcessing} disabled={isProcessing}
aria-pressed={currentRsvp === 'declined'} aria-pressed={currentRsvp === 'declined'}
className={cn( className={cn(
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border", "inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
currentRsvp === 'declined' currentRsvp === 'declined'
? "bg-destructive/15 text-destructive border-destructive/20" ? "bg-destructive/15 text-destructive border-destructive/30"
: "text-muted-foreground hover:text-destructive border-border hover:border-destructive/30 hover:bg-destructive/10" : "text-muted-foreground hover:text-destructive border-border hover:border-destructive/30 hover:bg-destructive/10",
)} )}
> >
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
{t('decline')} {t('decline')}
</button> </button>
<div className="w-px h-5 bg-border mx-1" />
</>
)}
<div className="w-px h-5 bg-border" /> {supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation && (
</> <>
)} <button
ref={pickerTriggerRef}
onClick={() => {
if (calendars.length <= 1) {
handleImport();
return;
}
if (showCalendarPicker) {
setShowCalendarPicker(false);
return;
}
if (pickerTriggerRef.current) {
const rect = pickerTriggerRef.current.getBoundingClientRect();
setPickerPosition({ top: rect.bottom + 4, left: rect.left });
}
setShowCalendarPicker(true);
}}
disabled={isProcessing}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] 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>
{supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation && ( {showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal(
<> <div
className="fixed w-52 bg-background rounded-lg shadow-lg border border-border z-50 py-1"
style={{ top: pickerPosition.top, left: pickerPosition.left }}
>
<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>,
document.body,
)}
</>
)}
{canApplyProposal && (
<button <button
ref={pickerTriggerRef} onClick={handleApplyProposal}
onClick={() => {
if (calendars.length <= 1) {
handleImport();
return;
}
if (showCalendarPicker) {
setShowCalendarPicker(false);
return;
}
if (pickerTriggerRef.current) {
const rect = pickerTriggerRef.current.getBoundingClientRect();
setPickerPosition({ top: rect.bottom + 4, left: rect.left });
}
setShowCalendarPicker(true);
}}
disabled={isProcessing} disabled={isProcessing}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50"
> >
<CalendarCheck className="w-3.5 h-3.5" /> <CalendarCheck className="w-3.5 h-3.5" />
{t('add_to_calendar')} {t('apply_proposal')}
{calendars.length > 1 && <ChevronDown className="w-3 h-3" />}
</button> </button>
)}
{showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal( {supportsCalendar && (existingEvent || parsedEvent) && (
<div <button
className="fixed w-52 bg-background rounded-lg shadow-lg border border-border z-50 py-1" onClick={handleViewInCalendar}
style={{ top: pickerPosition.top, left: pickerPosition.left }} className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md hover:bg-muted transition-colors min-h-[36px]"
> >
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground"> <Calendar className="w-3.5 h-3.5" />
{t('select_calendar')} {viewActionLabel}
</div> <ArrowRight className="w-3 h-3" />
{calendars.map((cal) => ( </button>
<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>,
document.body,
)}
</>
)}
{canApplyProposal && ( {!supportsCalendar && (
<button <span className="text-xs text-muted-foreground italic">{t('no_calendar')}</span>
onClick={handleApplyProposal} )}
disabled={isProcessing}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50"
>
<CalendarCheck className="w-3.5 h-3.5" />
{t('apply_proposal')}
</button>
)}
{supportsCalendar && (existingEvent || parsedEvent) && ( {isProcessing && (
<button <Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
onClick={handleViewInCalendar} )}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md hover:bg-muted transition-colors min-h-[36px]" </div>
>
<Calendar className="w-3.5 h-3.5" />
{viewActionLabel}
<ArrowRight className="w-3 h-3" />
</button>
)}
{!supportsCalendar && (
<span className="text-xs text-muted-foreground italic">{t('no_calendar')}</span>
)}
{isProcessing && (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
)} )}
</div> </div>
)}
</div> </div>
); );
} }
+1 -1
View File
@@ -4837,7 +4837,7 @@ export function EmailViewer({
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
hasCalendarInvitation) && ( hasCalendarInvitation) && (
<div className="border-b border-border bg-muted/30 isolate"> <div className="border-b border-border bg-muted/30 isolate">
<div className="max-w-6xl mx-auto px-6 py-1.5"> <div className="px-6 py-1.5">
<div className="flex flex-col gap-3 isolate"> <div className="flex flex-col gap-3 isolate">
{/* External Content Controls */} {/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && ( {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
+1
View File
@@ -390,6 +390,7 @@
"declined_counter_title": "Návrh na změnu zamítnut", "declined_counter_title": "Návrh na změnu zamítnut",
"cancelled_title": "Událost zrušena", "cancelled_title": "Událost zrušena",
"organizer": "Organizátor: {name}", "organizer": "Organizátor: {name}",
"organizer_label": "Organizátor:",
"attendees": "{count, plural, one {1 účastník} few {# účastníci} other {# účastníků}}", "attendees": "{count, plural, one {1 účastník} few {# účastníci} other {# účastníků}}",
"accept": "Přijmout", "accept": "Přijmout",
"maybe": "Možná", "maybe": "Možná",
+1
View File
@@ -388,6 +388,7 @@
"declined_counter_title": "Gegenvorschlag abgelehnt", "declined_counter_title": "Gegenvorschlag abgelehnt",
"cancelled_title": "Veranstaltung abgesagt", "cancelled_title": "Veranstaltung abgesagt",
"organizer": "Organisiert von {name}", "organizer": "Organisiert von {name}",
"organizer_label": "Organisiert von",
"attendees": "{count, plural, one {# Teilnehmer} other {# Teilnehmer}}", "attendees": "{count, plural, one {# Teilnehmer} other {# Teilnehmer}}",
"accept": "Annehmen", "accept": "Annehmen",
"maybe": "Vielleicht", "maybe": "Vielleicht",
+1
View File
@@ -415,6 +415,7 @@
"declined_counter_title": "Counter Proposal Declined", "declined_counter_title": "Counter Proposal Declined",
"cancelled_title": "Event Cancelled", "cancelled_title": "Event Cancelled",
"organizer": "Organized by {name}", "organizer": "Organized by {name}",
"organizer_label": "Organized by",
"attendees": "{count, plural, one {# attendee} other {# attendees}}", "attendees": "{count, plural, one {# attendee} other {# attendees}}",
"accept": "Accept", "accept": "Accept",
"maybe": "Maybe", "maybe": "Maybe",
+1
View File
@@ -388,6 +388,7 @@
"declined_counter_title": "Contrapropuesta rechazada", "declined_counter_title": "Contrapropuesta rechazada",
"cancelled_title": "Evento cancelado", "cancelled_title": "Evento cancelado",
"organizer": "Organizado por {name}", "organizer": "Organizado por {name}",
"organizer_label": "Organizado por",
"attendees": "{count, plural, one {# asistente} other {# asistentes}}", "attendees": "{count, plural, one {# asistente} other {# asistentes}}",
"accept": "Aceptar", "accept": "Aceptar",
"maybe": "Quizás", "maybe": "Quizás",
+1
View File
@@ -388,6 +388,7 @@
"declined_counter_title": "Contre-proposition refusée", "declined_counter_title": "Contre-proposition refusée",
"cancelled_title": "Événement annulé", "cancelled_title": "Événement annulé",
"organizer": "Organisé par {name}", "organizer": "Organisé par {name}",
"organizer_label": "Organisé par",
"attendees": "{count, plural, one {# participant} other {# participants}}", "attendees": "{count, plural, one {# participant} other {# participants}}",
"accept": "Accepter", "accept": "Accepter",
"maybe": "Peut-être", "maybe": "Peut-être",
+1
View File
@@ -388,6 +388,7 @@
"declined_counter_title": "Controproposta rifiutata", "declined_counter_title": "Controproposta rifiutata",
"cancelled_title": "Evento annullato", "cancelled_title": "Evento annullato",
"organizer": "Organizzato da {name}", "organizer": "Organizzato da {name}",
"organizer_label": "Organizzato da",
"attendees": "{count, plural, one {# partecipante} other {# partecipanti}}", "attendees": "{count, plural, one {# partecipante} other {# partecipanti}}",
"accept": "Accetta", "accept": "Accetta",
"maybe": "Forse", "maybe": "Forse",
+1
View File
@@ -388,6 +388,7 @@
"declined_counter_title": "対案は拒否されました", "declined_counter_title": "対案は拒否されました",
"cancelled_title": "イベントがキャンセルされました", "cancelled_title": "イベントがキャンセルされました",
"organizer": "{name} が主催", "organizer": "{name} が主催",
"organizer_label": "主催者:",
"attendees": "{count}名の参加者", "attendees": "{count}名の参加者",
"accept": "承諾", "accept": "承諾",
"maybe": "未定", "maybe": "未定",
+1
View File
@@ -390,6 +390,7 @@
"declined_counter_title": "시간 제안이 거절되었어요", "declined_counter_title": "시간 제안이 거절되었어요",
"cancelled_title": "일정이 취소되었어요", "cancelled_title": "일정이 취소되었어요",
"organizer": "주최자: {name}", "organizer": "주최자: {name}",
"organizer_label": "주최자:",
"attendees": "{count, plural, other {참석자 #명}}", "attendees": "{count, plural, other {참석자 #명}}",
"accept": "수락", "accept": "수락",
"maybe": "미정", "maybe": "미정",
+1
View File
@@ -390,6 +390,7 @@
"declined_counter_title": "Pretpiedāvājums noraidīts", "declined_counter_title": "Pretpiedāvājums noraidīts",
"cancelled_title": "Pasākums atcelts", "cancelled_title": "Pasākums atcelts",
"organizer": "Organizators: {name}", "organizer": "Organizators: {name}",
"organizer_label": "Organizators:",
"attendees": "{count, plural, one {# dalībnieks} other {# dalībnieki}}", "attendees": "{count, plural, one {# dalībnieks} other {# dalībnieki}}",
"accept": "Pieņemt", "accept": "Pieņemt",
"maybe": "Varbūt", "maybe": "Varbūt",
+1
View File
@@ -388,6 +388,7 @@
"declined_counter_title": "Tegenvoorstel afgewezen", "declined_counter_title": "Tegenvoorstel afgewezen",
"cancelled_title": "Evenement geannuleerd", "cancelled_title": "Evenement geannuleerd",
"organizer": "Georganiseerd door {name}", "organizer": "Georganiseerd door {name}",
"organizer_label": "Georganiseerd door",
"attendees": "{count, plural, one {# deelnemer} other {# deelnemers}}", "attendees": "{count, plural, one {# deelnemer} other {# deelnemers}}",
"accept": "Accepteren", "accept": "Accepteren",
"maybe": "Misschien", "maybe": "Misschien",
+1
View File
@@ -390,6 +390,7 @@
"declined_counter_title": "Propozycja zmian odrzucona", "declined_counter_title": "Propozycja zmian odrzucona",
"cancelled_title": "Wydarzenie anulowane", "cancelled_title": "Wydarzenie anulowane",
"organizer": "Organizator: {name}", "organizer": "Organizator: {name}",
"organizer_label": "Organizator:",
"attendees": "{count, plural, one {# uczestnik} other {# uczestników}}", "attendees": "{count, plural, one {# uczestnik} other {# uczestników}}",
"accept": "Akceptuj", "accept": "Akceptuj",
"maybe": "Może", "maybe": "Może",
+1
View File
@@ -388,6 +388,7 @@
"declined_counter_title": "Contraproposta recusada", "declined_counter_title": "Contraproposta recusada",
"cancelled_title": "Evento cancelado", "cancelled_title": "Evento cancelado",
"organizer": "Organizado por {name}", "organizer": "Organizado por {name}",
"organizer_label": "Organizado por",
"attendees": "{count, plural, one {# participante} other {# participantes}}", "attendees": "{count, plural, one {# participante} other {# participantes}}",
"accept": "Aceitar", "accept": "Aceitar",
"maybe": "Talvez", "maybe": "Talvez",
+1
View File
@@ -390,6 +390,7 @@
"declined_counter_title": "Встречное предложение отклонено", "declined_counter_title": "Встречное предложение отклонено",
"cancelled_title": "Событие отменено", "cancelled_title": "Событие отменено",
"organizer": "Организовано {name}", "organizer": "Организовано {name}",
"organizer_label": "Организовано",
"attendees": "{count, plural, one {# участник} other {# участников}}", "attendees": "{count, plural, one {# участник} other {# участников}}",
"accept": "Принять", "accept": "Принять",
"maybe": "Возможно", "maybe": "Возможно",
+1
View File
@@ -390,6 +390,7 @@
"declined_counter_title": "Karşı Teklif Reddedildi", "declined_counter_title": "Karşı Teklif Reddedildi",
"cancelled_title": "Etkinlik İptal Edildi", "cancelled_title": "Etkinlik İptal Edildi",
"organizer": "{name} tarafından düzenleniyor", "organizer": "{name} tarafından düzenleniyor",
"organizer_label": "Düzenleyen:",
"attendees": "{count, plural, one {# katılımcı} other {# katılımcı}}", "attendees": "{count, plural, one {# katılımcı} other {# katılımcı}}",
"accept": "Kabul et", "accept": "Kabul et",
"maybe": "Belki", "maybe": "Belki",
+1
View File
@@ -390,6 +390,7 @@
"declined_counter_title": "Зустрічна пропозиція відхилена", "declined_counter_title": "Зустрічна пропозиція відхилена",
"cancelled_title": "Подію скасовано", "cancelled_title": "Подію скасовано",
"organizer": "Організовано {name}", "organizer": "Організовано {name}",
"organizer_label": "Організовано",
"attendees": "{count, plural, one {# учасник} few {# учасники} many {# учасників} other {# учасників}}", "attendees": "{count, plural, one {# учасник} few {# учасники} many {# учасників} other {# учасників}}",
"accept": "прийняти", "accept": "прийняти",
"maybe": "можливо", "maybe": "можливо",
+1
View File
@@ -390,6 +390,7 @@
"declined_counter_title": "改期建议已被拒绝", "declined_counter_title": "改期建议已被拒绝",
"cancelled_title": "活动已取消", "cancelled_title": "活动已取消",
"organizer": "组织者:{name}", "organizer": "组织者:{name}",
"organizer_label": "组织者:",
"attendees": "{count, plural, one {# 位参与者} other {# 位参与者}}", "attendees": "{count, plural, one {# 位参与者} other {# 位参与者}}",
"accept": "接受", "accept": "接受",
"maybe": "暂定", "maybe": "暂定",