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
297 lines
13 KiB
TypeScript
297 lines
13 KiB
TypeScript
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<HTMLElement | null>;
|
|
};
|
|
|
|
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<number | null>(null);
|
|
const [visible, setVisible] = useState(false);
|
|
const dockRef = useRef<HTMLDivElement | null>(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 (
|
|
<div
|
|
ref={dockRef}
|
|
className={`absolute left-1/2 z-5 items-end gap-2 md:gap-6 px-2 py-1.5 md:px-3 md:py-2 rounded-2xl border backdrop-blur-xl shadow-lg transition-transform duration-300 ease-in-out ${className ?? 'flex'}`}
|
|
style={{
|
|
backgroundColor: 'var(--dock-bg)',
|
|
borderColor: 'var(--dock-border)',
|
|
bottom: '16px',
|
|
transform: `translateX(-50%) translateY(${visible ? '0' : 'calc(100% + 24px)'})`,
|
|
}}
|
|
>
|
|
{items.map((item, index) => {
|
|
const iconCenter = DOCK_PADDING + index * (ICON_SIZE + ICON_GAP) + ICON_SIZE / 2;
|
|
const scale = getScale(mouseX, iconCenter);
|
|
|
|
return (
|
|
<NavLink
|
|
key={item.to}
|
|
to={item.to}
|
|
// Home would otherwise light up on every route: without `end`, NavLink treats "/" as an
|
|
// ancestor of everything. Every other item wants the ancestor match, so that a detail route
|
|
// (/plans/x, /system-monitor/btop) keeps its dock tile lit.
|
|
end={item.to === '/'}
|
|
className="group relative flex flex-col items-center"
|
|
style={{
|
|
transform: `scale(${scale})`,
|
|
transformOrigin: 'bottom center',
|
|
transition: 'transform 150ms ease-out',
|
|
}}
|
|
>
|
|
{({ isActive }) => (
|
|
<>
|
|
<span
|
|
className="absolute -top-9 px-2 py-1 rounded-md text-white text-xs whitespace-nowrap hidden md:block opacity-0 group-hover:opacity-100 transition-opacity duration-150 pointer-events-none"
|
|
style={{ backgroundColor: 'var(--dock-tooltip-bg)' }}
|
|
>
|
|
{item.label}
|
|
</span>
|
|
<div
|
|
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center transition-all"
|
|
style={{
|
|
// `bare` keeps the box — it is what sizes the row and carries the glow — and simply
|
|
// does not paint it.
|
|
background: item.tile === 'bare' ? 'transparent' : item.color,
|
|
boxShadow: isActive ? `0 0 12px ${item.color}40` : 'none',
|
|
}}
|
|
>
|
|
{item.image ? (
|
|
<img
|
|
src={item.image}
|
|
alt=""
|
|
className={
|
|
item.tile === 'bare' ? 'h-full w-full object-contain' : 'h-7 w-7 md:h-8 md:w-8 object-contain'
|
|
}
|
|
/>
|
|
) : (
|
|
item.icon && <item.icon className="h-5 w-5 md:h-6 md:w-6 text-white" />
|
|
)}
|
|
</div>
|
|
{isActive && (
|
|
<div className="absolute -bottom-1.5 w-1.5 h-1.5 rounded-full" style={{ background: item.color }} />
|
|
)}
|
|
</>
|
|
)}
|
|
</NavLink>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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<DockItem, 'label'> & { 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),
|
|
];
|