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,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} />;
|
||||
};
|
||||
Reference in New Issue
Block a user