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>
240 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
};
|