feat(plex): add Playlist/Collection browse modes to the header pill
The header library-scope pill gains two entry points — Playlist and Collection — next to All/Movies/Shows, a second way into each area (the rail filter is the other). Picking Playlist shows a grid of the user's playlists (a tile opens that playlist's view); Collection shows a searchable grid of collections (a tile filters the title grid by that collection). Pure front-end — reuses the existing plexPlaylists / plexCollections(union) list endpoints. Two new top-level sub-views (`playlists` / `collections`, bookmarkable as ?v=playlists / ?v=collections) rendered by PlexBrowse via the new plexBrowseLists surfaces (hue-tiled like the Discover genre tiles). The PlexViewBridge now carries the view `kind`; PlexScopeToggle is a 5-way that derives its active segment from it (browse-mode when in one, else scope) and drives the two views through the bridge — picking a scope from a browse mode returns to the title grid. The Discover/Browse toggle hides in these modes; Backspace + the search→grid flip cover them. Tests: PlexScopeToggle (5-way + browse-mode drive/active), plexSubUrl round-trip for the two new views. Gate: tsc/lint/knip/prettier + 402 vitest.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Layers, ListMusic } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api } from "../lib/api";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
|
||||
// The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles,
|
||||
// each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the
|
||||
// title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections
|
||||
// union), so this is a pure front-end surface. Tiles are coloured by a title hash (no per-item art to
|
||||
// fetch/proxy), matching the Discover genre tiles.
|
||||
|
||||
// A stable, pleasant hue per title string — same idea as PlexDiscover's genre tiles.
|
||||
function hueOf(name: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
|
||||
return h;
|
||||
}
|
||||
|
||||
function Tile({
|
||||
title,
|
||||
count,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
title: string;
|
||||
count: ReactNode;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const hue = hueOf(title);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className="glass-hover group relative flex h-28 flex-col justify-end overflow-hidden rounded-xl p-3 text-left transition"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, hsl(${hue} 55% 32%), hsl(${(hue + 40) % 360} 60% 22%))`,
|
||||
}}
|
||||
>
|
||||
<span className="absolute right-2 top-2 text-white/70">{icon}</span>
|
||||
<span className="line-clamp-2 text-sm font-semibold text-white drop-shadow">{title}</span>
|
||||
<span className="text-2xs text-white/70">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const GRID = "grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6";
|
||||
|
||||
// A grid of the user's Plex playlists; a tile opens that playlist's view.
|
||||
export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
||||
const playlists = q.data?.playlists ?? [];
|
||||
|
||||
if (q.isError && !q.data) {
|
||||
return (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
);
|
||||
}
|
||||
if (q.isLoading) return <LoadingState label={t("plex.loading")} />;
|
||||
if (playlists.length === 0)
|
||||
return <p className="text-muted text-sm">{t("plex.playlist.none")}</p>;
|
||||
|
||||
return (
|
||||
<div className={GRID}>
|
||||
{playlists.map((p) => (
|
||||
<Tile
|
||||
key={p.id}
|
||||
title={p.title}
|
||||
count={t("plex.browse.items", { count: p.item_count })}
|
||||
icon={<ListMusic className="h-4 w-4" />}
|
||||
onClick={() => onOpen(p.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A grid of Plex collections (searchable — the union can run to hundreds); a tile filters the title
|
||||
// grid by that collection (via the parent's onPick → applyFilter).
|
||||
export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const searchDeb = useDebounced(search.trim(), 300);
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexCollections("union", searchDeb),
|
||||
queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
|
||||
});
|
||||
const collections = q.data?.collections ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("plex.filter.collectionSearch")}
|
||||
className="glass-card mb-4 w-full max-w-sm rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : collections.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.filter.collectionEmpty")}</p>
|
||||
) : (
|
||||
<div className={GRID}>
|
||||
{collections.map((c) => (
|
||||
<Tile
|
||||
key={c.id}
|
||||
title={c.title}
|
||||
count={c.child_count != null ? t("plex.browse.items", { count: c.child_count }) : ""}
|
||||
icon={<Layers className="h-4 w-4" />}
|
||||
onClick={() => onPick(c.id, c.title)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user