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
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>
453 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|