feat: add import functionality for channel configurations and export option for channels
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
"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<typeof importSchema>;
|
||||
|
||||
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<string | null>(null);
|
||||
const [parsed, setParsed] = useState<ChannelImportData | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import channel</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-1">
|
||||
<p className="text-xs text-zinc-500">
|
||||
Paste JSON or upload a file. Works with exported channel configs or LLM-generated blocks.
|
||||
</p>
|
||||
|
||||
{/* Textarea + drag-and-drop */}
|
||||
<div
|
||||
onDragOver={(e) => { 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"
|
||||
}`}
|
||||
>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => processText(e.target.value)}
|
||||
rows={12}
|
||||
placeholder={`{\n "name": "My Channel",\n "timezone": "UTC",\n "blocks": [\n {\n "name": "Evening Movies",\n "start_time": "20:00",\n "duration_mins": 180,\n "content": {\n "type": "algorithmic",\n "filter": { "genres": ["Drama"] },\n "strategy": "random"\n }\n }\n ]\n}`}
|
||||
spellCheck={false}
|
||||
className="w-full resize-none rounded-md bg-transparent px-3 py-2.5 font-mono text-xs text-zinc-100 placeholder:text-zinc-700 focus:outline-none"
|
||||
/>
|
||||
{dragging && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md bg-zinc-800/80">
|
||||
<p className="text-sm text-zinc-300">Drop JSON file here</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File upload */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-400 hover:border-zinc-600 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
<Upload className="size-3" />
|
||||
Upload .json file
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
||||
/>
|
||||
{parsed && (
|
||||
<span className="text-xs text-zinc-500">
|
||||
Drag-and-drop a file or paste JSON above
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Validation feedback */}
|
||||
{parseError && (
|
||||
<p className="rounded-md bg-red-950/40 px-3 py-2 text-xs text-red-400 border border-red-900/50">
|
||||
{parseError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{parsed && (
|
||||
<div className="rounded-md border border-zinc-700 bg-zinc-800/60 px-3 py-2.5 space-y-1">
|
||||
<p className="text-xs font-medium text-zinc-300">{parsed.name}</p>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 text-[11px] text-zinc-500">
|
||||
{parsed.description && <span>{parsed.description}</span>}
|
||||
<span>{parsed.timezone}</span>
|
||||
<span>{parsed.blocks.length} {parsed.blocks.length === 1 ? "block" : "blocks"}</span>
|
||||
</div>
|
||||
{parsed.blocks.length > 0 && (
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{parsed.blocks.slice(0, 5).map((b, i) => (
|
||||
<li key={i} className="text-[11px] text-zinc-600">
|
||||
· {b.name} — {b.start_time.slice(0, 5)}, {b.duration_mins}m
|
||||
</li>
|
||||
))}
|
||||
{parsed.blocks.length > 5 && (
|
||||
<li className="text-[11px] text-zinc-700">
|
||||
… and {parsed.blocks.length - 5} more
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={handleClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => parsed && onSubmit(parsed)}
|
||||
disabled={!parsed || isPending}
|
||||
>
|
||||
{isPending ? "Importing…" : "Import channel"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user