add /calendar and /contacts screens over the caldav json door

two workspace screens backed by one app folder: the collection list on the
left, the selected calendar's agenda or address book on the right. both read
the officer-caldav sidecar through the /api/caldav auth proxy, which holds no
DAV credentials of its own.

the selected collection is a DAV path with slashes in it, so it lives in
?collection= on the same route rather than as a path segment — still in the
URL, still bookmarkable, no selection channel. an absent or unknown value
resolves to the first collection inside the panels instead of redirecting,
because until the list has loaded there is no canonical URL to redirect to.

the agenda is a grouped list, not a month grid: the sidecar returns RRULE
unexpanded, so a grid would have to invent occurrences the server never
claimed existed. a repeating event gets a badge instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 10:50:53 +00:00
co-authored by Claude Opus 5
parent a961d8a34f
commit 4d2ec215a2
18 changed files with 540 additions and 0 deletions
+2
View File
@@ -39,6 +39,8 @@ export function App() {
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
<Route path="/plans" element={<Dashboard.Plans />} />
<Route path="/files" element={<Dashboard.FilesScreen />} />
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
<Route path="/music" element={<Dashboard.MusicScreen />} />
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
@@ -0,0 +1,50 @@
import { useEffect, useMemo } from 'react';
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /calendar uses the Workspace/Panel system (like /transmission): the calendar list on the left, the
// selected calendar's agenda on the right. Both panels read the officer-caldav sidecar through the
// /api/caldav auth proxy, which holds no DAV credentials of its own.
//
// There is no :param route pair here and so no <Navigate> guard: a collection id is a DAV path with
// slashes in it, so the selection lives in ?collection= on this same route. An absent or unknown value
// resolves to the first collection inside the panels rather than by redirecting, because until the
// collection list has loaded there is no canonical URL to redirect to.
const ALLOWED_APP_TYPES = new Set<string | null>(['calendar-nav', 'calendar-view', null]);
function normalizeLayout(node: LayoutNode): LayoutNode {
if (node.type === 'panel') {
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'calendar-view' };
}
const children = node.children.map((c) => {
const fixed = normalizeLayout(c.node);
return fixed === c.node ? c : { ...c, node: fixed };
});
const changed = children.some((c, i) => c !== node.children[i]);
return changed ? { ...node, children } : node;
}
export const CalendarScreen = () => {
const rawWorkspace = useDashboardState<LayoutNode>('screens/calendar', defaultLayout);
const workspace = useMemo(() => {
const fixed = normalizeLayout(rawWorkspace.value);
if (fixed === rawWorkspace.value) return rawWorkspace;
return { ...rawWorkspace, value: fixed };
}, [rawWorkspace]);
useEffect(() => {
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
rawWorkspace.setValue(workspace.value);
}
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
return (
<div className="h-full w-full pt-2">
<WorkspaceView workspace={workspace} locked />
</div>
);
};
@@ -0,0 +1,11 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'calendar-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'calendar-nav', appType: 'calendar-nav' }, size: 22 },
{ node: { type: 'panel', id: 'calendar-view', appType: 'calendar-view' }, size: 78 },
],
};
@@ -0,0 +1 @@
export * from './CalendarScreen';
@@ -0,0 +1,44 @@
import { useEffect, useMemo } from 'react';
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /contacts — the CardDAV half of the same sidecar as /calendar; see CalendarScreen for why the selected
// collection is a query param rather than a route segment.
const ALLOWED_APP_TYPES = new Set<string | null>(['contacts-nav', 'contacts-view', null]);
function normalizeLayout(node: LayoutNode): LayoutNode {
if (node.type === 'panel') {
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'contacts-view' };
}
const children = node.children.map((c) => {
const fixed = normalizeLayout(c.node);
return fixed === c.node ? c : { ...c, node: fixed };
});
const changed = children.some((c, i) => c !== node.children[i]);
return changed ? { ...node, children } : node;
}
export const ContactsScreen = () => {
const rawWorkspace = useDashboardState<LayoutNode>('screens/contacts', defaultLayout);
const workspace = useMemo(() => {
const fixed = normalizeLayout(rawWorkspace.value);
if (fixed === rawWorkspace.value) return rawWorkspace;
return { ...rawWorkspace, value: fixed };
}, [rawWorkspace]);
useEffect(() => {
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
rawWorkspace.setValue(workspace.value);
}
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
return (
<div className="h-full w-full pt-2">
<WorkspaceView workspace={workspace} locked />
</div>
);
};
@@ -0,0 +1,11 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'contacts-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'contacts-nav', appType: 'contacts-nav' }, size: 22 },
{ node: { type: 'panel', id: 'contacts-view', appType: 'contacts-view' }, size: 78 },
],
};
@@ -0,0 +1 @@
export * from './ContactsScreen';
@@ -139,12 +139,16 @@ import {
Bitcoin,
Receipt,
Images,
CalendarDays,
Contact,
} from 'lucide-react';
export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ label: 'Email', to: '/email', icon: Mail, color: '#ef4444' },
{ label: 'Calendar', to: '/calendar', icon: CalendarDays, color: '#3b82f6' },
{ label: 'Contacts', to: '/contacts', icon: Contact, color: '#0ea5e9' },
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
{ label: 'Photos', to: '/photos', icon: Images, color: '#10b981' },
@@ -10,6 +10,8 @@ export * from './TaskLogs';
export * from './Tasks';
export * from './Files';
export * from './Calendar';
export * from './Contacts';
export * from './Music';
export * from './Soulseek';
export * from './Headscale';
@@ -14,6 +14,8 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/chat'), title: 'Chat' },
{ match: (p) => p.startsWith('/email'), title: 'Email' },
{ match: (p) => p.startsWith('/files'), title: 'Files' },
{ match: (p) => p.startsWith('/calendar'), title: 'Calendar' },
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
{ match: (p) => p.startsWith('/music'), title: 'Music' },
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
@@ -16,6 +16,7 @@ import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
import { appRegistryMetas as davMetas } from '../apps/Dav';
import { useAppRegistry } from './useAppRegistry';
const apps = [
@@ -37,6 +38,7 @@ const apps = [
...walletMetas,
...monitorMetas,
...qrTransferMetas,
...davMetas,
];
export const AppRegistry = () => {
@@ -0,0 +1,99 @@
import type { CalendarEvent } from './shared';
import { CalendarDays, MapPin, Repeat } from 'lucide-react';
import { useCalendarEvents, useSelectedCollection } from './useDavData';
// 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 { 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={`Could not read ${selected.displayName}: ${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,87 @@
import type { LucideIcon } from 'lucide-react';
import type { DavCollection } from './shared';
import { Link, useSearchParams } from 'react-router';
import { CalendarDays, Contact as ContactIcon } from 'lucide-react';
import { davCollectionPath, COLLECTION_PARAM } from './shared';
import { useDavCollections } from './useDavData';
// 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 [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">
Nothing here yet. Add one from a phone or desktop client over CalDAV/CardDAV.
</p>
)}
</nav>
</div>
);
};
export const CalendarNav = () => (
<CollectionNav kind="calendar" base="/calendar" title="Calendar" icon={CalendarDays} />
);
export const ContactsNav = () => (
<CollectionNav kind="addressbook" base="/contacts" title="Contacts" icon={ContactIcon} />
);
@@ -0,0 +1,79 @@
import { Building2, Mail, Phone, User } from 'lucide-react';
import { useDavContacts, useSelectedCollection } from './useDavData';
// 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 { 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={`Could not read ${selected.displayName}: ${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,17 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { CalendarDays, Contact as ContactIcon, PanelLeft } from 'lucide-react';
import { CalendarNav, ContactsNav } from './CollectionNav';
import { CalendarView } from './CalendarView';
import { ContactsView } from './ContactsView';
// One app folder for both /calendar and /contacts: they are the same DAV sidecar, the same collection
// list and the same selection rule, differing only in which kind of collection they show.
export { CalendarNav, ContactsNav, CalendarView, ContactsView };
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'calendar-nav', name: 'Calendar', icon: PanelLeft, component: CalendarNav, availableOnPanel: false },
{ key: 'calendar-view', name: 'Calendar', icon: CalendarDays, component: CalendarView, availableOnPanel: false },
{ key: 'contacts-nav', name: 'Contacts', icon: PanelLeft, component: ContactsNav, availableOnPanel: false },
{ key: 'contacts-view', name: 'Contacts', icon: ContactIcon, component: ContactsView, availableOnPanel: false },
];
@@ -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 };
}
+5
View File
@@ -43,6 +43,11 @@ export {
} from './apps/Transmission/shared';
export type { TransmissionSectionId } from './apps/Transmission/shared';
// /calendar and /contacts select with a query param rather than a path segment, so what the screens need
// from the app is the param name and the link builder.
export { COLLECTION_PARAM, davCollectionPath } from './apps/Dav/shared';
export type { DavCollection, CalendarEvent, DavContact } from './apps/Dav/shared';
// Same for /invoices.
export { DEFAULT_INVOICES_SECTION, invoicesSectionPath, isInvoicesSection } from './apps/Invoices/shared';
export type { InvoicesSectionId } from './apps/Invoices/shared';