Eleventh extraction, and the only one that deleted its UI instead of moving it. Seventeen files under apps/Gitea/ reimplemented a fraction of an interface Gitea already has; the plugin renders the real one in an iframe over a same-origin proxy. Deleting rather than moving removed a platform change nobody now has to build: useGiteaLocation read :owner/:name/:tab/:item from five route patterns where a plugin gets two, so an as-is extraction would have required teaching the plugin system to declare extra route patterns. Nothing inside an iframe needs a route. Nothing working was lost. The router had been commented out of hono.ts since 2026-08-13 with api: [], and officer-gitea was running nowhere — the screen rendered and every request 404'd, like soulseek, jellyfin and invoices before it. Gitea also stops being a CORE DOCK ITEM. It was hardcoded in CORE_DOCK_ITEMS, the only installable feature that was, on the reasoning that it fronts a remote instance and installs nothing locally. As a plugin its tile arrives from assets/icon.png like every other — which is also why the icon added to public/icons/ this morning was reverted rather than kept. 'gitea' stays in the ServiceName union: the connection lives in core service_connections, and gitea is the reason that table has its "member rows carry only a token and inherit the owner's URL" shape at all. registry.test.ts's grantable-permission test named gitea as "the case this was built for"; a plugin's permissions are registered at install and are never in that compile-time list, so it now names a core one. catalogue.test.ts re-anchored to vnc/notify. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
271 lines
12 KiB
TypeScript
271 lines
12 KiB
TypeScript
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/usePermissions';
|
|
|
|
export type DockItem = {
|
|
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,
|
|
} 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: DockItem[] = [
|
|
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
|
|
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
|
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
|
{ label: 'Command HQ', to: '/command-hq', icon: Radar, color: '#f43f5e' },
|
|
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
|
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
|
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
|
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
|
{ 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' },
|
|
// Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and
|
|
// contacts became core, and deleting that entry took their tiles with it — the routes kept working
|
|
// while the dock and the settings list went silent about them, which is exactly the shape of failure
|
|
// this file's comment above warns about, arrived at from the other direction.
|
|
//
|
|
// Artwork rather than glyphs since 2026-08-19: both were redrawn to the icon brief, so they carry
|
|
// the same voxel mark the phone apps show. `tile: 'bare'` for the same reason a plugin's artwork
|
|
// does — the art brings its own frame, and a coloured square behind it reads as a mistake. `color`
|
|
// still earns its place: the active glow, the indicator dot and the settings tint all use it.
|
|
{ label: 'Calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' },
|
|
{ label: 'Contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' },
|
|
// 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.
|
|
{ label: 'App store', 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.
|
|
{ label: '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.
|
|
*/
|
|
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.
|
|
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),
|
|
];
|