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>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { useState, useCallback, type DragEvent } from 'react';
|
||||
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 { ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from '@/Screens/Dashboard/Layout/Dock';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from '@/Screens/Dashboard/Layout/Dock';
|
||||
|
||||
type DockPillProps = {
|
||||
label: string;
|
||||
@@ -18,14 +19,32 @@ type DockPillProps = {
|
||||
};
|
||||
|
||||
const DockPill = ({
|
||||
label, path, color, visible, onAction, onDragStart, onDropOnPill, dropIndicator, onDragOverPill, onDragLeavePill,
|
||||
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}
|
||||
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" />}
|
||||
@@ -69,7 +88,9 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
|
||||
|
||||
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>
|
||||
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">
|
||||
{label}
|
||||
</span>
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -83,7 +104,11 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
|
||||
};
|
||||
|
||||
export const DockSettings = () => {
|
||||
const { items, allItems, setItems, reset } = useDock(ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS);
|
||||
// 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));
|
||||
@@ -135,7 +160,10 @@ export const DockSettings = () => {
|
||||
|
||||
const handlePillDrop = useCallback(
|
||||
(draggedPath: string, targetPath: string) => {
|
||||
if (draggedPath === targetPath) { setDropTarget(null); return; }
|
||||
if (draggedPath === targetPath) {
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
const side = dropTarget?.path === targetPath ? dropTarget.side : 'right';
|
||||
insertAt(draggedPath, targetPath, side);
|
||||
},
|
||||
@@ -163,7 +191,11 @@ export const DockSettings = () => {
|
||||
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.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}
|
||||
@@ -182,7 +214,9 @@ export const DockSettings = () => {
|
||||
</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.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}
|
||||
@@ -196,12 +230,7 @@ export const DockSettings = () => {
|
||||
))}
|
||||
</DropZone>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={reset}
|
||||
className="w-full h-9 text-sm cursor-pointer"
|
||||
>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user