"use client"; import { useState, useRef } from "react"; import { z } from "zod"; import { Upload } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import type { ProgrammingBlock, RecyclePolicy } from "@/lib/types"; // --------------------------------------------------------------------------- // Import schema — lenient so LLM output and community exports both work // --------------------------------------------------------------------------- const importBlockSchema = z.object({ id: z.string().optional(), name: z.string().default("Unnamed block"), start_time: z .string() .regex(/^\d{2}:\d{2}(:\d{2})?$/, "start_time must be HH:MM or HH:MM:SS") .transform((t) => (t.length === 5 ? t + ":00" : t)), duration_mins: z.number().int().min(1, "duration_mins must be ≥ 1"), content: z.discriminatedUnion("type", [ z.object({ type: z.literal("algorithmic"), filter: z.object({ content_type: z.enum(["movie", "episode", "short"]).nullable().optional(), genres: z.array(z.string()).default([]), decade: z.number().nullable().optional(), tags: z.array(z.string()).default([]), min_duration_secs: z.number().nullable().optional(), max_duration_secs: z.number().nullable().optional(), collections: z.array(z.string()).default([]), }), strategy: z.enum(["best_fit", "sequential", "random"]).default("random"), }), z.object({ type: z.literal("manual"), items: z.array(z.string()).default([]), }), ]), }); const recyclePolicySchema = 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).max(1).default(0.1), }) .default({ min_available_ratio: 0.1 }); // Accept blocks at top level OR nested under schedule_config (matches our own export format) const importSchema = z .object({ name: z.string().default("Imported Channel"), description: z.string().optional(), timezone: z.string().default("UTC"), blocks: z.array(importBlockSchema).optional(), schedule_config: z.object({ blocks: z.array(importBlockSchema).optional() }).optional(), recycle_policy: recyclePolicySchema, }) .transform((d) => ({ name: d.name, description: d.description, timezone: d.timezone, blocks: (d.blocks ?? d.schedule_config?.blocks ?? []).map((b) => ({ ...b, id: b.id ?? crypto.randomUUID(), })) as ProgrammingBlock[], recycle_policy: d.recycle_policy as RecyclePolicy, })); export type ChannelImportData = z.output; export function parseChannelJson(text: string): { data: ChannelImportData } | { error: string } { let raw: unknown; try { raw = JSON.parse(text); } catch { return { error: "Invalid JSON — check for missing commas, brackets, or quotes." }; } const result = importSchema.safeParse(raw); if (!result.success) { return { error: result.error.issues[0].message }; } return { data: result.data }; } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- interface ImportChannelDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onSubmit: (data: ChannelImportData) => void; isPending: boolean; error?: string | null; } export function ImportChannelDialog({ open, onOpenChange, onSubmit, isPending, error, }: ImportChannelDialogProps) { const [text, setText] = useState(""); const [parseError, setParseError] = useState(null); const [parsed, setParsed] = useState(null); const [dragging, setDragging] = useState(false); const fileInputRef = useRef(null); const processText = (value: string) => { setText(value); if (!value.trim()) { setParsed(null); setParseError(null); return; } const result = parseChannelJson(value); if ("error" in result) { setParsed(null); setParseError(result.error); } else { setParsed(result.data); setParseError(null); } }; const handleFile = (file: File) => { if (!file.name.endsWith(".json") && file.type !== "application/json") { setParseError("Please upload a .json file."); return; } file.text().then(processText); }; const handleClose = () => { if (!isPending) { onOpenChange(false); setText(""); setParsed(null); setParseError(null); } }; return ( Import channel

Paste JSON or upload a file. Works with exported channel configs or LLM-generated blocks.

{/* Textarea + drag-and-drop */}
{ e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={(e) => { e.preventDefault(); setDragging(false); const file = e.dataTransfer.files[0]; if (file) handleFile(file); }} className={`relative rounded-md border transition-colors ${ dragging ? "border-zinc-500 bg-zinc-800" : "border-zinc-700" }`} >