Files
platform/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
T
pastilhasandClaude Opus 5 ca7d3c657b a send form for SendOff, and no receiving one
/sendoff — pick files, pick one of your own machines or a person who has allowed
you, send. The consent list sits on the same screen rather than in settings,
because the two questions arrive together: the first time somebody opens this and
finds nobody to send to, the reason is that consent runs the other way, and the
answer has to be visible where the question was asked.

There is deliberately no received view. Files land in Documents/SendOff/ inside
the recipient's own tree, so the file browser already IS the inbox — a second view
of the same directory would be a worse copy of one that works, kept in sync by
hand.

Two details worth keeping. The file input resets its value after every pick, or
choosing the same file twice in a row does nothing, because `change` does not fire
on an unchanged value. And a send clears the files but keeps the target, since
several batches to the same place is the common second action and re-picking it
each time is the sort of small tax that stops somebody using a thing.

The multipart goes through `client.post`, which already recognises a FormData and
drops the JSON content-type so the browser picks the boundary. That was the only
thing making this look like it needed a raw fetch, and a raw fetch would have
carried its own copy of the token resolution — which is a chain of seven sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:47:47 +00:00

276 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,
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: 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 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.
{ label: 'SendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
// 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),
];