Files
jellyfin/web/MediaCard.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

107 lines
4.3 KiB
TypeScript

import type { JellyItem } from './shared';
import { Link, useLocation, useSearchParams } from 'react-router';
import { Check, Play } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { episodeLabel, itemHref, posterUrl, progressPercent, subtitleFor, thumbUrl } from './shared';
// One tile in a shelf or a grid.
//
// It is a real <Link> to `?item=<id>` — cmd-click opens the detail in a new tab, the URL says what is open,
// and the back button closes it. The play button is a SIBLING anchor rather than nested inside, because an
// anchor inside an anchor is not valid HTML and browsers resolve it by dropping one of them.
type MediaCardProps = { item: JellyItem; wide?: boolean };
export const MediaCard = ({ item, wide = false }: MediaCardProps) => {
const { token } = useClient();
const { pathname } = useLocation();
const [params] = useSearchParams();
const src = wide ? thumbUrl(item, token) : posterUrl(item, token);
const progress = progressPercent(item);
const watched = item.UserData?.Played === true;
const unplayed = item.UserData?.UnplayedItemCount ?? 0;
const subtitle = subtitleFor(item);
return (
<div className="group/card flex flex-col gap-1.5">
<div className="relative">
<Link
to={itemHref(pathname, params, item.Id)}
className={`relative block overflow-hidden rounded-lg bg-muted ring-1 ring-black/5 transition-transform hover:scale-[1.02] ${
wide ? 'aspect-video' : 'aspect-[2/3]'
}`}
title={item.Name ?? undefined}
>
{src ? (
<img src={src} alt="" loading="lazy" className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center p-2 text-center text-[11px] text-muted-foreground">
{item.Name}
</div>
)}
{watched && (
<span className="absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check className="h-3 w-3" />
</span>
)}
{!watched && unplayed > 0 && (
<span className="absolute right-1.5 top-1.5 rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-medium text-primary-foreground">
{unplayed}
</span>
)}
{progress > 0 && progress < 100 && (
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/40">
<span className="block h-full bg-primary" style={{ width: `${progress}%` }} />
</span>
)}
</Link>
<Link
to={itemHref(pathname, params, item.Id, true)}
aria-label={`Play ${item.Name ?? 'item'}`}
className="absolute left-1/2 top-1/2 flex h-11 w-11 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-black/70 text-white opacity-0 transition-opacity group-hover/card:opacity-100"
>
<Play className="h-5 w-5 translate-x-px" fill="currentColor" />
</Link>
</div>
<div className="min-w-0">
<p className="truncate text-xs font-medium leading-tight">{episodeLabel(item)}</p>
{subtitle && <p className="truncate text-[11px] text-muted-foreground">{subtitle}</p>}
</div>
</div>
);
};
/** A horizontal shelf. Overflow scrolls rather than wrapping — a shelf that wraps is a grid with a title. */
export const MediaShelf = ({ title, items, wide }: { title: string; items: JellyItem[]; wide?: boolean }) => {
if (!items.length) return null;
return (
<section className="flex flex-col gap-2">
<h3 className="px-1 text-sm font-semibold">{title}</h3>
<div className="flex gap-3 overflow-x-auto pb-2">
{items.map((item) => (
<div key={item.Id} className={wide ? 'w-56 shrink-0' : 'w-32 shrink-0'}>
<MediaCard item={item} wide={wide} />
</div>
))}
</div>
</section>
);
};
/** A wrapping grid. Column count comes from a min tile width, so it works in a panel of any size. */
export const MediaGrid = ({ items, wide }: { items: JellyItem[]; wide?: boolean }) => (
<div
className="grid gap-x-3 gap-y-4"
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${wide ? 200 : 130}px, 1fr))` }}
>
{items.map((item) => (
<MediaCard key={item.Id} item={item} wide={wide} />
))}
</div>
);