calendar and contacts are a plugin now — plugins/caldav, its own repository like every other one. a fresh machine runs five processes instead of seven and gets a calendar by installing one plugin, which is the whole point: the platform stops carrying a python calendar server for people who may not want one. what left: the radicale sidecar, four routers, the panel apps, the dav_app_passwords table, the two screens, the two dock tiles, the permission registry entry, the settings section, and radicale from every package list. three platform things had to change first, each committed on its own:6b5c87daa plugin can be more than one screen;e0df28ffa first-party plugin can claim a reserved root path. this commit adds the third, `personal` on a plugin permission — a member granted READ must still mint and revoke their own app password, because that password is how their own phone reaches their own calendar. those rows are theirs and nobody else's. two fixes the work surfaced rather than caused. the dock pinned mountPrefix, so a multi-screen plugin pinned a tile pointing at /caldav — a route no plugin serves. and the OPTIONS carve-out was a literal list of four DAV paths; it asks the claim registry now, so it follows whoever owns the route. /calendar stays in DEFAULT_DOCK_PATHS. it is installed by default and a fresh dock that silently dropped it would read as a downgrade. docs:check earned its keep twice here: it caught spotlight still offering /settings/integrations/dav-app-passwords after the section was deleted, and the permission count going 22 -> 21. neither is greppable and both were already wrong when it ran. verified against alpha, which is still on the pre-extraction code: /.well-known/ caldav 301s, /dav/ answers 401 with the same WWW-Authenticate realm byte for byte, /api/caldav/passwords 401s, and OPTIONS reaches the DAV router rather than being answered as a CORS preflight. installed here end to end — schema applied, sidecar started, two tiles with their own artwork behind one permission. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
232 lines
7.9 KiB
TypeScript
232 lines
7.9 KiB
TypeScript
import { useMemo } from 'react';
|
|
import type { LucideIcon } from 'lucide-react';
|
|
import {
|
|
Bot,
|
|
CalendarDays,
|
|
Cpu,
|
|
Globe,
|
|
KeyRound,
|
|
LayoutGrid,
|
|
Lock,
|
|
Mail,
|
|
ScanText,
|
|
ShieldCheck,
|
|
Sparkles,
|
|
User,
|
|
Users,
|
|
Volume2,
|
|
Wrench,
|
|
} from 'lucide-react';
|
|
import { usePermissions } from 'hooks/usePermissions';
|
|
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
|
|
import { useDockItems } from '../Dock';
|
|
|
|
// What Spotlight can take you to.
|
|
//
|
|
// ── It indexes the DOCK, rather than a list of its own ──
|
|
//
|
|
// The dock is already the composed, permission-filtered answer to "what can this account open here":
|
|
// the shell's own screens, plus whatever installed plugins contribute, minus anything `canVisit` refuses.
|
|
// Building a second list would mean two answers to one question, and the second would be wrong the day
|
|
// somebody installs a plugin — which is the whole failure the dock's own comment warns about, arrived at
|
|
// from a third direction.
|
|
//
|
|
// So a new plugin appears in Spotlight for the same reason it appears in the dock: because it is
|
|
// installed. Nothing here is edited.
|
|
//
|
|
// Settings sections are added because they are the things people hunt for and the dock deliberately does
|
|
// not carry. They are static: a settings screen is shell, not a plugin.
|
|
|
|
export type SpotlightItem = {
|
|
/** Stable across renders — used as the React key and for the recency store. */
|
|
id: string;
|
|
/** Resolved for the active locale, because `rank()` matches what the reader can actually see. */
|
|
label: string;
|
|
/** Where it goes. Matched against `canVisit` for anything not already filtered. */
|
|
to: string;
|
|
/** Shown small beside the label, so two "Profile"s are distinguishable. */
|
|
hint?: string;
|
|
icon?: LucideIcon;
|
|
/** Plugin artwork, when the tile has some. */
|
|
image?: string;
|
|
color?: string;
|
|
/**
|
|
* Copied from the dock tile, and it has to be.
|
|
*
|
|
* `bare` means the artwork brings its own frame — calendar, contacts, and every plugin shipping a logo.
|
|
* Dropping it here painted a coloured swatch behind those logos, which the dock's own comment calls out
|
|
* as reading like a mistake. It was dropped, so Spotlight looked like a different app's list of the same
|
|
* things.
|
|
*/
|
|
tile?: 'badge' | 'bare';
|
|
};
|
|
|
|
/**
|
|
* The settings sections, which the dock does not carry.
|
|
*
|
|
* Hand-listed rather than derived, and that is a real cost: a section added to `IntegrationsSettings`
|
|
* will not appear here until someone adds it. The alternative is exporting every settings screen's
|
|
* section array up into the shell, which couples five screens to this one to save five lines.
|
|
*
|
|
* IT DRIFTED, and within two days. The first version listed nine rows against a settings area that has
|
|
* twenty-one, missed Languages and Voice entirely, pointed `/settings/users` at a route that does not
|
|
* exist (it is `/settings/user-management/users`), and gave four different rows the same User glyph —
|
|
* which is most of why the whole panel read as a generic list rather than as the app's own.
|
|
*
|
|
* So the cost is now measured rather than predicted, and deriving these from the screens is the fix if it
|
|
* happens a third time. Each group below names the file it is copied from, so checking is one grep.
|
|
*
|
|
* `hint` is what makes them findable — typing "keys" should reach the API keys screen, and the label alone
|
|
* does not say where it lives.
|
|
*/
|
|
type SettingsItem = Omit<SpotlightItem, 'label' | 'hint'> & {
|
|
labelKey: AppTranslationKey;
|
|
hintKey?: AppTranslationKey;
|
|
};
|
|
|
|
const SETTINGS_ITEMS: SettingsItem[] = [
|
|
// Profile — ProfileSettings/index.tsx
|
|
{
|
|
id: 'set:profile',
|
|
labelKey: 'settings.sections.profile.label',
|
|
to: '/settings/profile',
|
|
hintKey: 'settings.sections.profile.hint',
|
|
icon: User,
|
|
},
|
|
{
|
|
id: 'set:password',
|
|
labelKey: 'settings.sections.changePassword.label',
|
|
to: '/settings/profile/change-password',
|
|
icon: Lock,
|
|
},
|
|
{
|
|
id: 'set:api-keys',
|
|
labelKey: 'settings.sections.apiKeys.label',
|
|
to: '/settings/profile/api-keys',
|
|
hintKey: 'settings.sections.apiKeys.hint',
|
|
icon: KeyRound,
|
|
},
|
|
{
|
|
id: 'set:languages',
|
|
labelKey: 'settings.sections.languages.label',
|
|
to: '/settings/profile/languages',
|
|
hintKey: 'settings.sections.languages.hint',
|
|
icon: Globe,
|
|
},
|
|
{
|
|
id: 'set:voice',
|
|
labelKey: 'settings.sections.voice.label',
|
|
to: '/settings/profile/voice',
|
|
hintKey: 'settings.sections.voice.hint',
|
|
icon: Volume2,
|
|
},
|
|
{
|
|
id: 'set:dock',
|
|
labelKey: 'settings.sections.dock.label',
|
|
to: '/settings/profile/dock',
|
|
hintKey: 'settings.sections.dock.hint',
|
|
icon: LayoutGrid,
|
|
},
|
|
// AI — AISettings.tsx
|
|
{
|
|
id: 'set:ai-my-models',
|
|
labelKey: 'settings.sections.myModels.label',
|
|
to: '/settings/ai/my-models',
|
|
icon: Sparkles,
|
|
},
|
|
{
|
|
id: 'set:ai-channel-models',
|
|
labelKey: 'settings.sections.channelModels.label',
|
|
to: '/settings/ai/member-models',
|
|
icon: Sparkles,
|
|
},
|
|
{
|
|
id: 'set:ai-default-models',
|
|
labelKey: 'settings.sections.defaultModels.label',
|
|
to: '/settings/ai/default-models',
|
|
icon: Sparkles,
|
|
},
|
|
{
|
|
id: 'set:ai-harnesses',
|
|
labelKey: 'settings.sections.providers.label',
|
|
to: '/settings/ai/ai-harnesses',
|
|
hintKey: 'settings.sections.providers.hint',
|
|
icon: Bot,
|
|
},
|
|
// System — SystemSettings.tsx
|
|
{ id: 'set:tts', labelKey: 'settings.sections.tts.label', to: '/settings/system/tts', icon: Volume2 },
|
|
{ id: 'set:ocr', labelKey: 'settings.sections.ocr.label', to: '/settings/system/ocr', icon: ScanText },
|
|
{
|
|
id: 'set:maintenance',
|
|
labelKey: 'settings.sections.maintenance.label',
|
|
to: '/settings/system/maintenance',
|
|
hintKey: 'settings.sections.maintenance.hint',
|
|
icon: Cpu,
|
|
},
|
|
{
|
|
id: 'set:smtp',
|
|
labelKey: 'settings.sections.smtp.label',
|
|
to: '/settings/system/smtp',
|
|
hintKey: 'settings.sections.smtp.hint',
|
|
icon: Mail,
|
|
},
|
|
// Integrations — IntegrationsSettings
|
|
{
|
|
id: 'set:google-oauth',
|
|
labelKey: 'settings.sections.googleOauth.label',
|
|
to: '/settings/integrations/google-oauth',
|
|
icon: KeyRound,
|
|
},
|
|
{ id: 'set:apify', labelKey: 'settings.sections.apify.label', to: '/settings/integrations/apify', icon: Wrench },
|
|
// DAV sync setup left with the caldav plugin on 2026-08-27 — it is a modal on the Calendar screen now,
|
|
// and Spotlight has no way to open a plugin's modal. `bun run docs:check` caught this one: it compares
|
|
// every settings route Spotlight offers against the sections that actually exist.
|
|
// Users — UserManagement
|
|
{
|
|
id: 'set:users',
|
|
labelKey: 'settings.sections.accounts.label',
|
|
to: '/settings/user-management/users',
|
|
hintKey: 'settings.sections.accounts.hint',
|
|
icon: Users,
|
|
},
|
|
{
|
|
id: 'set:permissions',
|
|
labelKey: 'settings.sections.permissions.label',
|
|
to: '/settings/user-management/permissions',
|
|
icon: ShieldCheck,
|
|
},
|
|
];
|
|
|
|
/** Everything Spotlight can offer this account, in a stable order. */
|
|
export function useSpotlightItems(): SpotlightItem[] {
|
|
const { canVisit, plugins } = usePermissions();
|
|
const { t } = useTranslation();
|
|
const dockItems = useDockItems(plugins);
|
|
|
|
return useMemo(() => {
|
|
const fromDock: SpotlightItem[] = dockItems
|
|
.filter((item) => canVisit(item.to))
|
|
.map((item) => ({
|
|
id: `nav:${item.to}`,
|
|
label: item.label,
|
|
to: item.to,
|
|
icon: item.icon,
|
|
image: item.image,
|
|
color: item.color,
|
|
tile: item.tile,
|
|
}));
|
|
|
|
// Settings go through `canVisit` too. `usePermissions` fails OPEN by design — hiding a door is a
|
|
// courtesy and the 403 is the lock — so this cannot lock anyone out of their own settings.
|
|
const settings: SpotlightItem[] = SETTINGS_ITEMS.filter((item) => canVisit(item.to)).map(
|
|
({ labelKey, hintKey, ...item }) => ({
|
|
...item,
|
|
label: t(labelKey),
|
|
...(hintKey ? { hint: t(hintKey) } : {}),
|
|
}),
|
|
);
|
|
|
|
return [...fromDock, ...settings];
|
|
}, [canVisit, dockItems, t]);
|
|
}
|