"use client"; import { useState, useEffect, useRef } from "react"; import { z } from "zod"; import { Trash2, Plus, ChevronDown, ChevronUp } from "lucide-react"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; import { TagInput } from "./tag-input"; import { BlockTimeline, BLOCK_COLORS, timeToMins, minsToTime } from "./block-timeline"; import { SeriesPicker } from "./series-picker"; import { FilterPreview } from "./filter-preview"; import { useCollections, useSeries, useGenres } from "@/hooks/use-library"; import type { ChannelResponse, ProgrammingBlock, BlockContent, FillStrategy, ContentType, MediaFilter, RecyclePolicy, } from "@/lib/types"; // --------------------------------------------------------------------------- // Zod schemas // --------------------------------------------------------------------------- const mediaFilterSchema = z.object({ content_type: z.enum(["movie", "episode", "short"]).nullable().optional(), genres: z.array(z.string()), decade: z .number() .int() .min(1900, "Decade must be ≥ 1900") .max(2099, "Decade must be ≤ 2090") .nullable() .optional(), tags: z.array(z.string()), min_duration_secs: z.number().min(0, "Must be ≥ 0").nullable().optional(), max_duration_secs: z.number().min(0, "Must be ≥ 0").nullable().optional(), collections: z.array(z.string()), series_names: z.array(z.string()), search_term: z.string().nullable().optional(), }); const blockSchema = z.object({ id: z.string(), name: z.string().min(1, "Block name is required"), start_time: z.string(), duration_mins: z.number().int().min(1, "Must be at least 1 minute"), content: z.discriminatedUnion("type", [ z.object({ type: z.literal("algorithmic"), filter: mediaFilterSchema, strategy: z.enum(["best_fit", "sequential", "random"]), }), z.object({ type: z.literal("manual"), items: z.array(z.string()), }), ]), loop_on_finish: z.boolean().optional(), ignore_recycle_policy: z.boolean().optional(), }); const channelFormSchema = z.object({ name: z.string().min(1, "Name is required"), timezone: z.string().min(1, "Timezone is required"), description: z.string().optional(), blocks: z.array(blockSchema), recycle_policy: z.object({ cooldown_days: z.number().int().min(0).nullable().optional(), cooldown_generations: z.number().int().min(0).nullable().optional(), min_available_ratio: z.number().min(0, "Must be ≥ 0").max(1, "Must be ≤ 1"), }), }); type FieldErrors = Record; function extractErrors(err: z.ZodError): FieldErrors { const map: FieldErrors = {}; for (const issue of err.issues) { const key = issue.path.join("."); if (!map[key]) map[key] = issue.message; } return map; } // --------------------------------------------------------------------------- // Field wrapper // --------------------------------------------------------------------------- function Field({ label, hint, error, children, }: { label: string; hint?: string; error?: string; children: React.ReactNode; }) { return (
{children} {error ? (

{error}

) : hint ? (

{hint}

) : null}
); } function TextInput({ value, onChange, placeholder, required, error, }: { value: string; onChange: (v: string) => void; placeholder?: string; required?: boolean; error?: boolean; }) { return ( onChange(e.target.value)} placeholder={placeholder} className={`w-full rounded-md border bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:outline-none ${error ? "border-red-500 focus:border-red-400" : "border-zinc-700 focus:border-zinc-500"}`} /> ); } function NumberInput({ value, onChange, min, max, step, placeholder, error, }: { value: number | ""; onChange: (v: number | "") => void; min?: number; max?: number; step?: number | "any"; placeholder?: string; error?: boolean; }) { return ( onChange(e.target.value === "" ? "" : Number(e.target.value))} className={`w-full rounded-md border bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:outline-none ${error ? "border-red-500 focus:border-red-400" : "border-zinc-700 focus:border-zinc-500"}`} /> ); } function NativeSelect({ value, onChange, children, }: { value: string; onChange: (v: string) => void; children: React.ReactNode; }) { return ( ); } // --------------------------------------------------------------------------- // Defaults // --------------------------------------------------------------------------- function defaultFilter(): MediaFilter { return { content_type: null, genres: [], decade: null, tags: [], min_duration_secs: null, max_duration_secs: null, collections: [], series_names: [], search_term: null, }; } function defaultBlock(startMins = 20 * 60, durationMins = 60): ProgrammingBlock { return { id: crypto.randomUUID(), name: "", start_time: minsToTime(startMins), duration_mins: durationMins, content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" }, loop_on_finish: true, ignore_recycle_policy: false, }; } // --------------------------------------------------------------------------- // AlgorithmicFilterEditor — filter section with live library data // --------------------------------------------------------------------------- interface AlgorithmicFilterEditorProps { content: Extract; pfx: string; errors: FieldErrors; setFilter: (patch: Partial) => void; setStrategy: (strategy: FillStrategy) => void; } function AlgorithmicFilterEditor({ content, pfx, errors, setFilter, setStrategy, }: AlgorithmicFilterEditorProps) { const [showGenres, setShowGenres] = useState(false); const { data: collections, isLoading: loadingCollections } = useCollections(); const { data: series, isLoading: loadingSeries } = useSeries(); const { data: genreOptions } = useGenres(content.filter.content_type ?? undefined); const isEpisode = content.filter.content_type === "episode"; return (

Filter

setFilter({ content_type: v === "" ? null : (v as ContentType), // clear series names if switching away from episode series_names: v !== "episode" ? [] : content.filter.series_names, }) } > setStrategy(v as FillStrategy)} >
{/* Series — only meaningful for episodes */} {isEpisode && ( setFilter({ series_names: v })} series={series ?? []} isLoading={loadingSeries} /> )} {/* Library — real collection names when the provider supports it */} {collections && collections.length > 0 ? ( setFilter({ collections: v ? [v] : [] })} > {collections.map((c) => ( ))} ) : ( setFilter({ collections: v })} placeholder="Library ID…" /> )} {/* Genres with browse-from-library shortcut */} setFilter({ genres: v })} placeholder="Comedy, Animation…" /> {genreOptions && genreOptions.length > 0 && (
{showGenres && (
{genreOptions .filter((g) => !content.filter.genres.includes(g)) .map((g) => ( ))}
)}
)}
setFilter({ tags: v })} placeholder="classic, family…" />
setFilter({ decade: v === "" ? null : (v as number) })} placeholder="1990" error={!!errors[`${pfx}.content.filter.decade`]} /> setFilter({ min_duration_secs: v === "" ? null : (v as number) }) } placeholder="1200" error={!!errors[`${pfx}.content.filter.min_duration_secs`]} /> setFilter({ max_duration_secs: v === "" ? null : (v as number) }) } placeholder="3600" error={!!errors[`${pfx}.content.filter.max_duration_secs`]} />
{/* Preview — snapshot of current filter+strategy, only fetches on explicit click */}
); } // --------------------------------------------------------------------------- // BlockEditor (detail form for a single block) // --------------------------------------------------------------------------- interface BlockEditorProps { block: ProgrammingBlock; index: number; isSelected: boolean; color: string; errors: FieldErrors; onChange: (block: ProgrammingBlock) => void; onRemove: () => void; onSelect: () => void; } function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemove, onSelect }: BlockEditorProps) { const [expanded, setExpanded] = useState(isSelected); const elRef = useRef(null); // Scroll into view when selected useEffect(() => { if (isSelected) { setExpanded(true); elRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" }); } }, [isSelected]); const setField = (key: K, value: ProgrammingBlock[K]) => onChange({ ...block, [key]: value }); const content = block.content; const pfx = `blocks.${index}`; const setContentType = (type: "algorithmic" | "manual") => { onChange({ ...block, content: type === "algorithmic" ? { type: "algorithmic", filter: defaultFilter(), strategy: "random" } : { type: "manual", items: [] }, }); }; const setFilter = (patch: Partial) => { if (content.type !== "algorithmic") return; onChange({ ...block, content: { ...content, filter: { ...content.filter, ...patch } } }); }; const setStrategy = (strategy: FillStrategy) => { if (content.type !== "algorithmic") return; onChange({ ...block, content: { ...content, strategy } }); }; return (
{expanded && (
setField("name", v)} placeholder="Evening Sitcoms" error={!!errors[`${pfx}.name`]} /> setContentType(v as "algorithmic" | "manual")} >
setField("start_time", e.target.value + ":00")} className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none" /> setField("duration_mins", v === "" ? 60 : v)} min={1} error={!!errors[`${pfx}.duration_mins`]} />
{content.type === "algorithmic" && ( <> {content.strategy === "sequential" && (

Sequential options

)} )} {content.type === "manual" && (

Item IDs