Files
jellyfin/web/JellyfinNav.tsx
T
officer a7f1a71785 jellyfin, extracted from the platform as a plugin
Eighth plugin out, and the second resurrection: like soulseek, hono.ts carried
the router import and its mount commented out since 2026-08-13, with the
permission reduced to api: [], so the server booted clean while every panel
request 404'd. jellyfin_servers was also absent from the drizzle barrel, so
bun db:push would have dropped it.

Easier than soulseek in one specific way: mountPrefix derives /jellyfin from
the appName, which is exactly what the router already hardcoded, so no frontend
call site needed repointing. Soulseek needed 47 because its appName and its old
prefix differed. The frontend also already routed through one helper, so there
were five call sites rather than forty-seven.

Self-contained, unlike transmission and soulseek: jellyfin_servers is a
registry this plugin owns outright — label, URL, encrypted token, one active —
because more than one Jellyfin is a thing someone has, and more than one
Transmission is not.

Carries its own .prettierignore for the vendored hls.mjs, rather than leaving a
stale rule behind in the platform naming a path that no longer exists.
2026-08-19 03:17:32 +00:00

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
// connection 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', 'Connection', Plug)}</nav>
</div>
);
};