feat: add import functionality for channel configurations and export option for channels

This commit is contained in:
2026-03-11 21:37:18 +01:00
parent 8cc3439d2e
commit 37167fc19c
3 changed files with 350 additions and 6 deletions

View File

@@ -1,5 +1,5 @@
import Link from "next/link";
import { Pencil, Trash2, RefreshCw, Tv2, CalendarDays } from "lucide-react";
import { Pencil, Trash2, RefreshCw, Tv2, CalendarDays, Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ChannelResponse } from "@/lib/types";
@@ -10,6 +10,7 @@ interface ChannelCardProps {
onDelete: () => void;
onGenerateSchedule: () => void;
onViewSchedule: () => void;
onExport: () => void;
}
export function ChannelCard({
@@ -19,6 +20,7 @@ export function ChannelCard({
onDelete,
onGenerateSchedule,
onViewSchedule,
onExport,
}: ChannelCardProps) {
const blockCount = channel.schedule_config.blocks.length;
@@ -38,6 +40,15 @@ export function ChannelCard({
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={onExport}
title="Export as JSON"
className="text-zinc-600 hover:text-zinc-200"
>
<Download className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"

View File

@@ -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>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Plus } from "lucide-react";
import { Plus, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
useChannels,
@@ -10,14 +10,20 @@ import {
useDeleteChannel,
useGenerateSchedule,
} from "@/hooks/use-channels";
import { useAuthContext } from "@/context/auth-context";
import { api } from "@/lib/api";
import { useQueryClient } from "@tanstack/react-query";
import { ChannelCard } from "./components/channel-card";
import { CreateChannelDialog } from "./components/create-channel-dialog";
import { DeleteChannelDialog } from "./components/delete-channel-dialog";
import { EditChannelSheet } from "./components/edit-channel-sheet";
import { ScheduleSheet } from "./components/schedule-sheet";
import { ImportChannelDialog, type ChannelImportData } from "./components/import-channel-dialog";
import type { ChannelResponse, ProgrammingBlock, RecyclePolicy } from "@/lib/types";
export default function DashboardPage() {
const { token } = useAuthContext();
const queryClient = useQueryClient();
const { data: channels, isLoading, error } = useChannels();
const createChannel = useCreateChannel();
@@ -26,6 +32,9 @@ export default function DashboardPage() {
const generateSchedule = useGenerateSchedule();
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importPending, setImportPending] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [editChannel, setEditChannel] = useState<ChannelResponse | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ChannelResponse | null>(null);
const [scheduleChannel, setScheduleChannel] = useState<ChannelResponse | null>(null);
@@ -57,6 +66,46 @@ export default function DashboardPage() {
);
};
const handleImport = async (data: ChannelImportData) => {
if (!token) return;
setImportPending(true);
setImportError(null);
try {
const created = await api.channels.create(
{ name: data.name, timezone: data.timezone, description: data.description },
token,
);
await api.channels.update(
created.id,
{ schedule_config: { blocks: data.blocks }, recycle_policy: data.recycle_policy },
token,
);
await queryClient.invalidateQueries({ queryKey: ["channels"] });
setImportOpen(false);
} catch (e) {
setImportError(e instanceof Error ? e.message : "Import failed");
} finally {
setImportPending(false);
}
};
const handleExport = (channel: ChannelResponse) => {
const payload = {
name: channel.name,
description: channel.description ?? undefined,
timezone: channel.timezone,
blocks: channel.schedule_config.blocks,
recycle_policy: channel.recycle_policy,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${channel.name.toLowerCase().replace(/\s+/g, "-")}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleDelete = () => {
if (!deleteTarget) return;
deleteChannel.mutate(deleteTarget.id, {
@@ -74,10 +123,16 @@ export default function DashboardPage() {
Build your broadcast lineup
</p>
</div>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
New channel
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setImportOpen(true)} className="border-zinc-700 text-zinc-300 hover:text-zinc-100">
<Upload className="size-4" />
Import
</Button>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
New channel
</Button>
</div>
</div>
{/* Content */}
@@ -117,12 +172,21 @@ export default function DashboardPage() {
onDelete={() => setDeleteTarget(channel)}
onGenerateSchedule={() => generateSchedule.mutate(channel.id)}
onViewSchedule={() => setScheduleChannel(channel)}
onExport={() => handleExport(channel)}
/>
))}
</div>
)}
{/* Dialogs / sheets */}
<ImportChannelDialog
open={importOpen}
onOpenChange={(open) => { if (!open) { setImportOpen(false); setImportError(null); } }}
onSubmit={handleImport}
isPending={importPending}
error={importError}
/>
<CreateChannelDialog
open={createOpen}
onOpenChange={setCreateOpen}