dock: the shell keeps its own items, sidecars contribute theirs
ALL_DOCK_ITEMS was a hardcoded list of everything, so a fresh machine offered Photos, Jellyfin, Transmission and the rest — each leading to a screen reporting itself unavailable — and adding a sidecar meant editing the shell. Neither survives sidecars shipping from their own repositories. Split in two. CORE_DOCK_ITEMS is the baseline that exists on every install: chat, files, terminal, the app's own screens, and Gitea, which is in the light profile because it fronts a remote instance. Everything else is derived from installed sidecars' UI manifests, delivered with /capabilities. Sent with the capability answer rather than fetched separately so the dock has ONE source. Two requests means two moments, and a dock rendered between them shows a tile for something uninstalled or nothing for something installed. Filtered by capability server-side too: a member is not handed the manifest of a feature they cannot use, because "hidden in the client" is the kind of privacy that lasts until someone opens the network tab. Verified live. The owner — who bypasses every permission check — does not bypass this: /photos is absent from routes and present in deniedRoutes because Photos is not installed. Flipping a row's `enabled` makes its tile leave and return with no process touched. Two things fell out. A manifest can declare extraTiles, because CalDAV is one sidecar presenting as Calendar AND Contacts, and collapsing them to keep the model tidy would make the app worse. And DEFAULT_DOCK_PATHS no longer pins /music: useDock drops a path with nothing behind it, so the default dock came up a tile short on any machine where Music was never installed — a default that references an optional feature is how an app looks subtly wrong on a fresh install for no stated reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ScreenErrorFallback } from './ScreenErrorFallback';
|
||||
import { Background } from './Background';
|
||||
import { Header } from './Header';
|
||||
import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock';
|
||||
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from './Dock';
|
||||
import { useIsTouch } from './useIsTouch';
|
||||
import { usePageTitleSync } from '@/state/usePageTitle';
|
||||
|
||||
@@ -14,12 +14,17 @@ type DashboardLayoutProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const { canVisit } = useCapabilities();
|
||||
const { canVisit, plugins } = useCapabilities();
|
||||
// Filtered BEFORE useDock, so a member's saved dock order cannot resurrect an icon their role no longer
|
||||
// reaches, and so the pinned-item defaults fall back to something they can actually open. Cosmetic
|
||||
// either way — every one of these routes is refused server-side too — but an app that offers a door it
|
||||
// will then slam is worse than one that never showed it.
|
||||
const permitted = useMemo(() => ALL_DOCK_ITEMS.filter((item) => canVisit(item.to)), [canVisit]);
|
||||
// The shell's own items plus whatever the installed sidecars contribute. `plugins` already excludes
|
||||
// anything uninstalled or disabled, so an absent feature has no tile at all rather than a dead one.
|
||||
const permitted = useMemo(
|
||||
() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)].filter((item) => canVisit(item.to)),
|
||||
[canVisit, plugins],
|
||||
);
|
||||
const { items: visibleItems } = useDock(permitted, DEFAULT_DOCK_PATHS);
|
||||
const isTouch = useIsTouch();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { NavLink } from 'react-router';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { resolveIcon } from 'officerdev';
|
||||
import type { PluginManifest } from 'hooks/useCapabilities';
|
||||
|
||||
export type DockItem = {
|
||||
label: string;
|
||||
@@ -149,21 +151,21 @@ import {
|
||||
GitBranch,
|
||||
} from 'lucide-react';
|
||||
|
||||
export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||
/**
|
||||
* The dock items that belong to the SHELL — present on every install, with no sidecar behind them.
|
||||
*
|
||||
* Everything else is contributed by an installed sidecar's UI manifest and arrives from
|
||||
* `/capabilities` at runtime (see `dockItemsFromPlugins`). The split is the point: a feature that can be
|
||||
* installed and uninstalled must not be hardcoded here, or the dock would list things this server does
|
||||
* not have and the shell would need editing every time a sidecar is added.
|
||||
*
|
||||
* These are the baseline — chat, files, the terminal and the app's own screens — plus Gitea, which is in
|
||||
* the light profile because it fronts a remote instance and installs nothing locally.
|
||||
*/
|
||||
export const CORE_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' },
|
||||
{ label: 'Video', to: '/jellyfin', icon: Clapperboard, color: '#a855f7' },
|
||||
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
|
||||
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
|
||||
{ label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' },
|
||||
{ label: 'Wallet', to: '/wallet', icon: Bitcoin, color: '#f7931a' },
|
||||
{ label: 'Invoices', to: '/invoices', icon: Receipt, color: '#0891b2' },
|
||||
{ label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' },
|
||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||
@@ -171,10 +173,40 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
||||
{ label: 'Desktop', to: '/desktop', icon: MonitorSmartphone, color: '#ec4899' },
|
||||
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
||||
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
||||
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
||||
];
|
||||
|
||||
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/music', '/dashboards', '/chat'];
|
||||
/**
|
||||
* Turn the manifests of installed sidecars into dock tiles.
|
||||
*
|
||||
* `resolveIcon` maps a NAME to a glyph, which is why manifests carry names rather than imports — they
|
||||
* have to survive being JSON from a marketplace. An unknown name resolves to a neutral box rather than
|
||||
* throwing: a plugin naming an icon this build does not have should look plain, not break the dock.
|
||||
*/
|
||||
export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] {
|
||||
return plugins.flatMap((plugin) => {
|
||||
const tile = (t: { name: string; icon?: string; image?: string; color: string; route: string }): DockItem => ({
|
||||
label: t.name,
|
||||
to: t.route,
|
||||
color: t.color,
|
||||
...(t.image ? { image: t.image } : { icon: resolveIcon(t.icon ?? 'Box') }),
|
||||
});
|
||||
|
||||
return [
|
||||
tile({ name: plugin.name, icon: plugin.icon, image: plugin.image, color: plugin.color, route: plugin.rootRoute }),
|
||||
...(plugin.extraTiles ?? []).map(tile),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What is pinned before anyone has chosen. Deliberately drawn only from CORE_DOCK_ITEMS.
|
||||
*
|
||||
* This used to pin `/music`, which is now an installable sidecar. `useDock` drops a path with no item
|
||||
* behind it, so nothing breaks — the default dock just quietly comes up one tile short on a machine
|
||||
* where Music was never installed. Defaults that reference optional features are how an app ends up
|
||||
* looking subtly wrong on a fresh install for no stated reason.
|
||||
*/
|
||||
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/terminal', '/dashboards', '/chat'];
|
||||
|
||||
Reference in New Issue
Block a user