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
82 lines
3.6 KiB
TypeScript
82 lines
3.6 KiB
TypeScript
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>
|
|
);
|