one sidecar, one permission, one install — and two screens, because Calendar and Contacts are two places you go rather than two sections of one page. the first plugin to declare `screens`, which the platform gained for this. what moved: the radicale sidecar, the four routers, the panel apps, the app-password table and the settings form. the form became a modal on the calendar screen — a plugin ships panels and cannot contribute a settings section, which is the same wall the email plugin hit and the same answer. the routes are /calendar and /contacts, not /caldav/calendar. they were core routes and keeping them is not cosmetic: a bookmark, a dock tile someone arranged, and a persisted layout all address them. only a first-party plugin may choose its own segment; a third-party one is confined to /p/<publisher>/. four paths outside /api come with it, above the account gate, because phones are not browsers: DAVx5, iOS and thunderbird send HTTP basic on every request and have nowhere to put a JWT. that is not "open" — the sync door demands a scoped app password and resolves it to one user before radicale sees anything. two things deliberately unchanged. the table is still dav_app_passwords, not caldav_app_passwords: installs hold real rows and a naming convention is not worth a data migration on a live machine. and the panel names still resolve through the platform's catalogs, so the six locales they had as core screens survive rather than dropping to english to make the refactor tidier. per-user was already true and is worth writing down because the config said otherwise: the sync door resolves an app password to an account, tells radicale that id is the principal, and owner_only does the rest. the comment claiming "the owner is the only principal that exists" had been stale since multi-user landed on 2026-08-07. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
102 lines
4.7 KiB
TypeScript
102 lines
4.7 KiB
TypeScript
import type { CalendarEvent } from './shared';
|
||
import { CalendarDays, MapPin, Repeat } from 'lucide-react';
|
||
import { useCalendarEvents, useSelectedCollection } from './useDavData';
|
||
import { useTranslation } from 'officerdev';
|
||
|
||
// Right panel of /calendar. Deliberately a grouped agenda list, not a month grid: the sidecar returns
|
||
// events as they are stored, with RRULE unexpanded, so a grid would have to invent occurrences the server
|
||
// never claimed existed. A list can show a repeating event honestly, as one entry with a "repeats" badge.
|
||
|
||
const dayKey = (ev: CalendarEvent) => (ev.start ? ev.start.slice(0, 10) : '');
|
||
|
||
const formatDay = (iso: string) => {
|
||
if (!iso) return 'No date';
|
||
const date = new Date(`${iso}T12:00:00`);
|
||
if (Number.isNaN(date.getTime())) return iso;
|
||
return date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||
};
|
||
|
||
const formatTime = (ev: CalendarEvent) => {
|
||
if (ev.allDay || !ev.start) return 'All day';
|
||
const start = new Date(ev.start);
|
||
if (Number.isNaN(start.getTime())) return '';
|
||
const opts: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
|
||
const end = ev.end ? new Date(ev.end) : null;
|
||
if (!end || Number.isNaN(end.getTime())) return start.toLocaleTimeString(undefined, opts);
|
||
return `${start.toLocaleTimeString(undefined, opts)} – ${end.toLocaleTimeString(undefined, opts)}`;
|
||
};
|
||
|
||
export const CalendarView = () => {
|
||
const { t } = useTranslation();
|
||
const { selected, isLoading: loadingCollections } = useSelectedCollection('calendar');
|
||
const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null);
|
||
|
||
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
|
||
if (!selected) return <Placeholder text="No calendars yet. Create one from a CalDAV client." />;
|
||
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
|
||
if (isLoading) return <Placeholder text="Loading events…" />;
|
||
if (events.length === 0) return <Placeholder text={`${selected.displayName} has no events.`} />;
|
||
|
||
const sorted = [...events].sort((a, b) => (a.start ?? '').localeCompare(b.start ?? ''));
|
||
const days: Array<{ key: string; events: CalendarEvent[] }> = [];
|
||
for (const ev of sorted) {
|
||
const key = dayKey(ev);
|
||
const last = days[days.length - 1];
|
||
if (last && last.key === key) last.events.push(ev);
|
||
else days.push({ key, events: [ev] });
|
||
}
|
||
|
||
return (
|
||
<div className="h-full overflow-y-auto">
|
||
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
|
||
{days.map((day) => (
|
||
<section key={day.key} className="flex flex-col gap-2">
|
||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||
{formatDay(day.key)}
|
||
</h2>
|
||
{day.events.map((ev) => (
|
||
<article key={ev.uid} className="flex gap-3 rounded-xl border bg-card p-3">
|
||
<span
|
||
className="mt-1 h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-black/10"
|
||
style={{ backgroundColor: selected.color || '#94a3b8' }}
|
||
/>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="truncate text-sm font-medium">{ev.summary || '(no title)'}</span>
|
||
{ev.rrule && (
|
||
<span
|
||
title={ev.rrule}
|
||
className="flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground"
|
||
>
|
||
<Repeat className="h-3 w-3" />
|
||
repeats
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-0.5 text-xs tabular-nums text-muted-foreground">{formatTime(ev)}</div>
|
||
{ev.location && (
|
||
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
||
<MapPin className="h-3 w-3 shrink-0" />
|
||
<span className="truncate">{ev.location}</span>
|
||
</div>
|
||
)}
|
||
{ev.description && (
|
||
<p className="mt-1 whitespace-pre-wrap text-xs text-muted-foreground">{ev.description}</p>
|
||
)}
|
||
</div>
|
||
</article>
|
||
))}
|
||
</section>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const Placeholder = ({ text }: { text: string }) => (
|
||
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center text-sm text-muted-foreground">
|
||
<CalendarDays className="h-6 w-6 opacity-40" />
|
||
<span>{text}</span>
|
||
</div>
|
||
);
|