import { useEffect, useMemo, useRef, useState } from 'react'; import { NavLink } from 'react-router'; import type { LucideIcon } from 'lucide-react'; import { resolveIcon } from 'officerdev'; import type { PluginManifest } from 'hooks/usePermissions'; import { useTranslation, type AppTranslationKey } from '@/lib/i18n'; export type DockItem = { /** Already resolved for the active locale — see `useDockItems`. */ label: string; to: string; color: string; // Either a lucide glyph (rendered white on the coloured tile) or an image asset (e.g. an app favicon). icon?: LucideIcon; image?: string; /** * `badge` (the default) fills a rounded square with `color` and insets the icon — which is what a * white lucide glyph needs, because on nothing it is invisible. `bare` draws no background and lets * artwork fill the tile, because a logo framed in an arbitrary swatch reads as a mistake. * * `color` is still used either way: the active glow and the indicator dot are drawn from it. */ tile?: 'badge' | 'bare'; }; type DockProps = { items: DockItem[]; className?: string; // The content region the dock lives in. Reveal/hide is measured from ITS bottom edge, which the // in-flow music dock shrinks when present — so the dock clears the music dock with no magic offset. boundaryRef: React.RefObject; }; const ICON_SIZE = 48; const ICON_GAP = 24; const DOCK_PADDING = 12; const MAX_SCALE = 1.5; const MAX_DISTANCE = 150; const SHOW_THRESHOLD = 24; const HIDE_THRESHOLD = 100; const getScale = (mouseX: number | null, iconCenterX: number) => { if (mouseX === null) return 1; const distance = Math.abs(mouseX - iconCenterX); if (distance > MAX_DISTANCE) return 1; return 1 + (MAX_SCALE - 1) * Math.cos((distance / MAX_DISTANCE) * (Math.PI / 2)); }; export const Dock = ({ items, className, boundaryRef }: DockProps) => { const [mouseX, setMouseX] = useState(null); const [visible, setVisible] = useState(false); const dockRef = useRef(null); useEffect(() => { const handleMouseMove = (ev: MouseEvent) => { // Distance from the bottom of the content region (which the in-flow music dock shrinks when // present) — so reveal/hide triggers just above wherever the region ends, above the music dock. const bottom = boundaryRef.current?.getBoundingClientRect().bottom ?? window.innerHeight; const distFromBottom = bottom - ev.clientY; setVisible((prev) => (prev ? distFromBottom <= HIDE_THRESHOLD : distFromBottom <= SHOW_THRESHOLD)); if (distFromBottom <= HIDE_THRESHOLD) { const rect = dockRef.current?.getBoundingClientRect(); if (rect) setMouseX(ev.clientX - rect.left); } else { setMouseX(null); } }; document.addEventListener('mousemove', handleMouseMove); return () => document.removeEventListener('mousemove', handleMouseMove); }, [boundaryRef]); return (
{items.map((item, index) => { const iconCenter = DOCK_PADDING + index * (ICON_SIZE + ICON_GAP) + ICON_SIZE / 2; const scale = getScale(mouseX, iconCenter); return ( {({ isActive }) => ( <> {item.label}
{item.image ? ( ) : ( item.icon && )}
{isActive && (
)} )} ); })}
); }; import { Home, MessageCircle, FolderOpen, Code, LayoutGrid, Monitor, Globe, Workflow, Music, Activity, Radio, GitBranch, Store, Puzzle, Radar, Send, } from 'lucide-react'; /** * 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 * `/permissions` 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: CoreDockItem[] = [ { labelKey: 'nav.home', to: '/', icon: Home, color: '#f59e0b' }, { labelKey: 'nav.files', to: '/files', icon: FolderOpen, color: '#fbbf24' }, { labelKey: 'nav.chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' }, { labelKey: 'nav.commandHq', to: '/command-hq', icon: Radar, color: '#f43f5e' }, { labelKey: 'nav.editor', to: '/code-editor', icon: Code, color: '#a78bfa' }, { labelKey: 'nav.jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' }, { labelKey: 'nav.terminal', to: '/terminal', icon: Monitor, color: '#f97316' }, { labelKey: 'nav.browser', to: '/browser', icon: Globe, color: '#06b6d4' }, { labelKey: 'nav.monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' }, { labelKey: 'nav.activity', to: '/activity', icon: Radio, color: '#f59e0b' }, { labelKey: 'nav.dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' }, // Core because it is the door into another account's filesystem, and the permission that gates it is // `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment // every other grantable tile here gets. { labelKey: 'nav.sendOff', to: '/sendoff', icon: Send, color: '#22d3ee' }, // Calendar and Contacts are NOT here. They were core tiles from 2026-08-19 until caldav became a // plugin on 2026-08-27, and they come back through `dockItemsFromPlugins` — one tile per screen, each // with its own artwork, filtered by the same `calendar` permission. A plugin contributing two tiles is // what `screens` in a manifest is for. // Core because the tailnet is the perimeter — origin checking was removed on the grounds that the // tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`, // and DashboardLayout filters every tile through canVisit(), so a member never sees this one. // Core by necessity: the store is how every other feature arrives, so it can never be one of the // things that disappears when uninstalled. { labelKey: 'nav.appStore', to: '/app-store', icon: Store, color: '#64748b' }, // Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one. { labelKey: 'nav.marketplace', to: '/marketplace', icon: Puzzle, color: '#94a3b8' }, ]; /** * 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. */ /** * A shell tile before its label is resolved. `labelKey` rather than `label` because these are the only * dock entries the platform owns the words for — a plugin's tile carries the name from its own manifest, * which is the plugin's string and not ours to translate (docs/localization.md open question 3). */ type CoreDockItem = Omit & { labelKey: AppTranslationKey }; /** * The composed dock, with the shell's own labels in the active locale. * * One place rather than three: DashboardLayout, Spotlight and DockSettings each built this list by * spreading the same two sources, and each would otherwise have to resolve labels itself. */ export function useDockItems(plugins: PluginManifest[]): DockItem[] { const { t } = useTranslation(); return useMemo( () => [ ...CORE_DOCK_ITEMS.map(({ labelKey, ...item }) => ({ ...item, label: t(labelKey) })), ...dockItemsFromPlugins(plugins), ], [t, plugins], ); } export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] { return plugins.flatMap((plugin) => { const tile = (t: { name: string; icon?: string; image?: string; tile?: 'badge' | 'bare'; color: string; route: string; }): DockItem => ({ label: t.name, to: t.route, color: t.color, ...(t.tile ? { tile: t.tile } : {}), ...(t.image ? { image: t.image } : { icon: resolveIcon(t.icon ?? 'Box') }), }); return [ tile({ name: plugin.name, icon: plugin.icon, image: plugin.image, tile: plugin.tile, 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. */ // Calendar joined on 2026-08-19, when it stopped being an installable app and became core. The rest of // this list is what every install has and most people want visible; a calendar is now in that category. // Contacts is deliberately NOT here — it is core and available in dock settings, but a second tile for // the same sidecar in everyone's default dock is a crowded default rather than a useful one. // `/calendar` stays in the default arrangement even though it is a plugin route now: it is installed by // default and a fresh dock that silently dropped it would look like a downgrade. export const DEFAULT_DOCK_PATHS = ['/', '/files', '/terminal', '/dashboards', '/chat', '/calendar']; /** * The same defaults, plus a tile for every installed plugin. * * A plugin's tile is hidden by DEFAULT_DOCK_PATHS alone, because that list is core-only and `useDock` * renders the saved list rather than everything available. For someone who has never touched their dock * that reads as the install silently not working — they installed Music and the dock looks identical. * * Installing IS the choice, so the default follows it. This covers the never-customised user; someone * with a saved dock has a row instead, and the installer appends to that (see `plugins/install.ts`). * Both call sites use this so the dock and its settings screen cannot disagree about what "default" means. */ export const defaultDockPaths = (plugins: PluginManifest[]): string[] => [ ...DEFAULT_DOCK_PATHS, ...dockItemsFromPlugins(plugins).map((item) => item.to), ];