Files
platform/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx
T
pastilhasandClaude Opus 5 82e9fdacc2 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>
2026-08-10 18:32:16 +00:00

240 lines
7.9 KiB
TypeScript

import { useMemo, useState, useCallback, type DragEvent } from 'react';
import { X, Plus, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useDock } from 'officerdev';
import { useCapabilities } from 'hooks/useCapabilities';
import { CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from '@/Screens/Dashboard/Layout/Dock';
type DockPillProps = {
label: string;
path: string;
color: string;
visible: boolean;
onAction: (path: string) => void;
onDragStart: (ev: DragEvent, path: string) => void;
onDropOnPill?: (draggedPath: string) => void;
dropIndicator?: 'left' | 'right' | null;
onDragOverPill?: (ev: DragEvent) => void;
onDragLeavePill?: () => void;
};
const DockPill = ({
label,
path,
color,
visible,
onAction,
onDragStart,
onDropOnPill,
dropIndicator,
onDragOverPill,
onDragLeavePill,
}: DockPillProps) => (
<span
draggable
onDragStart={(ev) => onDragStart(ev, path)}
onDragOver={onDragOverPill}
onDragLeave={onDragLeavePill}
onDrop={
onDropOnPill
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
const p = ev.dataTransfer.getData('text/plain');
if (p) onDropOnPill(p);
}
: undefined
}
className={`relative inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium cursor-grab active:cursor-grabbing select-none border border-duck-dark/15 dark:border-foreground/15 bg-background/60 text-duck-dark/80 dark:text-foreground/80 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors ${dropIndicator === 'left' ? 'ring-l-2 ring-duck-teal' : ''} ${dropIndicator === 'right' ? 'ring-r-2 ring-duck-teal' : ''}`}
>
{dropIndicator === 'left' && <span className="absolute -left-1 top-1 bottom-1 w-0.5 rounded-full bg-duck-teal" />}
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: color }} />
{label}
<button
onClick={() => onAction(path)}
className="ml-0.5 p-0.5 rounded-full hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
{visible ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
</button>
{dropIndicator === 'right' && <span className="absolute -right-1 top-1 bottom-1 w-0.5 rounded-full bg-duck-teal" />}
</span>
);
type DropZoneProps = {
label: string;
children: React.ReactNode;
onDrop: (path: string) => void;
};
const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
const [over, setOver] = useState(false);
const handleDragOver = useCallback((ev: DragEvent) => {
ev.preventDefault();
setOver(true);
}, []);
const handleDragLeave = useCallback(() => setOver(false), []);
const handleDrop = useCallback(
(ev: DragEvent) => {
ev.preventDefault();
setOver(false);
const path = ev.dataTransfer.getData('text/plain');
if (path) onDrop(path);
},
[onDrop],
);
return (
<div className="grid gap-1.5">
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">
{label}
</span>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`min-h-[48px] p-2 rounded-lg border border-dashed transition-colors flex flex-wrap gap-1.5 ${over ? 'border-duck-teal bg-duck-teal/5' : 'border-duck-dark/15 dark:border-foreground/15'}`}
>
{children}
</div>
</div>
);
};
export const DockSettings = () => {
// Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone
// pin a tile that cannot appear, which reads as the setting being broken.
const { plugins } = useCapabilities();
const allDockItems = useMemo(() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)], [plugins]);
const { items, allItems, setItems, reset } = useDock(allDockItems, DEFAULT_DOCK_PATHS);
const [dropTarget, setDropTarget] = useState<{ path: string; side: 'left' | 'right' } | null>(null);
const visiblePaths = new Set(items.map((i) => i.to));
const hiddenItems = allItems.filter((i) => !visiblePaths.has(i.to));
const onDragStart = useCallback((ev: DragEvent, path: string) => {
ev.dataTransfer.setData('text/plain', path);
ev.dataTransfer.effectAllowed = 'move';
}, []);
const addItem = useCallback(
(path: string) => {
if (visiblePaths.has(path)) return;
setItems([...items.map((i) => i.to), path]);
},
[items, visiblePaths, setItems],
);
const removeItem = useCallback(
(path: string) => {
setItems(items.filter((i) => i.to !== path).map((i) => i.to));
},
[items, setItems],
);
const insertAt = useCallback(
(draggedPath: string, targetPath: string, side: 'left' | 'right') => {
const paths = items.filter((i) => i.to !== draggedPath).map((i) => i.to);
const targetIdx = paths.indexOf(targetPath);
if (targetIdx === -1) return;
const insertIdx = side === 'left' ? targetIdx : targetIdx + 1;
paths.splice(insertIdx, 0, draggedPath);
setItems(paths);
setDropTarget(null);
},
[items, setItems],
);
const handlePillDragOver = useCallback((ev: DragEvent, pillPath: string) => {
ev.preventDefault();
ev.dataTransfer.dropEffect = 'move';
const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
const midX = rect.left + rect.width / 2;
const side = ev.clientX < midX ? 'left' : 'right';
setDropTarget((prev) => (prev?.path === pillPath && prev?.side === side ? prev : { path: pillPath, side }));
}, []);
const handlePillDragLeave = useCallback(() => setDropTarget(null), []);
const handlePillDrop = useCallback(
(draggedPath: string, targetPath: string) => {
if (draggedPath === targetPath) {
setDropTarget(null);
return;
}
const side = dropTarget?.path === targetPath ? dropTarget.side : 'right';
insertAt(draggedPath, targetPath, side);
},
[dropTarget, insertAt],
);
const onDropVisible = useCallback(
(path: string) => {
setDropTarget(null);
if (visiblePaths.has(path)) return;
addItem(path);
},
[visiblePaths, addItem],
);
const onDropHidden = useCallback(
(path: string) => {
setDropTarget(null);
if (!visiblePaths.has(path)) return;
removeItem(path);
},
[visiblePaths, removeItem],
);
return (
<div className="grid gap-4">
<DropZone label="Visible" onDrop={onDropVisible}>
{items.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
Drag items here to show in dock
</span>
)}
{items.map((item) => (
<DockPill
key={item.to}
label={item.label}
path={item.to}
color={item.color}
visible
onAction={removeItem}
onDragStart={onDragStart}
dropIndicator={dropTarget?.path === item.to ? dropTarget.side : null}
onDragOverPill={(ev) => handlePillDragOver(ev, item.to)}
onDragLeavePill={handlePillDragLeave}
onDropOnPill={(draggedPath) => handlePillDrop(draggedPath, item.to)}
/>
))}
</DropZone>
<DropZone label="Hidden" onDrop={onDropHidden}>
{hiddenItems.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All items visible</span>
)}
{hiddenItems.map((item) => (
<DockPill
key={item.to}
label={item.label}
path={item.to}
color={item.color}
visible={false}
onAction={addItem}
onDragStart={onDragStart}
/>
))}
</DropZone>
<Button type="button" variant="outline" onClick={reset} className="w-full h-9 text-sm cursor-pointer">
<RotateCcw className="h-3.5 w-3.5 mr-1.5" />
Reset to defaults
</Button>
</div>
);
};