feat(frontend): implement grouped/drilldown view in library grid
This commit is contained in:
@@ -1,10 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useLibraryShows } from "@/hooks/use-library-shows";
|
||||||
|
import { useLibrarySeasons } from "@/hooks/use-library-seasons";
|
||||||
import { LibraryItemCard } from "./library-item-card";
|
import { LibraryItemCard } from "./library-item-card";
|
||||||
|
import { ShowTile } from "./show-tile";
|
||||||
|
import { SeasonTile } from "./season-tile";
|
||||||
|
import { BreadcrumbNav } from "./breadcrumb-nav";
|
||||||
import { ScheduleFromLibraryDialog } from "./schedule-from-library-dialog";
|
import { ScheduleFromLibraryDialog } from "./schedule-from-library-dialog";
|
||||||
import { AddToBlockDialog } from "./add-to-block-dialog";
|
import { AddToBlockDialog } from "./add-to-block-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { LibraryItemFull } from "@/lib/types";
|
import type { LibraryItemFull, ShowSummary } from "@/lib/types";
|
||||||
|
import type { LibrarySearchParams } from "@/hooks/use-library-search";
|
||||||
|
|
||||||
|
type Drilldown = null | { series: string } | { series: string; season: number };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: LibraryItemFull[];
|
items: LibraryItemFull[];
|
||||||
@@ -16,36 +24,142 @@ interface Props {
|
|||||||
onToggleSelect: (id: string) => void;
|
onToggleSelect: (id: string) => void;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
selectedItems: LibraryItemFull[];
|
selectedItems: LibraryItemFull[];
|
||||||
|
viewMode: "grouped" | "flat";
|
||||||
|
drilldown: Drilldown;
|
||||||
|
onDrilldown: (next: Drilldown) => void;
|
||||||
|
filter: LibrarySearchParams;
|
||||||
|
selectedShows: ShowSummary[];
|
||||||
|
selectedShowNames: Set<string>;
|
||||||
|
onToggleSelectShow: (show: ShowSummary) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LibraryGrid({
|
export function LibraryGrid({
|
||||||
items, total, page, pageSize, isLoading,
|
items, total, page, pageSize, isLoading,
|
||||||
selected, onToggleSelect, onPageChange, selectedItems,
|
selected, onToggleSelect, onPageChange, selectedItems,
|
||||||
|
viewMode, drilldown, onDrilldown, filter,
|
||||||
|
selectedShows, selectedShowNames, onToggleSelectShow,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
// Hooks for grouped mode (called unconditionally per React rules)
|
||||||
|
const showsFilter = {
|
||||||
|
q: filter.q,
|
||||||
|
genres: filter.genres,
|
||||||
|
provider: filter.provider,
|
||||||
|
};
|
||||||
|
const { data: showsData, isLoading: showsLoading } = useLibraryShows(showsFilter);
|
||||||
|
const seasonsSeries = (viewMode === "grouped" && drilldown !== null && !("season" in drilldown))
|
||||||
|
? drilldown.series
|
||||||
|
: null;
|
||||||
|
const { data: seasonsData, isLoading: seasonsLoading } = useLibrarySeasons(
|
||||||
|
seasonsSeries,
|
||||||
|
filter.provider,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isGroupedTopLevel = viewMode === "grouped" && drilldown === null;
|
||||||
|
const isSeasonLevel = viewMode === "grouped" && drilldown !== null && !("season" in drilldown);
|
||||||
|
const isEpisodeLevel = viewMode === "flat" || (viewMode === "grouped" && drilldown !== null && "season" in drilldown);
|
||||||
|
|
||||||
|
function renderContent() {
|
||||||
|
if (isGroupedTopLevel) {
|
||||||
|
const shows = showsData ?? [];
|
||||||
|
const nonEpisodes = items.filter(i => i.content_type !== "episode");
|
||||||
|
const loading = showsLoading;
|
||||||
|
|
||||||
|
if (loading && shows.length === 0 && nonEpisodes.length === 0) {
|
||||||
|
return <p className="text-sm text-zinc-500">Loading…</p>;
|
||||||
|
}
|
||||||
|
if (shows.length === 0 && nonEpisodes.length === 0) {
|
||||||
|
return <p className="text-sm text-zinc-500">No items found. Run a library sync to populate the library.</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
{shows.map(show => (
|
||||||
|
<ShowTile
|
||||||
|
key={show.series_name}
|
||||||
|
show={show}
|
||||||
|
selected={selectedShowNames.has(show.series_name)}
|
||||||
|
onToggle={() => onToggleSelectShow(show)}
|
||||||
|
onClick={() => onDrilldown({ series: show.series_name })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{nonEpisodes.map(item => (
|
||||||
|
<LibraryItemCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
selected={selected.has(item.id)}
|
||||||
|
onToggle={() => onToggleSelect(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSeasonLevel && drilldown) {
|
||||||
|
const seasons = seasonsData ?? [];
|
||||||
|
if (seasonsLoading && seasons.length === 0) {
|
||||||
|
return <p className="text-sm text-zinc-500">Loading…</p>;
|
||||||
|
}
|
||||||
|
if (seasons.length === 0) {
|
||||||
|
return <p className="text-sm text-zinc-500">No seasons found.</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
{seasons.map(season => (
|
||||||
|
<SeasonTile
|
||||||
|
key={season.season_number}
|
||||||
|
season={season}
|
||||||
|
selected={false}
|
||||||
|
onToggle={() => {}}
|
||||||
|
onClick={() => onDrilldown({ series: drilldown.series, season: season.season_number })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat mode or episode-level drilldown
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className="text-sm text-zinc-500">Loading…</p>;
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <p className="text-sm text-zinc-500">No items found. Run a library sync to populate the library.</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
{items.map(item => (
|
||||||
|
<LibraryItemCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
selected={selected.has(item.id)}
|
||||||
|
onToggle={() => onToggleSelect(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showPagination = isEpisodeLevel && totalPages > 1;
|
||||||
|
const totalSelected = selected.size + selectedShows.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col min-h-0">
|
<div className="flex flex-1 flex-col min-h-0">
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
{isLoading ? (
|
{drilldown && (
|
||||||
<p className="text-sm text-zinc-500">Loading…</p>
|
<BreadcrumbNav
|
||||||
) : items.length === 0 ? (
|
series={"series" in drilldown ? drilldown.series : undefined}
|
||||||
<p className="text-sm text-zinc-500">No items found. Run a library sync to populate the library.</p>
|
season={"season" in drilldown ? drilldown.season : undefined}
|
||||||
) : (
|
onNavigate={target => {
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
if (target === "root") onDrilldown(null);
|
||||||
{items.map(item => (
|
else if (target === "series" && drilldown && "series" in drilldown)
|
||||||
<LibraryItemCard
|
onDrilldown({ series: drilldown.series });
|
||||||
key={item.id}
|
}}
|
||||||
item={item}
|
/>
|
||||||
selected={selected.has(item.id)}
|
|
||||||
onToggle={() => onToggleSelect(item.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{showPagination && (
|
||||||
<div className="flex items-center justify-between border-t border-zinc-800 px-6 py-3">
|
<div className="flex items-center justify-between border-t border-zinc-800 px-6 py-3">
|
||||||
<p className="text-xs text-zinc-500">{total.toLocaleString()} items total</p>
|
<p className="text-xs text-zinc-500">{total.toLocaleString()} items total</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -56,11 +170,11 @@ export function LibraryGrid({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selected.size > 0 && (
|
{totalSelected > 0 && (
|
||||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-3 rounded-full border border-zinc-700 bg-zinc-900 px-6 py-3 shadow-2xl">
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-3 rounded-full border border-zinc-700 bg-zinc-900 px-6 py-3 shadow-2xl">
|
||||||
<span className="text-sm text-zinc-300">{selected.size} selected</span>
|
<span className="text-sm text-zinc-300">{totalSelected} selected</span>
|
||||||
<ScheduleFromLibraryDialog selectedItems={selectedItems} />
|
<ScheduleFromLibraryDialog selectedItems={selectedItems} selectedShows={selectedShows} />
|
||||||
<AddToBlockDialog selectedItems={selectedItems} />
|
{selected.size > 0 && <AddToBlockDialog selectedItems={selectedItems} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user