Provisioning was invisible. Connecting a server can create accounts, adopt accounts and leave accounts alone, and the only record of which happened was a JSON field in a response nobody reads and rows in a table nobody can see. "Did it work" could only be answered by opening Jellyfin and counting. The nav entry is Settings rather than Connection, since it is now more than the connection. The join is computed in the sidecar and shares `normalize` with the reconcile planner, so the screen cannot disagree with the matching rule about whether Diogo and diogo are one person — a settings page saying two would be lying about what the next connection will do. It joins by the link ROW first and by folded name only where none exists: a recorded pairing is a fact, a matching name is a candidate. Four states, because they mean different things — linked (adopted or created), names match but not recorded yet, Officer account with no Jellyfin one, and a Jellyfin account with no Officer user. The last is never touched, and a linked pairing whose Jellyfin account has vanished is shown as such rather than silently re-matched by name. "Not known" is kept distinct from "none": if the platform has not sent the account list, or the server cannot be reached, it says so instead of rendering an empty table that would read as a fact about the server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
104 lines
4.0 KiB
TypeScript
104 lines
4.0 KiB
TypeScript
import type { JellyItem } from './shared';
|
|
import { Link, NavLink, useSearchParams } from 'react-router';
|
|
import { Clapperboard, Film, FolderOpen, Home, Images, ListVideo, Plug, Search, Tv } from 'lucide-react';
|
|
import { jellyfinSectionPath, VIEW_PARAM } from './shared';
|
|
import { ServerSwitcher } from './ServerSwitcher';
|
|
import { useJellyfinViews } from './useJellyfinData';
|
|
|
|
// Left panel of the /jellyfin workspace: Home and Search, the libraries the server actually has, then the
|
|
// settings screen.
|
|
//
|
|
// The libraries are not a fixed vocabulary, so they cannot be sections in the path — they are
|
|
// /jellyfin/library?view=<id>. That keeps the section list closed (four entries, checked by a type guard)
|
|
// while the thing being browsed stays addressable, which is the same split the photos app uses for albums.
|
|
//
|
|
// Their active state is computed rather than taken from NavLink, because every library link shares the path
|
|
// /jellyfin/library and react-router's isActive does not look at the query string.
|
|
|
|
const ICON_FOR_COLLECTION: Record<string, typeof Film> = {
|
|
movies: Film,
|
|
tvshows: Tv,
|
|
homevideos: Images,
|
|
musicvideos: Clapperboard,
|
|
boxsets: FolderOpen,
|
|
playlists: ListVideo,
|
|
};
|
|
|
|
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
|
const ACTIVE = 'bg-primary/10 font-medium text-primary';
|
|
const IDLE = 'text-muted-foreground hover:bg-muted hover:text-foreground';
|
|
|
|
const Marker = () => <span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />;
|
|
|
|
export const JellyfinNav = () => {
|
|
const { data } = useJellyfinViews();
|
|
const [params] = useSearchParams();
|
|
const activeView = params.get(VIEW_PARAM);
|
|
|
|
const views = data?.views ?? [];
|
|
|
|
const sectionLink = (id: 'home' | 'search' | 'settings', label: string, Icon: typeof Film) => (
|
|
<NavLink key={id} to={jellyfinSectionPath(id)} className={({ isActive }) => `${ROW} ${isActive ? ACTIVE : IDLE}`}>
|
|
{({ isActive }) => (
|
|
<>
|
|
{isActive && <Marker />}
|
|
<Icon
|
|
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
|
/>
|
|
{label}
|
|
</>
|
|
)}
|
|
</NavLink>
|
|
);
|
|
|
|
const libraryLink = (view: JellyItem) => {
|
|
const Icon = ICON_FOR_COLLECTION[view.CollectionType ?? ''] ?? FolderOpen;
|
|
const isActive = activeView === view.Id;
|
|
return (
|
|
<Link
|
|
key={view.Id}
|
|
to={`${jellyfinSectionPath('library')}?${VIEW_PARAM}=${encodeURIComponent(view.Id)}`}
|
|
className={`${ROW} ${isActive ? ACTIVE : IDLE}`}
|
|
>
|
|
{isActive && <Marker />}
|
|
<Icon
|
|
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
|
/>
|
|
<span className="truncate">{view.Name ?? 'Library'}</span>
|
|
</Link>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
|
<div className="flex items-center gap-3 px-4 py-4">
|
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/15 text-primary ring-1 ring-black/5">
|
|
<Clapperboard className="h-5 w-5" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="truncate text-sm font-semibold leading-tight">Video</div>
|
|
<ServerSwitcher />
|
|
</div>
|
|
</div>
|
|
|
|
<nav className="flex flex-col gap-0.5 px-2">
|
|
{sectionLink('home', 'Home', Home)}
|
|
{sectionLink('search', 'Search', Search)}
|
|
</nav>
|
|
|
|
{views.length > 0 && (
|
|
<>
|
|
<div className="mx-4 my-2 border-t" />
|
|
<p className="px-4 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Libraries</p>
|
|
<nav className="flex flex-col gap-0.5 px-2">{views.map(libraryLink)}</nav>
|
|
</>
|
|
)}
|
|
|
|
<div className="flex-1" />
|
|
|
|
<div className="mx-4 my-2 border-t" />
|
|
<nav className="flex flex-col gap-0.5 px-2 pb-3">{sectionLink('settings', 'Settings', Plug)}</nav>
|
|
</div>
|
|
);
|
|
};
|