feat: add import functionality for channel configurations and export option for channels
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
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 { Button } from "@/components/ui/button";
|
||||||
import type { ChannelResponse } from "@/lib/types";
|
import type { ChannelResponse } from "@/lib/types";
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ interface ChannelCardProps {
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onGenerateSchedule: () => void;
|
onGenerateSchedule: () => void;
|
||||||
onViewSchedule: () => void;
|
onViewSchedule: () => void;
|
||||||
|
onExport: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChannelCard({
|
export function ChannelCard({
|
||||||
@@ -19,6 +20,7 @@ export function ChannelCard({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onGenerateSchedule,
|
onGenerateSchedule,
|
||||||
onViewSchedule,
|
onViewSchedule,
|
||||||
|
onExport,
|
||||||
}: ChannelCardProps) {
|
}: ChannelCardProps) {
|
||||||
const blockCount = channel.schedule_config.blocks.length;
|
const blockCount = channel.schedule_config.blocks.length;
|
||||||
|
|
||||||
@@ -38,6 +40,15 @@ export function ChannelCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, Upload } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
useChannels,
|
useChannels,
|
||||||
@@ -10,14 +10,20 @@ import {
|
|||||||
useDeleteChannel,
|
useDeleteChannel,
|
||||||
useGenerateSchedule,
|
useGenerateSchedule,
|
||||||
} from "@/hooks/use-channels";
|
} 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 { ChannelCard } from "./components/channel-card";
|
||||||
import { CreateChannelDialog } from "./components/create-channel-dialog";
|
import { CreateChannelDialog } from "./components/create-channel-dialog";
|
||||||
import { DeleteChannelDialog } from "./components/delete-channel-dialog";
|
import { DeleteChannelDialog } from "./components/delete-channel-dialog";
|
||||||
import { EditChannelSheet } from "./components/edit-channel-sheet";
|
import { EditChannelSheet } from "./components/edit-channel-sheet";
|
||||||
import { ScheduleSheet } from "./components/schedule-sheet";
|
import { ScheduleSheet } from "./components/schedule-sheet";
|
||||||
|
import { ImportChannelDialog, type ChannelImportData } from "./components/import-channel-dialog";
|
||||||
import type { ChannelResponse, ProgrammingBlock, RecyclePolicy } from "@/lib/types";
|
import type { ChannelResponse, ProgrammingBlock, RecyclePolicy } from "@/lib/types";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { data: channels, isLoading, error } = useChannels();
|
const { data: channels, isLoading, error } = useChannels();
|
||||||
|
|
||||||
const createChannel = useCreateChannel();
|
const createChannel = useCreateChannel();
|
||||||
@@ -26,6 +32,9 @@ export default function DashboardPage() {
|
|||||||
const generateSchedule = useGenerateSchedule();
|
const generateSchedule = useGenerateSchedule();
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
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 [editChannel, setEditChannel] = useState<ChannelResponse | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<ChannelResponse | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<ChannelResponse | null>(null);
|
||||||
const [scheduleChannel, setScheduleChannel] = 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 = () => {
|
const handleDelete = () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
deleteChannel.mutate(deleteTarget.id, {
|
deleteChannel.mutate(deleteTarget.id, {
|
||||||
@@ -74,11 +123,17 @@ export default function DashboardPage() {
|
|||||||
Build your broadcast lineup
|
Build your broadcast lineup
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<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)}>
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New channel
|
New channel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
@@ -117,12 +172,21 @@ export default function DashboardPage() {
|
|||||||
onDelete={() => setDeleteTarget(channel)}
|
onDelete={() => setDeleteTarget(channel)}
|
||||||
onGenerateSchedule={() => generateSchedule.mutate(channel.id)}
|
onGenerateSchedule={() => generateSchedule.mutate(channel.id)}
|
||||||
onViewSchedule={() => setScheduleChannel(channel)}
|
onViewSchedule={() => setScheduleChannel(channel)}
|
||||||
|
onExport={() => handleExport(channel)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dialogs / sheets */}
|
{/* Dialogs / sheets */}
|
||||||
|
<ImportChannelDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={(open) => { if (!open) { setImportOpen(false); setImportError(null); } }}
|
||||||
|
onSubmit={handleImport}
|
||||||
|
isPending={importPending}
|
||||||
|
error={importError}
|
||||||
|
/>
|
||||||
|
|
||||||
<CreateChannelDialog
|
<CreateChannelDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onOpenChange={setCreateOpen}
|
onOpenChange={setCreateOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user