calendar and contacts, extracted from the officer platform
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
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
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>
|
||||
);
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { DavCollection } from './shared';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { CalendarDays, Contact as ContactIcon, Smartphone } from 'lucide-react';
|
||||
import { davCollectionPath, COLLECTION_PARAM } from './shared';
|
||||
import { useDavCollections } from './useDavData';
|
||||
import { AppPasswordsModal, useAppPasswordsModal } from '../AppPasswordsModal';
|
||||
import { useTranslation } from 'officerdev';
|
||||
|
||||
// Left panel for both /calendar and /contacts: the list of collections belonging to this user.
|
||||
//
|
||||
// Rows are real links carrying ?collection=<dav path>, so a calendar is a bookmarkable address and the
|
||||
// right-hand panel reads the same URL rather than being told over a channel. Active state is compared
|
||||
// against the URL directly instead of NavLink's isActive, because every row shares one pathname and
|
||||
// differs only in the query string.
|
||||
|
||||
type Props = {
|
||||
kind: DavCollection['kind'];
|
||||
base: '/calendar' | '/contacts';
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
||||
|
||||
const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [, setSyncOpen] = useAppPasswordsModal();
|
||||
const [params] = useSearchParams();
|
||||
const { collections, isLoading } = useDavCollections(kind);
|
||||
const fromUrl = params.get(COLLECTION_PARAM);
|
||||
// Nothing in the URL means the view falls back to the first collection — highlight the same one.
|
||||
const activePath = collections.some((c) => c.path === fromUrl) ? fromUrl : (collections[0]?.path ?? null);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||
<div className="flex items-center gap-3 px-4 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-sky-500/15 text-sky-500 ring-1 ring-black/5">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-tight">{title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isLoading
|
||||
? 'loading…'
|
||||
: `${collections.length} ${collections.length === 1 ? 'collection' : 'collections'}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
||||
{collections.map((collection) => {
|
||||
const isActive = collection.path === activePath;
|
||||
return (
|
||||
<Link
|
||||
key={collection.path}
|
||||
to={davCollectionPath(base, collection.path)}
|
||||
className={`${ROW} ${
|
||||
isActive
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{isActive && (
|
||||
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
|
||||
)}
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-black/10"
|
||||
style={{ backgroundColor: collection.color || '#94a3b8' }}
|
||||
/>
|
||||
<span className="flex-1 truncate">{collection.displayName}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{!isLoading && collections.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">{t('dav.empty')}</p>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Sync setup lives at the bottom of the nav, and the modal is mounted here once.
|
||||
It was Settings → Integrations → DAV sync until caldav became a plugin; a plugin ships panels and
|
||||
cannot contribute a settings section, so it follows email's AccountsModal. Pushed down by
|
||||
`mt-auto` because it is a twice-a-year task that should not compete with the collections. */}
|
||||
<button
|
||||
onClick={() => setSyncOpen(true)}
|
||||
className="mt-auto flex items-center gap-2 border-t px-4 py-3 text-left text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Smartphone className="h-3.5 w-3.5 shrink-0" />
|
||||
Sync to your phone
|
||||
</button>
|
||||
<AppPasswordsModal />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CalendarNav = () => {
|
||||
const { t } = useTranslation();
|
||||
return <CollectionNav kind="calendar" base="/calendar" title={t('dav.calendar')} icon={CalendarDays} />;
|
||||
};
|
||||
|
||||
export const ContactsNav = () => {
|
||||
const { t } = useTranslation();
|
||||
return <CollectionNav kind="addressbook" base="/contacts" title={t('dav.contacts')} icon={ContactIcon} />;
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Building2, Mail, Phone, User } from 'lucide-react';
|
||||
import { useDavContacts, useSelectedCollection } from './useDavData';
|
||||
import { useTranslation } from 'officerdev';
|
||||
|
||||
// Right panel of /contacts: the selected address book as cards, sorted by name.
|
||||
//
|
||||
// `hasPhoto` is a flag, not the bytes — the sidecar keeps embedded photos out of the list response so a
|
||||
// book with a hundred cards is not a hundred base64 blobs. Until there is an endpoint that serves one
|
||||
// card's photo, an initial stands in.
|
||||
|
||||
const initials = (name: string) =>
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('') || '?';
|
||||
|
||||
export const ContactsView = () => {
|
||||
const { t } = useTranslation();
|
||||
const { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook');
|
||||
const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null);
|
||||
|
||||
if (loadingCollections) return <Placeholder text="Loading address books…" />;
|
||||
if (!selected) return <Placeholder text="No address books yet. Create one from a CardDAV client." />;
|
||||
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
|
||||
if (isLoading) return <Placeholder text="Loading contacts…" />;
|
||||
if (contacts.length === 0) return <Placeholder text={`${selected.displayName} has no contacts.`} />;
|
||||
|
||||
const sorted = [...contacts].sort((a, b) => a.fullName.localeCompare(b.fullName));
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-3 p-6 sm:grid-cols-2">
|
||||
{sorted.map((contact) => (
|
||||
<article key={contact.uid} className="flex gap-3 rounded-xl border bg-card p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500/15 text-sm font-semibold text-sky-600">
|
||||
{initials(contact.fullName)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{contact.fullName || '(no name)'}</div>
|
||||
{(contact.title || contact.org) && (
|
||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Building2 className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{[contact.title, contact.org].filter(Boolean).join(' · ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{contact.emails.map((email) => (
|
||||
<a
|
||||
key={email}
|
||||
href={`mailto:${email}`}
|
||||
className="mt-1 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Mail className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{email}</span>
|
||||
</a>
|
||||
))}
|
||||
{contact.phones.map((phone) => (
|
||||
<a
|
||||
key={phone}
|
||||
href={`tel:${phone}`}
|
||||
className="mt-1 flex items-center gap-1 text-xs tabular-nums text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Phone className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{phone}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</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">
|
||||
<User className="h-6 w-6 opacity-40" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
// Shared wire shapes for the /calendar and /contacts workspaces.
|
||||
//
|
||||
// These mirror what the officer-caldav sidecar returns from its JSON door under /api/caldav/_officer/* —
|
||||
// see src/servers/sidecar/caldav/{collections,ical}.ts, which is where they are produced. The sidecar
|
||||
// parses the iCalendar/vCard itself so nothing here ever sees DAV XML.
|
||||
|
||||
/** One calendar or address book. `path` is the DAV path (/dav/<userId>/<name>/) and doubles as the id. */
|
||||
export type DavCollection = {
|
||||
path: string;
|
||||
displayName: string;
|
||||
kind: 'calendar' | 'addressbook';
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export type CalendarEvent = {
|
||||
uid: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
allDay: boolean;
|
||||
/** Raw RRULE, unexpanded — the sidecar does not expand a series, so this is a badge, not occurrences. */
|
||||
rrule?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type DavContact = {
|
||||
uid: string;
|
||||
fullName: string;
|
||||
emails: string[];
|
||||
phones: string[];
|
||||
org?: string;
|
||||
title?: string;
|
||||
hasPhoto: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The query param both screens select with. A collection id contains slashes, so it cannot be a path
|
||||
* segment without escaping — and per the navigation rules the selection still has to live in the URL,
|
||||
* so it is a query param on the screen you are already on.
|
||||
*/
|
||||
export const COLLECTION_PARAM = 'collection';
|
||||
|
||||
/** The one place the two screens' URLs are spelled, so nav links and guards cannot drift. */
|
||||
export const davCollectionPath = (base: '/calendar' | '/contacts', collection: string) =>
|
||||
`${base}?${COLLECTION_PARAM}=${encodeURIComponent(collection)}`;
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { CalendarEvent, DavCollection, DavContact } from './shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { COLLECTION_PARAM } from './shared';
|
||||
|
||||
// Data layer for /calendar and /contacts. Everything goes through the /api/caldav auth proxy to the
|
||||
// officer-caldav sidecar, which holds the Radicale process and every DAV credential; officer forwards the
|
||||
// authenticated user's id and nothing else.
|
||||
//
|
||||
// There is no push channel — Radicale is a plain DAV server — so these are ordinary queries with a
|
||||
// generous staleTime rather than a poll. A calendar changes when someone edits it, not on a timer.
|
||||
|
||||
const STALE_MS = 30_000;
|
||||
|
||||
const EMPTY_COLLECTIONS: DavCollection[] = [];
|
||||
const EMPTY_EVENTS: CalendarEvent[] = [];
|
||||
const EMPTY_CONTACTS: DavContact[] = [];
|
||||
|
||||
export function useDavCollections(kind?: DavCollection['kind']) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['caldav', 'collections'],
|
||||
queryFn: () => get<{ collections: DavCollection[] }>('/caldav/_officer/collections'),
|
||||
staleTime: STALE_MS,
|
||||
});
|
||||
|
||||
const all = query.data?.collections ?? EMPTY_COLLECTIONS;
|
||||
return {
|
||||
collections: kind ? all.filter((c) => c.kind === kind) : all,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCalendarEvents(collection: string | null) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['caldav', 'events', collection],
|
||||
queryFn: () =>
|
||||
get<{ events: CalendarEvent[] }>(`/caldav/_officer/events?collection=${encodeURIComponent(collection ?? '')}`),
|
||||
enabled: Boolean(collection),
|
||||
staleTime: STALE_MS,
|
||||
});
|
||||
|
||||
return { events: query.data?.events ?? EMPTY_EVENTS, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function useDavContacts(collection: string | null) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['caldav', 'contacts', collection],
|
||||
queryFn: () =>
|
||||
get<{ contacts: DavContact[] }>(`/caldav/_officer/contacts?collection=${encodeURIComponent(collection ?? '')}`),
|
||||
enabled: Boolean(collection),
|
||||
staleTime: STALE_MS,
|
||||
});
|
||||
|
||||
return { contacts: query.data?.contacts ?? EMPTY_CONTACTS, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* The collection the URL selects, or the first one of that kind once the list arrives. Both panels of a
|
||||
* screen call this and get the same answer, because the answer is derived from the URL and the query
|
||||
* cache rather than passed between them.
|
||||
*/
|
||||
export function useSelectedCollection(kind: DavCollection['kind']) {
|
||||
const [params] = useSearchParams();
|
||||
const { collections, isLoading } = useDavCollections(kind);
|
||||
const fromUrl = params.get(COLLECTION_PARAM);
|
||||
const selected = collections.find((c) => c.path === fromUrl) ?? collections[0] ?? null;
|
||||
return { selected, collections, isLoading };
|
||||
}
|
||||
Reference in New Issue
Block a user