diff --git a/k-tv-frontend/app/(main)/layout.tsx b/k-tv-frontend/app/(main)/layout.tsx index ef26bd7..c6ab6d3 100644 --- a/k-tv-frontend/app/(main)/layout.tsx +++ b/k-tv-frontend/app/(main)/layout.tsx @@ -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" }, ]; diff --git a/k-tv-frontend/app/(main)/library/components/add-to-block-dialog.tsx b/k-tv-frontend/app/(main)/library/components/add-to-block-dialog.tsx new file mode 100644 index 0000000..55e665d --- /dev/null +++ b/k-tv-frontend/app/(main)/library/components/add-to-block-dialog.tsx @@ -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(); + 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 ( + <> + + + + Add to existing block +
+
+

Channel

+ +
+ {channelId && ( +
+

Manual block

+ {manualBlocks.length === 0 ? ( +

No manual blocks in this channel.

+ ) : ( + + )} +
+ )} +

Adding {selectedItems.length} item(s) to selected block.

+
+ + + + +
+
+ + ); +} diff --git a/k-tv-frontend/app/(main)/library/components/library-grid.tsx b/k-tv-frontend/app/(main)/library/components/library-grid.tsx new file mode 100644 index 0000000..1486d0f --- /dev/null +++ b/k-tv-frontend/app/(main)/library/components/library-grid.tsx @@ -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; + 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 ( +
+
+ {isLoading ? ( +

Loading…

+ ) : items.length === 0 ? ( +

No items found. Run a library sync to populate the library.

+ ) : ( +
+ {items.map(item => ( + onToggleSelect(item.id)} + /> + ))} +
+ )} +
+ + {totalPages > 1 && ( +
+

{total.toLocaleString()} items total

+
+ + {page + 1} / {totalPages} + +
+
+ )} + + {selected.size > 0 && ( +
+ {selected.size} selected + + +
+ )} +
+ ); +} diff --git a/k-tv-frontend/app/(main)/library/components/library-item-card.tsx b/k-tv-frontend/app/(main)/library/components/library-item-card.tsx new file mode 100644 index 0000000..1b53086 --- /dev/null +++ b/k-tv-frontend/app/(main)/library/components/library-item-card.tsx @@ -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 ( +
+
+ {item.thumbnail_url && !imgError ? ( + {item.title} setImgError(true)} + /> + ) : ( +
No image
+ )} +
+
{ e.stopPropagation(); onToggle(); }}> + +
+
+

{item.title}

+

+ {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`} +

+
+
+ ); +} diff --git a/k-tv-frontend/app/(main)/library/components/library-sidebar.tsx b/k-tv-frontend/app/(main)/library/components/library-sidebar.tsx new file mode 100644 index 0000000..4952ada --- /dev/null +++ b/k-tv-frontend/app/(main)/library/components/library-sidebar.tsx @@ -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) => 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 ( + + ); +} diff --git a/k-tv-frontend/app/(main)/library/components/schedule-from-library-dialog.tsx b/k-tv-frontend/app/(main)/library/components/schedule-from-library-dialog.tsx new file mode 100644 index 0000000..97ee782 --- /dev/null +++ b/k-tv-frontend/app/(main)/library/components/schedule-from-library-dialog.tsx @@ -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>(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 ( + <> + + + + Schedule on channel +
+
+

Channel

+ +
+
+

Days

+
+ {WEEKDAYS.map(day => ( + + ))} +
+
+
+
+

+ Start time{selectedChannel?.timezone ? ` (${selectedChannel.timezone})` : ""} +

+ setStartTime(e.target.value)} + disabled={!channelId} + /> +
+
+

Duration (mins)

+ setDurationMins(Number(e.target.value))} + disabled={!channelId} + /> +
+
+
+

Fill strategy

+ +
+ {preview && ( +

+ {preview} +

+ )} +
+ + + + +
+
+ + ); +} diff --git a/k-tv-frontend/app/(main)/library/components/sync-status-bar.tsx b/k-tv-frontend/app/(main)/library/components/sync-status-bar.tsx new file mode 100644 index 0000000..7c41195 --- /dev/null +++ b/k-tv-frontend/app/(main)/library/components/sync-status-bar.tsx @@ -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 ( +
+
+ {statuses.map(s => ( + + {s.provider_id}:{" "} + {s.status === "running" ? ( + syncing… + ) : s.status === "error" ? ( + error + ) : ( + + {s.items_found.toLocaleString()} items + {s.finished_at ? ` · synced ${new Date(s.finished_at).toLocaleTimeString()}` : ""} + + )} + + ))} +
+
+ ); +} diff --git a/k-tv-frontend/app/(main)/library/page.tsx b/k-tv-frontend/app/(main)/library/page.tsx new file mode 100644 index 0000000..08f73c3 --- /dev/null +++ b/k-tv-frontend/app/(main)/library/page.tsx @@ -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({ limit: PAGE_SIZE, offset: 0 }); + const [selected, setSelected] = useState>(new Set()); + const [page, setPage] = useState(0); + + const { data, isLoading } = useLibrarySearch({ ...filter, offset: page * PAGE_SIZE }); + + function handleFilterChange(next: Partial) { + 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 ( +
+ +
+ + +
+
+ ); +}