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>
66 lines
3.7 KiB
TypeScript
66 lines
3.7 KiB
TypeScript
import { useMemo, useRef } from 'react';
|
|
import { useLocation } from 'react-router';
|
|
import { useDock, MusicPlayerHost, usePanelFullscreen } from 'officerdev';
|
|
import { useCapabilities } from 'hooks/useCapabilities';
|
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
|
import { ScreenErrorFallback } from './ScreenErrorFallback';
|
|
import { Background } from './Background';
|
|
import { Header } from './Header';
|
|
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from './Dock';
|
|
import { useIsTouch } from './useIsTouch';
|
|
import { usePageTitleSync } from '@/state/usePageTitle';
|
|
|
|
type DashboardLayoutProps = {
|
|
children?: React.ReactNode;
|
|
};
|
|
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|
const { canVisit, plugins } = useCapabilities();
|
|
// Filtered BEFORE useDock, so a member's saved dock order cannot resurrect an icon their role no longer
|
|
// reaches, and so the pinned-item defaults fall back to something they can actually open. Cosmetic
|
|
// either way — every one of these routes is refused server-side too — but an app that offers a door it
|
|
// will then slam is worse than one that never showed it.
|
|
// The shell's own items plus whatever the installed sidecars contribute. `plugins` already excludes
|
|
// anything uninstalled or disabled, so an absent feature has no tile at all rather than a dead one.
|
|
const permitted = useMemo(
|
|
() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)].filter((item) => canVisit(item.to)),
|
|
[canVisit, plugins],
|
|
);
|
|
const { items: visibleItems } = useDock(permitted, DEFAULT_DOCK_PATHS);
|
|
const isTouch = useIsTouch();
|
|
const { pathname } = useLocation();
|
|
usePageTitleSync();
|
|
// A panel can maximize into the content region on its own, but it cannot paint over this header — the
|
|
// region below is an `absolute z-2` stacking context and the header is a `fixed z-10` sibling of it. So
|
|
// "full screen" is cooperative: the panel asks, and the chrome steps aside.
|
|
const panelFullscreen = usePanelFullscreen();
|
|
// The content region shrinks when the (in-flow) music dock takes its space; the nav dock measures its
|
|
// reveal boundary from this element, so it always sits just above whatever's at the bottom.
|
|
const regionRef = useRef<HTMLElement | null>(null);
|
|
|
|
return (
|
|
<div className="relative flex h-dvh flex-col overflow-hidden outline-none inset-0">
|
|
<Header dockItems={visibleItems} hidden={panelFullscreen} />
|
|
{/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock —
|
|
absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to
|
|
the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal
|
|
the focused element: content slid up under the fixed header and the hidden dock slid into view.
|
|
`clip` clips identically but is not scrollable, so nothing can ever scroll it. */}
|
|
<section ref={regionRef} className="relative min-h-0 flex-1 snap-start overflow-clip">
|
|
<Background />
|
|
{!isTouch && <Dock items={visibleItems} boundaryRef={regionRef} className="hidden md:flex" />}
|
|
<div className="absolute inset-0 z-2 pt-[52px] md:pt-[64px] pb-2 overflow-y-auto">
|
|
{/* Chrome stays outside: whatever broke, the dock and the header still navigate you off it.
|
|
Keyed on the pathname so leaving a broken screen is itself a recovery. */}
|
|
<ErrorBoundary
|
|
resetKeys={[pathname]}
|
|
fallback={({ error, reset }) => <ScreenErrorFallback error={error} reset={reset} />}
|
|
>
|
|
{children}
|
|
</ErrorBoundary>
|
|
</div>
|
|
</section>
|
|
<MusicPlayerHost />
|
|
</div>
|
|
);
|
|
}
|