"use client";
import { useState, useEffect } from "react";
import { Trash2, Plus, ChevronDown, ChevronUp } from "lucide-react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import type {
ChannelResponse,
ProgrammingBlock,
BlockContent,
FillStrategy,
ContentType,
MediaFilter,
RecyclePolicy,
} from "@/lib/types";
// ---------------------------------------------------------------------------
// Sub-components (all dumb, no hooks)
// ---------------------------------------------------------------------------
interface FieldProps {
label: string;
hint?: string;
children: React.ReactNode;
}
function Field({ label, hint, children }: FieldProps) {
return (
{label}
{children}
{hint &&
{hint}
}
);
}
function TextInput({
value,
onChange,
placeholder,
required,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
required?: boolean;
}) {
return (
onChange(e.target.value)}
placeholder={placeholder}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
);
}
function NumberInput({
value,
onChange,
min,
max,
placeholder,
}: {
value: number | "";
onChange: (v: number | "") => void;
min?: number;
max?: number;
placeholder?: string;
}) {
return (
onChange(e.target.value === "" ? "" : Number(e.target.value))
}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
);
}
function NativeSelect({
value,
onChange,
children,
}: {
value: string;
onChange: (v: string) => void;
children: React.ReactNode;
}) {
return (
onChange(e.target.value)}
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"
>
{children}
);
}
// ---------------------------------------------------------------------------
// Block editor
// ---------------------------------------------------------------------------
function defaultFilter(): MediaFilter {
return {
content_type: null,
genres: [],
decade: null,
tags: [],
min_duration_secs: null,
max_duration_secs: null,
collections: [],
};
}
function defaultBlock(): ProgrammingBlock {
return {
id: crypto.randomUUID(),
name: "",
start_time: "20:00:00",
duration_mins: 60,
content: {
type: "algorithmic",
filter: defaultFilter(),
strategy: "random",
},
};
}
interface BlockEditorProps {
block: ProgrammingBlock;
onChange: (block: ProgrammingBlock) => void;
onRemove: () => void;
}
function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) {
const [expanded, setExpanded] = useState(true);
const setField = (
key: K,
value: ProgrammingBlock[K],
) => onChange({ ...block, [key]: value });
const content = block.content;
const setContentType = (type: "algorithmic" | "manual") => {
if (type === "algorithmic") {
onChange({
...block,
content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" },
});
} else {
onChange({ ...block, content: { 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 (
{/* Block header */}
setExpanded((v) => !v)}
className="flex flex-1 items-center gap-2 text-left text-sm font-medium text-zinc-200"
>
{expanded ? (
) : (
)}
{block.name || "Unnamed block"}
{block.start_time.slice(0, 5)} · {block.duration_mins}m
{expanded && (
setField("name", v)}
placeholder="Evening Sitcoms"
/>
setContentType(v as "algorithmic" | "manual")}
>
Algorithmic
Manual
setField("start_time", v + ":00")}
placeholder="20:00"
/>
setField("duration_mins", v === "" ? 60 : v)}
min={1}
/>
{content.type === "algorithmic" && (
Filter
setFilter({
content_type: v === "" ? null : (v as ContentType),
})
}
>
Any
Movie
Episode
Short
setStrategy(v as FillStrategy)}
>
Random
Best fit
Sequential
setFilter({
genres: v
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
placeholder="Comedy, Sci-Fi"
/>
setFilter({ decade: v === "" ? null : (v as number) })
}
placeholder="1990"
/>
setFilter({
min_duration_secs: v === "" ? null : (v as number),
})
}
placeholder="1200"
/>
setFilter({
max_duration_secs: v === "" ? null : (v as number),
})
}
placeholder="3600"
/>
setFilter({
collections: v
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
placeholder="abc123"
/>
)}
{content.type === "manual" && (
)}
)}
);
}
// ---------------------------------------------------------------------------
// Recycle policy editor
// ---------------------------------------------------------------------------
interface RecyclePolicyEditorProps {
policy: RecyclePolicy;
onChange: (policy: RecyclePolicy) => void;
}
function RecyclePolicyEditor({ policy, onChange }: RecyclePolicyEditorProps) {
return (
onChange({
...policy,
cooldown_days: v === "" ? null : (v as number),
})
}
min={0}
placeholder="7"
/>
onChange({
...policy,
cooldown_generations: v === "" ? null : (v as number),
})
}
min={0}
placeholder="3"
/>
onChange({
...policy,
min_available_ratio: v === "" ? 0.1 : (v as number),
})
}
min={0}
max={1}
placeholder="0.1"
/>
);
}
// ---------------------------------------------------------------------------
// Main sheet
// ---------------------------------------------------------------------------
interface EditChannelSheetProps {
channel: ChannelResponse | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (
id: string,
data: {
name: string;
description: string;
timezone: string;
schedule_config: { blocks: ProgrammingBlock[] };
recycle_policy: RecyclePolicy;
},
) => void;
isPending: boolean;
error?: string | null;
}
export function EditChannelSheet({
channel,
open,
onOpenChange,
onSubmit,
isPending,
error,
}: EditChannelSheetProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [timezone, setTimezone] = useState("UTC");
const [blocks, setBlocks] = useState([]);
const [recyclePolicy, setRecyclePolicy] = useState({
cooldown_days: null,
cooldown_generations: null,
min_available_ratio: 0.1,
});
// Sync from channel whenever it changes or sheet opens
useEffect(() => {
if (channel) {
setName(channel.name);
setDescription(channel.description ?? "");
setTimezone(channel.timezone);
setBlocks(channel.schedule_config.blocks);
setRecyclePolicy(channel.recycle_policy);
}
}, [channel]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!channel) return;
onSubmit(channel.id, {
name,
description,
timezone,
schedule_config: { blocks },
recycle_policy: recyclePolicy,
});
};
const addBlock = () => setBlocks((prev) => [...prev, defaultBlock()]);
const updateBlock = (idx: number, block: ProgrammingBlock) =>
setBlocks((prev) => prev.map((b, i) => (i === idx ? block : b)));
const removeBlock = (idx: number) =>
setBlocks((prev) => prev.filter((_, i) => i !== idx));
return (
Edit channel
);
}