feat(frontend): library page, components, and schedule/add-to-block dialogs (tasks 11-14)

This commit is contained in:
2026-03-20 00:35:40 +01:00
parent 49c7f7abd7
commit 91271bd83c
8 changed files with 591 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ import { AdminNavLink } from "./components/admin-nav-link";
const NAV_LINKS = [ const NAV_LINKS = [
{ href: "/tv", label: "TV" }, { href: "/tv", label: "TV" },
{ href: "/guide", label: "Guide" }, { href: "/guide", label: "Guide" },
{ href: "/library", label: "Library" },
{ href: "/dashboard", label: "Dashboard" }, { href: "/dashboard", label: "Dashboard" },
{ href: "/docs", label: "Docs" }, { href: "/docs", label: "Docs" },
]; ];

View File

@@ -0,0 +1,111 @@
"use client";
import { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useChannels, useChannel, useUpdateChannel } from "@/hooks/use-channels";
import type { LibraryItemFull, ScheduleConfig } from "@/lib/types";
import { WEEKDAYS } from "@/lib/types";
interface Props {
selectedItems: LibraryItemFull[];
}
export function AddToBlockDialog({ selectedItems }: Props) {
const [open, setOpen] = useState(false);
const [channelId, setChannelId] = useState("");
const [blockId, setBlockId] = useState("");
const { data: channels } = useChannels();
const { data: channel } = useChannel(channelId);
const updateChannel = useUpdateChannel();
const manualBlocks = useMemo(() => {
if (!channel) return [];
const seen = new Set<string>();
const result: { id: string; name: string }[] = [];
for (const day of WEEKDAYS) {
for (const block of channel.schedule_config.day_blocks[day] ?? []) {
if (block.content.type === "manual" && !seen.has(block.id)) {
seen.add(block.id);
result.push({ id: block.id, name: block.name });
}
}
}
return result;
}, [channel]);
async function handleConfirm() {
if (!channel || !blockId) return;
const updatedDayBlocks = { ...channel.schedule_config.day_blocks };
for (const day of WEEKDAYS) {
updatedDayBlocks[day] = (updatedDayBlocks[day] ?? []).map(block => {
if (block.id !== blockId || block.content.type !== "manual") return block;
return {
...block,
content: {
...block.content,
items: [...block.content.items, ...selectedItems.map(i => i.id)],
},
};
});
}
const scheduleConfig: ScheduleConfig = { day_blocks: updatedDayBlocks };
await updateChannel.mutateAsync({
id: channelId,
data: { schedule_config: scheduleConfig },
});
setOpen(false);
}
return (
<>
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>Add to block</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader><DialogTitle>Add to existing block</DialogTitle></DialogHeader>
<div className="flex flex-col gap-4">
<div>
<p className="mb-1.5 text-xs text-zinc-400">Channel</p>
<Select value={channelId} onValueChange={v => { setChannelId(v); setBlockId(""); }}>
<SelectTrigger><SelectValue placeholder="Select channel…" /></SelectTrigger>
<SelectContent>
{channels?.map(c => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{channelId && (
<div>
<p className="mb-1.5 text-xs text-zinc-400">Manual block</p>
{manualBlocks.length === 0 ? (
<p className="text-xs text-zinc-500">No manual blocks in this channel.</p>
) : (
<Select value={blockId} onValueChange={setBlockId}>
<SelectTrigger><SelectValue placeholder="Select block…" /></SelectTrigger>
<SelectContent>
{manualBlocks.map(b => (
<SelectItem key={b.id} value={b.id}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
<p className="text-xs text-zinc-500">Adding {selectedItems.length} item(s) to selected block.</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button disabled={!blockId || updateChannel.isPending} onClick={handleConfirm}>
{updateChannel.isPending ? "Saving…" : "Add items"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import { LibraryItemCard } from "./library-item-card";
import { ScheduleFromLibraryDialog } from "./schedule-from-library-dialog";
import { AddToBlockDialog } from "./add-to-block-dialog";
import { Button } from "@/components/ui/button";
import type { LibraryItemFull } from "@/lib/types";
interface Props {
items: LibraryItemFull[];
total: number;
page: number;
pageSize: number;
isLoading: boolean;
selected: Set<string>;
onToggleSelect: (id: string) => void;
onPageChange: (page: number) => void;
selectedItems: LibraryItemFull[];
}
export function LibraryGrid({
items, total, page, pageSize, isLoading,
selected, onToggleSelect, onPageChange, selectedItems,
}: Props) {
const totalPages = Math.ceil(total / pageSize);
return (
<div className="flex flex-1 flex-col min-h-0">
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<p className="text-sm text-zinc-500">Loading</p>
) : items.length === 0 ? (
<p className="text-sm text-zinc-500">No items found. Run a library sync to populate the library.</p>
) : (
<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>
)}
</div>
{totalPages > 1 && (
<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>
<div className="flex gap-2">
<Button size="sm" variant="outline" disabled={page === 0} onClick={() => onPageChange(page - 1)}>Prev</Button>
<span className="flex items-center text-xs text-zinc-400">{page + 1} / {totalPages}</span>
<Button size="sm" variant="outline" disabled={page >= totalPages - 1} onClick={() => onPageChange(page + 1)}>Next</Button>
</div>
</div>
)}
{selected.size > 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">
<span className="text-sm text-zinc-300">{selected.size} selected</span>
<ScheduleFromLibraryDialog selectedItems={selectedItems} />
<AddToBlockDialog selectedItems={selectedItems} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import type { LibraryItemFull } from "@/lib/types";
interface Props {
item: LibraryItemFull;
selected: boolean;
onToggle: () => void;
}
export function LibraryItemCard({ item, selected, onToggle }: Props) {
const [imgError, setImgError] = useState(false);
const mins = Math.ceil(item.duration_secs / 60);
return (
<div
className={`group relative cursor-pointer rounded-lg border transition-colors ${
selected
? "border-violet-500 bg-violet-950/30"
: "border-zinc-800 bg-zinc-900 hover:border-zinc-600"
}`}
onClick={onToggle}
>
<div className="aspect-video w-full overflow-hidden rounded-t-lg bg-zinc-800">
{item.thumbnail_url && !imgError ? (
<img
src={item.thumbnail_url}
alt={item.title}
className="h-full w-full object-cover"
onError={() => setImgError(true)}
/>
) : (
<div className="flex h-full items-center justify-center text-zinc-600 text-xs">No image</div>
)}
</div>
<div className="absolute left-2 top-2" onClick={e => { e.stopPropagation(); onToggle(); }}>
<Checkbox checked={selected} className="border-white/50 bg-black/40" />
</div>
<div className="p-2">
<p className="truncate text-xs font-medium text-zinc-100">{item.title}</p>
<p className="mt-0.5 text-xs text-zinc-500">
{item.content_type === "episode" && item.series_name
? `${item.series_name} S${item.season_number ?? "?"}E${item.episode_number ?? "?"}`
: item.content_type}
{" · "}{mins >= 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
"use client";
import { useCollections, useGenres } from "@/hooks/use-library";
import type { LibrarySearchParams } from "@/hooks/use-library-search";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
interface Props {
filter: LibrarySearchParams;
onFilterChange: (next: Partial<LibrarySearchParams>) => void;
}
const CONTENT_TYPES = [
{ value: "", label: "All types" },
{ value: "movie", label: "Movies" },
{ value: "episode", label: "Episodes" },
{ value: "short", label: "Shorts" },
];
export function LibrarySidebar({ filter, onFilterChange }: Props) {
const { data: collections } = useCollections(filter.provider);
const { data: genres } = useGenres(filter.type, { provider: filter.provider });
return (
<aside className="w-56 shrink-0 border-r border-zinc-800 bg-zinc-950 p-4 flex flex-col gap-4">
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-zinc-500">Search</p>
<Input
placeholder="Search…"
value={filter.q ?? ""}
onChange={e => onFilterChange({ q: e.target.value || undefined })}
className="h-8 text-xs"
/>
</div>
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-zinc-500">Type</p>
<Select value={filter.type ?? ""} onValueChange={v => onFilterChange({ type: v || undefined })}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{CONTENT_TYPES.map(ct => (
<SelectItem key={ct.value} value={ct.value}>{ct.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{collections && collections.length > 0 && (
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-zinc-500">Collection</p>
<Select value={filter.collection ?? ""} onValueChange={v => onFilterChange({ collection: v || undefined })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="All" /></SelectTrigger>
<SelectContent>
<SelectItem value="">All</SelectItem>
{collections.map(c => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{genres && genres.length > 0 && (
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-zinc-500">Genre</p>
<div className="flex flex-wrap gap-1">
{genres.map(g => {
const active = filter.genres?.includes(g) ?? false;
return (
<Badge
key={g}
variant={active ? "default" : "outline"}
className="cursor-pointer text-xs"
onClick={() => {
const current = filter.genres ?? [];
onFilterChange({
genres: active ? current.filter(x => x !== g) : [...current, g],
});
}}
>
{g}
</Badge>
);
})}
</div>
</div>
)}
</aside>
);
}

View File

@@ -0,0 +1,183 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { useChannels, useUpdateChannel } from "@/hooks/use-channels";
import type { LibraryItemFull, Weekday, ProgrammingBlock, ScheduleConfig } from "@/lib/types";
import { WEEKDAYS, WEEKDAY_LABELS } from "@/lib/types";
interface Props {
selectedItems: LibraryItemFull[];
}
export function ScheduleFromLibraryDialog({ selectedItems }: Props) {
const [open, setOpen] = useState(false);
const [channelId, setChannelId] = useState("");
const [selectedDays, setSelectedDays] = useState<Set<Weekday>>(new Set());
const [startTime, setStartTime] = useState("20:00");
const [durationMins, setDurationMins] = useState(() =>
selectedItems.length === 1 ? Math.ceil(selectedItems[0].duration_secs / 60) : 60
);
const [strategy, setStrategy] = useState<"sequential" | "random" | "best_fit">("sequential");
const { data: channels } = useChannels();
const updateChannel = useUpdateChannel();
const selectedChannel = channels?.find(c => c.id === channelId);
const isEpisodic = selectedItems.every(i => i.content_type === "episode");
const allSameSeries =
isEpisodic &&
selectedItems.length > 0 &&
new Set(selectedItems.map(i => i.series_name)).size === 1;
function toggleDay(day: Weekday) {
setSelectedDays(prev => {
const next = new Set(prev);
if (next.has(day)) next.delete(day);
else next.add(day);
return next;
});
}
async function handleConfirm() {
if (!selectedChannel || selectedDays.size === 0) return;
const startTimeFull = startTime.length === 5 ? `${startTime}:00` : startTime;
const newBlock: ProgrammingBlock = allSameSeries
? {
id: globalThis.crypto.randomUUID(),
name: `${selectedItems[0].series_name ?? "Series"}${startTime}`,
start_time: startTimeFull,
duration_mins: durationMins,
content: {
type: "algorithmic",
filter: {
content_type: "episode",
series_names: [selectedItems[0].series_name!],
genres: [],
tags: [],
collections: [],
},
strategy,
provider_id: selectedItems[0].id.split("::")[0],
},
}
: {
id: globalThis.crypto.randomUUID(),
name: `${selectedItems.length} items — ${startTime}`,
start_time: startTimeFull,
duration_mins: durationMins,
content: { type: "manual", items: selectedItems.map(i => i.id) },
};
const updatedDayBlocks = { ...selectedChannel.schedule_config.day_blocks };
for (const day of selectedDays) {
updatedDayBlocks[day] = [...(updatedDayBlocks[day] ?? []), newBlock];
}
const scheduleConfig: ScheduleConfig = { day_blocks: updatedDayBlocks };
await updateChannel.mutateAsync({
id: channelId,
data: { schedule_config: scheduleConfig },
});
setOpen(false);
}
const canConfirm = !!channelId && selectedDays.size > 0;
const daysLabel = [...selectedDays].map(d => WEEKDAY_LABELS[d]).join(", ");
const preview = canConfirm
? `${selectedDays.size} block(s) will be created on ${selectedChannel?.name}${daysLabel} at ${startTime}, ${strategy}`
: null;
return (
<>
<Button size="sm" onClick={() => setOpen(true)}>Schedule on channel</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader><DialogTitle>Schedule on channel</DialogTitle></DialogHeader>
<div className="flex flex-col gap-4">
<div>
<p className="mb-1.5 text-xs text-zinc-400">Channel</p>
<Select value={channelId} onValueChange={setChannelId}>
<SelectTrigger><SelectValue placeholder="Select channel…" /></SelectTrigger>
<SelectContent>
{channels?.map(c => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<p className="mb-1.5 text-xs text-zinc-400">Days</p>
<div className="flex flex-wrap gap-2">
{WEEKDAYS.map(day => (
<label key={day} className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={selectedDays.has(day)} onCheckedChange={() => toggleDay(day)} />
<span className="text-xs">{WEEKDAY_LABELS[day]}</span>
</label>
))}
</div>
</div>
<div className="flex gap-4">
<div className="flex-1">
<p className="mb-1.5 text-xs text-zinc-400">
Start time{selectedChannel?.timezone ? ` (${selectedChannel.timezone})` : ""}
</p>
<Input
type="time"
value={startTime}
onChange={e => setStartTime(e.target.value)}
disabled={!channelId}
/>
</div>
<div className="flex-1">
<p className="mb-1.5 text-xs text-zinc-400">Duration (mins)</p>
<Input
type="number"
min={1}
value={durationMins}
onChange={e => setDurationMins(Number(e.target.value))}
disabled={!channelId}
/>
</div>
</div>
<div>
<p className="mb-1.5 text-xs text-zinc-400">Fill strategy</p>
<Select
value={strategy}
onValueChange={(v: "sequential" | "random" | "best_fit") => setStrategy(v)}
disabled={!channelId}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="sequential">Sequential</SelectItem>
<SelectItem value="random">Random</SelectItem>
<SelectItem value="best_fit">Best fit</SelectItem>
</SelectContent>
</Select>
</div>
{preview && (
<p className="rounded-md bg-emerald-950/30 border border-emerald-800 px-3 py-2 text-xs text-emerald-300">
{preview}
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button
disabled={!canConfirm || updateChannel.isPending}
onClick={handleConfirm}
>
{updateChannel.isPending ? "Saving…" : `Create ${selectedDays.size} block(s)`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
import { useLibrarySyncStatus } from "@/hooks/use-library-sync";
export function SyncStatusBar() {
const { data: statuses } = useLibrarySyncStatus();
if (!statuses || statuses.length === 0) return null;
return (
<div className="border-b border-zinc-800 bg-zinc-900 px-6 py-1.5">
<div className="flex flex-wrap gap-4">
{statuses.map(s => (
<span key={s.id} className="text-xs text-zinc-500">
{s.provider_id}:{" "}
{s.status === "running" ? (
<span className="text-yellow-400">syncing</span>
) : s.status === "error" ? (
<span className="text-red-400">error</span>
) : (
<span className="text-zinc-400">
{s.items_found.toLocaleString()} items
{s.finished_at ? ` · synced ${new Date(s.finished_at).toLocaleTimeString()}` : ""}
</span>
)}
</span>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import { useLibrarySearch, type LibrarySearchParams } from "@/hooks/use-library-search";
import { LibrarySidebar } from "./components/library-sidebar";
import { LibraryGrid } from "./components/library-grid";
import { SyncStatusBar } from "./components/sync-status-bar";
import type { LibraryItemFull } from "@/lib/types";
const PAGE_SIZE = 50;
export default function LibraryPage() {
const [filter, setFilter] = useState<LibrarySearchParams>({ limit: PAGE_SIZE, offset: 0 });
const [selected, setSelected] = useState<Set<string>>(new Set());
const [page, setPage] = useState(0);
const { data, isLoading } = useLibrarySearch({ ...filter, offset: page * PAGE_SIZE });
function handleFilterChange(next: Partial<LibrarySearchParams>) {
setFilter(f => ({ ...f, ...next, offset: 0 }));
setPage(0);
setSelected(new Set());
}
function toggleSelect(id: string) {
setSelected(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
const selectedItems = data?.items.filter(i => selected.has(i.id)) ?? [];
return (
<div className="flex flex-1 flex-col">
<SyncStatusBar />
<div className="flex flex-1">
<LibrarySidebar filter={filter} onFilterChange={handleFilterChange} />
<LibraryGrid
items={data?.items ?? []}
total={data?.total ?? 0}
page={page}
pageSize={PAGE_SIZE}
isLoading={isLoading}
selected={selected}
onToggleSelect={toggleSelect}
onPageChange={setPage}
selectedItems={selectedItems}
/>
</div>
</div>
);
}