feat(frontend): library page, components, and schedule/add-to-block dialogs (tasks 11-14)
This commit is contained in:
@@ -6,6 +6,7 @@ import { AdminNavLink } from "./components/admin-nav-link";
|
||||
const NAV_LINKS = [
|
||||
{ href: "/tv", label: "TV" },
|
||||
{ href: "/guide", label: "Guide" },
|
||||
{ href: "/library", label: "Library" },
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{ href: "/docs", label: "Docs" },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
k-tv-frontend/app/(main)/library/components/library-grid.tsx
Normal file
68
k-tv-frontend/app/(main)/library/components/library-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
55
k-tv-frontend/app/(main)/library/page.tsx
Normal file
55
k-tv-frontend/app/(main)/library/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user