fix: align calendar invitation icon with sender avatar column
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' && (
|
||||||
|
|||||||
@@ -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á",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "未定",
|
||||||
|
|||||||
@@ -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": "미정",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Возможно",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "можливо",
|
||||||
|
|||||||
@@ -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": "暂定",
|
||||||
|
|||||||
Reference in New Issue
Block a user