Files
ersatztv/web/src/app/AppShell.tsx
T
timothyandClaude Opus 4.8 8cef07a673
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Has been cancelled
fix(396): mark active route in a default-collapsed group; fix mobile brand bar
Cold-review findings:
- #1 (CONFIRMED): deep-linking/reloading into a route inside a default-collapsed
  labeled group (e.g. /app/settings, /app/libraries) left the expanded sidebar
  with no active indicator, violating "active route marked in both states". Now
  the group that contains the active route is force-shown (and its header shows
  expanded) WITHOUT persisting — navigating away reverts to the stored preference.
  Regression test added.
- #2 (mobile): a persisted-collapsed state hid the brand logo+wordmark while the
  toggle is also hidden at <=980px, leaving an empty header. Restore the brand in
  the mobile query (collapse is desktop-only).

Verified live (Playwright): /app/settings deep-link shows Settings active +
aria-current, System auto-revealed, Media stays collapsed, nothing persisted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:43:33 +02:00

453 lines
12 KiB
TypeScript

import {
Fragment,
useEffect,
useRef,
useState,
type KeyboardEvent,
type MouseEvent,
type ReactNode
} from 'react';
import {
Bell,
CalendarClock,
Cast,
Check,
ChevronDown,
CircleHelp,
ClipboardCopy,
Info,
ListVideo,
PanelLeftClose,
PanelLeftOpen,
Plus,
Search
} from 'lucide-react';
import chicoryMarkUrl from '../../../design-system/assets/chicory-mark.svg';
import {
useChannelsQuery,
useDashboardVersionQuery,
type DashboardHealthQueryState
} from '../api';
import {
Button,
IconButton,
NavItem
} from '../components';
import {
designSystemThemes,
type DesignSystemThemeId
} from '../designSystem';
import { usePrimaryActionHandler } from '../primaryAction';
import { navigateToPath } from '../routing';
import { useSidebarState } from './sidebarState';
import { DashboardHealthSummary } from '../screens/DashboardScreen';
import { UnauthorizedBanner } from '../UnauthorizedBanner';
import { UserMenu } from '../UserMenu';
import {
routeById,
routeHref,
sidebarNavGroups,
type ScreenId,
type ScreenRoute
} from './routes';
type NavigateHandler = (route: ScreenRoute, event: MouseEvent) => void;
function SidebarNavGroup({
activeRoute,
collapsed,
ids,
onNavigate
}: {
activeRoute: ScreenRoute | null;
collapsed: boolean;
ids: readonly ScreenId[];
onNavigate: NavigateHandler;
}) {
return (
<>
{ids.map((id) => {
const route = routeById.get(id)!;
return (
<NavItem
key={route.id}
icon={route.icon}
label={route.label}
// In the collapsed rail, the label is visually hidden (kept for a11y) so surface it
// as a native tooltip; expanded shows the label inline so a tooltip would be redundant.
title={collapsed ? route.label : undefined}
active={route.id === activeRoute?.id}
badge={route.badge}
badgeTone="warn"
href={routeHref(route)}
onClick={(event) => onNavigate(route, event)}
/>
);
})}
</>
);
}
function ThemeSwitcher({
theme,
onThemeChange
}: {
theme: DesignSystemThemeId;
onThemeChange: (theme: DesignSystemThemeId) => void;
}) {
return (
<section className="ctv-theme-switcher" aria-label="Theme">
<span>Theme</span>
<div>
{designSystemThemes.map(({ id, description, label }) => (
<button
key={id}
type="button"
className={`ctv-theme-swatch ctv-theme-swatch-${id}`}
aria-label={description}
aria-pressed={theme === id}
title={label}
onClick={() => onThemeChange(id)}
/>
))}
</div>
</section>
);
}
function Sidebar({
activeRoute,
collapsed,
healthState,
isGroupCollapsed,
onNavigate,
onToggleCollapsed,
onToggleGroup
}: {
activeRoute: ScreenRoute | null;
collapsed: boolean;
healthState: DashboardHealthQueryState;
isGroupCollapsed: (groupKey: string) => boolean;
onNavigate: NavigateHandler;
onToggleCollapsed: () => void;
onToggleGroup: (groupKey: string) => void;
}) {
return (
<aside className="ctv-sidebar">
<div className="ctv-brand">
<img src={chicoryMarkUrl} alt="ChicoryTV" />
<span className="ctv-brand-wordmark">
Chicory<span>TV</span>
</span>
<button
type="button"
className="ctv-sidebar-toggle"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-pressed={collapsed}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onClick={onToggleCollapsed}
>
{collapsed ? (
<PanelLeftOpen aria-hidden="true" size={17} />
) : (
<PanelLeftClose aria-hidden="true" size={17} />
)}
</button>
</div>
<nav aria-label="Primary" className="ctv-nav">
{sidebarNavGroups.map((group) => {
const { ids, key, label } = group;
// Primary (unlabeled) is always open, in both the rail and the expanded sidebar.
if (!label) {
return (
<SidebarNavGroup
key="Primary"
activeRoute={activeRoute}
collapsed={collapsed}
ids={ids}
onNavigate={onNavigate}
/>
);
}
const groupKey = key ?? label;
// Accordions only apply in the expanded sidebar; the rail always shows every item
// (separated by a hairline divider), so group-collapse is ignored there.
const groupCollapsed = isGroupCollapsed(groupKey);
// Always reveal the group that contains the active route so its active item is visible +
// highlighted in the expanded sidebar, even when the group's stored state is collapsed
// (e.g. a fresh deep-link to /app/settings). This does NOT persist — navigating away
// reverts to the stored preference (acceptance: "active route marked in both states").
const containsActive =
activeRoute != null && ids.includes(activeRoute.id);
const expanded = containsActive || !groupCollapsed;
const showItems = collapsed || expanded;
return (
<Fragment key={groupKey}>
{collapsed ? (
<div className="ctv-nav-divider" role="presentation" />
) : (
<button
type="button"
className="ctv-nav-group-header"
aria-expanded={expanded}
onClick={() => onToggleGroup(groupKey)}
>
<span>{label}</span>
<ChevronDown className="ctv-nav-group-chevron" aria-hidden="true" size={14} />
</button>
)}
{showItems ? (
<SidebarNavGroup
activeRoute={activeRoute}
collapsed={collapsed}
ids={ids}
onNavigate={onNavigate}
/>
) : null}
</Fragment>
);
})}
</nav>
<div className="ctv-sidebar-health">
<div>
<span>ChicoryTV</span>
<SidebarVersion />
</div>
<DashboardHealthSummary healthState={healthState} />
</div>
</aside>
);
}
function SidebarVersion() {
const versionQuery = useDashboardVersionQuery();
if (versionQuery.status === 'success') {
return <code>{versionQuery.version.appVersion ?? 'version unknown'}</code>;
}
if (versionQuery.status === 'error') {
return <code>version unavailable</code>;
}
return <code>loading version</code>;
}
function EndpointRow({ icon, label, url }: { icon: ReactNode; label: string; url: string }) {
const [copied, setCopied] = useState(false);
const copyUrl = () => {
void navigator.clipboard?.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
};
return (
<div className="ctv-connect-endpoint">
<span className="ctv-connect-endpoint-icon">{icon}</span>
<div>
<strong>{label}</strong>
<code>{url}</code>
</div>
<button type="button" onClick={copyUrl}>
{copied ? <Check aria-hidden="true" size={13} /> : <ClipboardCopy aria-hidden="true" size={13} />}
{copied ? 'Copied' : 'Copy'}
</button>
</div>
);
}
function ConnectMenu() {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const channelsQuery = useChannelsQuery();
const playlistUrl = `${window.location.origin}/iptv/channels.m3u`;
const guideUrl = `${window.location.origin}/iptv/xmltv.xml`;
useEffect(() => {
if (open) {
menuRef.current?.focus();
}
}, [open]);
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
setOpen(false);
}
};
const channelCountLabel = (() => {
if (channelsQuery.status === 'loading') {
return 'Loading channels';
}
if (channelsQuery.status === 'error') {
return 'Channels unavailable';
}
const count = channelsQuery.channels.length;
return `${count} ${count === 1 ? 'channel' : 'channels'}`;
})();
return (
<div className="ctv-connect">
<button
type="button"
className="ctv-connect-button"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
<Cast aria-hidden="true" size={15} />
Connect
<ChevronDown aria-hidden="true" size={13} />
</button>
{open && (
<>
<button
type="button"
className="ctv-connect-dismiss"
aria-label="Close Connect menu"
onClick={() => setOpen(false)}
/>
<div
className="ctv-connect-menu"
role="dialog"
aria-label="Connect a player"
tabIndex={-1}
ref={menuRef}
onKeyDown={onMenuKeyDown}
>
<div className="ctv-connect-header">
<strong>Connect a player</strong>
<span>{channelCountLabel}</span>
</div>
<EndpointRow
icon={<ListVideo aria-hidden="true" size={15} />}
label="M3U playlist"
url={playlistUrl}
/>
<EndpointRow
icon={<CalendarClock aria-hidden="true" size={15} />}
label="XMLTV guide"
url={guideUrl}
/>
<p>
<Info aria-hidden="true" size={13} />
Works with any IPTV player.
</p>
</div>
</>
)}
</div>
);
}
function TopBar({ route }: { route: ScreenRoute | null }) {
const title = route?.title ?? 'Page not found';
const kicker = route?.kicker ?? 'Unknown route';
const primaryAction = route?.primaryAction ?? '';
const primaryActionHandler = usePrimaryActionHandler(route?.id ?? '');
const [searchValue, setSearchValue] = useState('');
return (
<header className="ctv-topbar">
<div className="ctv-topbar-title">
<p>{kicker}</p>
<h1>{title}</h1>
</div>
<form
className="ctv-topbar-search"
onSubmit={(event) => {
event.preventDefault();
const trimmed = searchValue.trim();
if (trimmed) {
navigateToPath(`/app/search?query=${encodeURIComponent(trimmed)}`);
}
}}
role="search"
>
<Search aria-hidden="true" size={15} />
<input
aria-label="Search"
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Search movies, shows, music..."
type="search"
value={searchValue}
/>
</form>
<div className="ctv-topbar-spacer" />
<div className="ctv-topbar-tools">
<UserMenu />
<ConnectMenu />
<span className="ctv-topbar-divider" />
<IconButton title="Documentation" size="sm">
<CircleHelp aria-hidden="true" size={17} />
</IconButton>
<IconButton title="Notifications" size="sm">
<Bell aria-hidden="true" size={17} />
</IconButton>
<span className="ctv-avatar">TB</span>
</div>
{route && primaryAction && primaryActionHandler ? (
<Button
startIcon={<Plus aria-hidden="true" size={15} />}
onClick={primaryActionHandler}
>
{primaryAction}
</Button>
) : null}
</header>
);
}
export function AppShell({
children,
healthState,
onNavigate,
onThemeChange,
route,
theme
}: {
children: ReactNode;
healthState: DashboardHealthQueryState;
onNavigate: NavigateHandler;
onThemeChange: (theme: DesignSystemThemeId) => void;
route: ScreenRoute | null;
theme: DesignSystemThemeId;
}) {
const { collapsed, isGroupCollapsed, toggleCollapsed, toggleGroup } = useSidebarState();
return (
<div className={`ctv-app-shell${collapsed ? ' ctv-app-shell-collapsed' : ''}`}>
<Sidebar
activeRoute={route}
collapsed={collapsed}
healthState={healthState}
isGroupCollapsed={isGroupCollapsed}
onNavigate={onNavigate}
onToggleCollapsed={toggleCollapsed}
onToggleGroup={toggleGroup}
/>
<div className="ctv-shell-body">
<TopBar route={route} />
<UnauthorizedBanner />
<main className="ctv-main">
<ThemeSwitcher theme={theme} onThemeChange={onThemeChange} />
{children}
</main>
</div>
</div>
);
}