885 lines
30 KiB
TypeScript
885 lines
30 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useRef } from "react";
|
||
import { z } from "zod";
|
||
import { Trash2, Plus, ChevronDown, ChevronUp } from "lucide-react";
|
||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||
import { Button } from "@/components/ui/button";
|
||
import { TagInput } from "./tag-input";
|
||
import { BlockTimeline, BLOCK_COLORS, timeToMins, minsToTime } from "./block-timeline";
|
||
import { SeriesPicker } from "./series-picker";
|
||
import { FilterPreview } from "./filter-preview";
|
||
import { useCollections, useSeries, useGenres } from "@/hooks/use-library";
|
||
import type {
|
||
ChannelResponse,
|
||
ProgrammingBlock,
|
||
BlockContent,
|
||
FillStrategy,
|
||
ContentType,
|
||
MediaFilter,
|
||
RecyclePolicy,
|
||
} from "@/lib/types";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Zod schemas
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const mediaFilterSchema = z.object({
|
||
content_type: z.enum(["movie", "episode", "short"]).nullable().optional(),
|
||
genres: z.array(z.string()),
|
||
decade: z
|
||
.number()
|
||
.int()
|
||
.min(1900, "Decade must be ≥ 1900")
|
||
.max(2099, "Decade must be ≤ 2090")
|
||
.nullable()
|
||
.optional(),
|
||
tags: z.array(z.string()),
|
||
min_duration_secs: z.number().min(0, "Must be ≥ 0").nullable().optional(),
|
||
max_duration_secs: z.number().min(0, "Must be ≥ 0").nullable().optional(),
|
||
collections: z.array(z.string()),
|
||
series_names: z.array(z.string()),
|
||
search_term: z.string().nullable().optional(),
|
||
});
|
||
|
||
const blockSchema = z.object({
|
||
id: z.string(),
|
||
name: z.string().min(1, "Block name is required"),
|
||
start_time: z.string(),
|
||
duration_mins: z.number().int().min(1, "Must be at least 1 minute"),
|
||
content: z.discriminatedUnion("type", [
|
||
z.object({
|
||
type: z.literal("algorithmic"),
|
||
filter: mediaFilterSchema,
|
||
strategy: z.enum(["best_fit", "sequential", "random"]),
|
||
}),
|
||
z.object({
|
||
type: z.literal("manual"),
|
||
items: z.array(z.string()),
|
||
}),
|
||
]),
|
||
loop_on_finish: z.boolean().optional(),
|
||
ignore_recycle_policy: z.boolean().optional(),
|
||
});
|
||
|
||
const channelFormSchema = z.object({
|
||
name: z.string().min(1, "Name is required"),
|
||
timezone: z.string().min(1, "Timezone is required"),
|
||
description: z.string().optional(),
|
||
blocks: z.array(blockSchema),
|
||
recycle_policy: 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, "Must be ≥ 0").max(1, "Must be ≤ 1"),
|
||
}),
|
||
});
|
||
|
||
type FieldErrors = Record<string, string | undefined>;
|
||
|
||
function extractErrors(err: z.ZodError): FieldErrors {
|
||
const map: FieldErrors = {};
|
||
for (const issue of err.issues) {
|
||
const key = issue.path.join(".");
|
||
if (!map[key]) map[key] = issue.message;
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Field wrapper
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function Field({
|
||
label,
|
||
hint,
|
||
error,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
hint?: string;
|
||
error?: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-medium text-zinc-400">{label}</label>
|
||
{children}
|
||
{error ? (
|
||
<p className="text-[11px] text-red-400">{error}</p>
|
||
) : hint ? (
|
||
<p className="text-[11px] text-zinc-600">{hint}</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TextInput({
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
required,
|
||
error,
|
||
}: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
placeholder?: string;
|
||
required?: boolean;
|
||
error?: boolean;
|
||
}) {
|
||
return (
|
||
<input
|
||
required={required}
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={placeholder}
|
||
className={`w-full rounded-md border bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:outline-none ${error ? "border-red-500 focus:border-red-400" : "border-zinc-700 focus:border-zinc-500"}`}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function NumberInput({
|
||
value,
|
||
onChange,
|
||
min,
|
||
max,
|
||
step,
|
||
placeholder,
|
||
error,
|
||
}: {
|
||
value: number | "";
|
||
onChange: (v: number | "") => void;
|
||
min?: number;
|
||
max?: number;
|
||
step?: number | "any";
|
||
placeholder?: string;
|
||
error?: boolean;
|
||
}) {
|
||
return (
|
||
<input
|
||
type="number"
|
||
min={min}
|
||
max={max}
|
||
step={step}
|
||
value={value}
|
||
placeholder={placeholder}
|
||
onChange={(e) => onChange(e.target.value === "" ? "" : Number(e.target.value))}
|
||
className={`w-full rounded-md border bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:outline-none ${error ? "border-red-500 focus:border-red-400" : "border-zinc-700 focus:border-zinc-500"}`}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function NativeSelect({
|
||
value,
|
||
onChange,
|
||
children,
|
||
}: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<select
|
||
value={value}
|
||
onChange={(e) => 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}
|
||
</select>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Defaults
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function defaultFilter(): MediaFilter {
|
||
return {
|
||
content_type: null,
|
||
genres: [],
|
||
decade: null,
|
||
tags: [],
|
||
min_duration_secs: null,
|
||
max_duration_secs: null,
|
||
collections: [],
|
||
series_names: [],
|
||
search_term: null,
|
||
};
|
||
}
|
||
|
||
function defaultBlock(startMins = 20 * 60, durationMins = 60): ProgrammingBlock {
|
||
return {
|
||
id: crypto.randomUUID(),
|
||
name: "",
|
||
start_time: minsToTime(startMins),
|
||
duration_mins: durationMins,
|
||
content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" },
|
||
loop_on_finish: true,
|
||
ignore_recycle_policy: false,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// AlgorithmicFilterEditor — filter section with live library data
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface AlgorithmicFilterEditorProps {
|
||
content: Extract<BlockContent, { type: "algorithmic" }>;
|
||
pfx: string;
|
||
errors: FieldErrors;
|
||
setFilter: (patch: Partial<MediaFilter>) => void;
|
||
setStrategy: (strategy: FillStrategy) => void;
|
||
}
|
||
|
||
function AlgorithmicFilterEditor({
|
||
content,
|
||
pfx,
|
||
errors,
|
||
setFilter,
|
||
setStrategy,
|
||
}: AlgorithmicFilterEditorProps) {
|
||
const [showGenres, setShowGenres] = useState(false);
|
||
|
||
const { data: collections, isLoading: loadingCollections } = useCollections();
|
||
const { data: series, isLoading: loadingSeries } = useSeries();
|
||
const { data: genreOptions } = useGenres(content.filter.content_type ?? undefined);
|
||
|
||
const isEpisode = content.filter.content_type === "episode";
|
||
|
||
return (
|
||
<div className="space-y-3 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
|
||
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Filter</p>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Media type">
|
||
<NativeSelect
|
||
value={content.filter.content_type ?? ""}
|
||
onChange={(v) =>
|
||
setFilter({
|
||
content_type: v === "" ? null : (v as ContentType),
|
||
// clear series names if switching away from episode
|
||
series_names: v !== "episode" ? [] : content.filter.series_names,
|
||
})
|
||
}
|
||
>
|
||
<option value="">Any</option>
|
||
<option value="movie">Movie</option>
|
||
<option value="episode">Episode</option>
|
||
<option value="short">Short</option>
|
||
</NativeSelect>
|
||
</Field>
|
||
<Field label="Strategy">
|
||
<NativeSelect
|
||
value={content.strategy}
|
||
onChange={(v) => setStrategy(v as FillStrategy)}
|
||
>
|
||
<option value="random">Random</option>
|
||
<option value="best_fit">Best fit</option>
|
||
<option value="sequential">Sequential</option>
|
||
</NativeSelect>
|
||
</Field>
|
||
</div>
|
||
|
||
{/* Series — only meaningful for episodes */}
|
||
{isEpisode && (
|
||
<Field
|
||
label="Series"
|
||
hint={
|
||
content.strategy === "sequential"
|
||
? "Episodes will play in chronological order"
|
||
: "Filter to specific shows, or leave empty for all"
|
||
}
|
||
>
|
||
<SeriesPicker
|
||
values={content.filter.series_names ?? []}
|
||
onChange={(v) => setFilter({ series_names: v })}
|
||
series={series ?? []}
|
||
isLoading={loadingSeries}
|
||
/>
|
||
</Field>
|
||
)}
|
||
|
||
{/* Library — real collection names when the provider supports it */}
|
||
<Field
|
||
label="Library"
|
||
hint={
|
||
loadingCollections
|
||
? "Loading libraries…"
|
||
: collections
|
||
? "Scope this block to one library"
|
||
: "Enter a provider library ID"
|
||
}
|
||
>
|
||
{collections && collections.length > 0 ? (
|
||
<NativeSelect
|
||
value={content.filter.collections[0] ?? ""}
|
||
onChange={(v) => setFilter({ collections: v ? [v] : [] })}
|
||
>
|
||
<option value="">All libraries</option>
|
||
{collections.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{c.name}
|
||
{c.collection_type ? ` (${c.collection_type})` : ""}
|
||
</option>
|
||
))}
|
||
</NativeSelect>
|
||
) : (
|
||
<TagInput
|
||
values={content.filter.collections}
|
||
onChange={(v) => setFilter({ collections: v })}
|
||
placeholder="Library ID…"
|
||
/>
|
||
)}
|
||
</Field>
|
||
|
||
{/* Genres with browse-from-library shortcut */}
|
||
<Field label="Genres" hint="Press Enter or comma to add">
|
||
<TagInput
|
||
values={content.filter.genres}
|
||
onChange={(v) => setFilter({ genres: v })}
|
||
placeholder="Comedy, Animation…"
|
||
/>
|
||
{genreOptions && genreOptions.length > 0 && (
|
||
<div className="mt-1.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowGenres((s) => !s)}
|
||
className="text-[11px] text-zinc-600 hover:text-zinc-400"
|
||
>
|
||
{showGenres ? "Hide" : "Browse"} available genres
|
||
</button>
|
||
{showGenres && (
|
||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||
{genreOptions
|
||
.filter((g) => !content.filter.genres.includes(g))
|
||
.map((g) => (
|
||
<button
|
||
key={g}
|
||
type="button"
|
||
onClick={() => setFilter({ genres: [...content.filter.genres, g] })}
|
||
className="rounded px-1.5 py-0.5 text-[11px] bg-zinc-700/50 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||
>
|
||
+ {g}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Field>
|
||
|
||
<Field label="Tags" hint="Press Enter or comma to add">
|
||
<TagInput
|
||
values={content.filter.tags}
|
||
onChange={(v) => setFilter({ tags: v })}
|
||
placeholder="classic, family…"
|
||
/>
|
||
</Field>
|
||
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<Field label="Decade" hint="e.g. 1990" error={errors[`${pfx}.content.filter.decade`]}>
|
||
<NumberInput
|
||
value={content.filter.decade ?? ""}
|
||
onChange={(v) => setFilter({ decade: v === "" ? null : (v as number) })}
|
||
placeholder="1990"
|
||
error={!!errors[`${pfx}.content.filter.decade`]}
|
||
/>
|
||
</Field>
|
||
<Field label="Min duration (s)" error={errors[`${pfx}.content.filter.min_duration_secs`]}>
|
||
<NumberInput
|
||
value={content.filter.min_duration_secs ?? ""}
|
||
onChange={(v) =>
|
||
setFilter({ min_duration_secs: v === "" ? null : (v as number) })
|
||
}
|
||
placeholder="1200"
|
||
error={!!errors[`${pfx}.content.filter.min_duration_secs`]}
|
||
/>
|
||
</Field>
|
||
<Field label="Max duration (s)" error={errors[`${pfx}.content.filter.max_duration_secs`]}>
|
||
<NumberInput
|
||
value={content.filter.max_duration_secs ?? ""}
|
||
onChange={(v) =>
|
||
setFilter({ max_duration_secs: v === "" ? null : (v as number) })
|
||
}
|
||
placeholder="3600"
|
||
error={!!errors[`${pfx}.content.filter.max_duration_secs`]}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
|
||
{/* Preview — snapshot of current filter+strategy, only fetches on explicit click */}
|
||
<FilterPreview filter={content.filter} strategy={content.strategy} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// BlockEditor (detail form for a single block)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface BlockEditorProps {
|
||
block: ProgrammingBlock;
|
||
index: number;
|
||
isSelected: boolean;
|
||
color: string;
|
||
errors: FieldErrors;
|
||
onChange: (block: ProgrammingBlock) => void;
|
||
onRemove: () => void;
|
||
onSelect: () => void;
|
||
}
|
||
|
||
function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemove, onSelect }: BlockEditorProps) {
|
||
const [expanded, setExpanded] = useState(isSelected);
|
||
const elRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Scroll into view when selected
|
||
useEffect(() => {
|
||
if (isSelected) {
|
||
setExpanded(true);
|
||
elRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||
}
|
||
}, [isSelected]);
|
||
|
||
const setField = <K extends keyof ProgrammingBlock>(key: K, value: ProgrammingBlock[K]) =>
|
||
onChange({ ...block, [key]: value });
|
||
|
||
const content = block.content;
|
||
const pfx = `blocks.${index}`;
|
||
|
||
const setContentType = (type: "algorithmic" | "manual") => {
|
||
onChange({
|
||
...block,
|
||
content:
|
||
type === "algorithmic"
|
||
? { type: "algorithmic", filter: defaultFilter(), strategy: "random" }
|
||
: { type: "manual", items: [] },
|
||
});
|
||
};
|
||
|
||
const setFilter = (patch: Partial<MediaFilter>) => {
|
||
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 (
|
||
<div
|
||
ref={elRef}
|
||
className={`rounded-lg border bg-zinc-800/50 ${isSelected ? "border-zinc-500" : "border-zinc-700"}`}
|
||
>
|
||
<div className="flex items-center gap-2 px-3 py-2">
|
||
<div className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||
<button
|
||
type="button"
|
||
onClick={() => { setExpanded((v) => !v); onSelect(); }}
|
||
className="flex flex-1 items-center gap-2 text-left text-sm font-medium text-zinc-200"
|
||
>
|
||
{expanded ? (
|
||
<ChevronUp className="size-3.5 shrink-0 text-zinc-500" />
|
||
) : (
|
||
<ChevronDown className="size-3.5 shrink-0 text-zinc-500" />
|
||
)}
|
||
<span className="truncate">{block.name || "Unnamed block"}</span>
|
||
<span className="shrink-0 font-mono text-[11px] text-zinc-500">
|
||
{block.start_time.slice(0, 5)} · {block.duration_mins}m
|
||
</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onRemove}
|
||
className="rounded p-1 text-zinc-600 hover:bg-zinc-700 hover:text-red-400"
|
||
>
|
||
<Trash2 className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
|
||
{expanded && (
|
||
<div className="space-y-3 border-t border-zinc-700 px-3 py-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Block name" error={errors[`${pfx}.name`]}>
|
||
<TextInput
|
||
value={block.name}
|
||
onChange={(v) => setField("name", v)}
|
||
placeholder="Evening Sitcoms"
|
||
error={!!errors[`${pfx}.name`]}
|
||
/>
|
||
</Field>
|
||
<Field label="Content type">
|
||
<NativeSelect
|
||
value={content.type}
|
||
onChange={(v) => setContentType(v as "algorithmic" | "manual")}
|
||
>
|
||
<option value="algorithmic">Algorithmic</option>
|
||
<option value="manual">Manual</option>
|
||
</NativeSelect>
|
||
</Field>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Start time">
|
||
<input
|
||
type="time"
|
||
value={block.start_time.slice(0, 5)}
|
||
onChange={(e) => setField("start_time", e.target.value + ":00")}
|
||
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"
|
||
/>
|
||
</Field>
|
||
<Field label="Duration (minutes)" error={errors[`${pfx}.duration_mins`]}>
|
||
<NumberInput
|
||
value={block.duration_mins}
|
||
onChange={(v) => setField("duration_mins", v === "" ? 60 : v)}
|
||
min={1}
|
||
error={!!errors[`${pfx}.duration_mins`]}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
|
||
{content.type === "algorithmic" && (
|
||
<>
|
||
<AlgorithmicFilterEditor
|
||
content={content}
|
||
pfx={pfx}
|
||
errors={errors}
|
||
setFilter={setFilter}
|
||
setStrategy={setStrategy}
|
||
/>
|
||
|
||
{content.strategy === "sequential" && (
|
||
<div className="space-y-2 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
|
||
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||
Sequential options
|
||
</p>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={block.loop_on_finish ?? true}
|
||
onChange={(e) => onChange({ ...block, loop_on_finish: e.target.checked })}
|
||
className="accent-zinc-400"
|
||
/>
|
||
<span className="text-sm text-zinc-300">Loop series</span>
|
||
<span className="text-[11px] text-zinc-600">
|
||
Restart from episode 1 after the final episode
|
||
</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={block.ignore_recycle_policy ?? false}
|
||
onChange={(e) => onChange({ ...block, ignore_recycle_policy: e.target.checked })}
|
||
className="accent-zinc-400"
|
||
/>
|
||
<span className="text-sm text-zinc-300">Independent scheduling</span>
|
||
<span className="text-[11px] text-zinc-600">
|
||
Play episodes in order even if they aired in another block today
|
||
</span>
|
||
</label>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{content.type === "manual" && (
|
||
<div className="space-y-2 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
|
||
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Item IDs</p>
|
||
<textarea
|
||
rows={3}
|
||
value={content.items.join("\n")}
|
||
onChange={(e) =>
|
||
onChange({
|
||
...block,
|
||
content: {
|
||
type: "manual",
|
||
items: e.target.value.split("\n").map((s) => s.trim()).filter(Boolean),
|
||
},
|
||
})
|
||
}
|
||
placeholder={"abc123\ndef456\nghi789"}
|
||
className="w-full resize-none rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 font-mono text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
|
||
/>
|
||
<p className="text-[11px] text-zinc-600">One Jellyfin item ID per line, played in order.</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Recycle policy editor
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function RecyclePolicyEditor({
|
||
policy,
|
||
errors,
|
||
onChange,
|
||
}: {
|
||
policy: RecyclePolicy;
|
||
errors: FieldErrors;
|
||
onChange: (policy: RecyclePolicy) => void;
|
||
}) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Cooldown (days)" hint="Don't replay within N days">
|
||
<NumberInput
|
||
value={policy.cooldown_days ?? ""}
|
||
onChange={(v) => onChange({ ...policy, cooldown_days: v === "" ? null : (v as number) })}
|
||
min={0}
|
||
placeholder="7"
|
||
/>
|
||
</Field>
|
||
<Field label="Cooldown (generations)" hint="Don't replay within N schedules">
|
||
<NumberInput
|
||
value={policy.cooldown_generations ?? ""}
|
||
onChange={(v) => onChange({ ...policy, cooldown_generations: v === "" ? null : (v as number) })}
|
||
min={0}
|
||
placeholder="3"
|
||
/>
|
||
</Field>
|
||
</div>
|
||
<Field
|
||
label="Min available ratio"
|
||
hint="0.0–1.0 · Fraction of the pool kept selectable even if cooldown is active"
|
||
error={errors["recycle_policy.min_available_ratio"]}
|
||
>
|
||
<NumberInput
|
||
value={policy.min_available_ratio}
|
||
onChange={(v) => onChange({ ...policy, min_available_ratio: v === "" ? 0.1 : (v as number) })}
|
||
min={0}
|
||
max={1}
|
||
step={0.01}
|
||
placeholder="0.1"
|
||
error={!!errors["recycle_policy.min_available_ratio"]}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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<ProgrammingBlock[]>([]);
|
||
const [recyclePolicy, setRecyclePolicy] = useState<RecyclePolicy>({
|
||
cooldown_days: null,
|
||
cooldown_generations: null,
|
||
min_available_ratio: 0.1,
|
||
});
|
||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||
|
||
useEffect(() => {
|
||
if (channel) {
|
||
setName(channel.name);
|
||
setDescription(channel.description ?? "");
|
||
setTimezone(channel.timezone);
|
||
setBlocks(channel.schedule_config.blocks);
|
||
setRecyclePolicy(channel.recycle_policy);
|
||
setSelectedBlockId(null);
|
||
setFieldErrors({});
|
||
}
|
||
}, [channel]);
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!channel) return;
|
||
|
||
const result = channelFormSchema.safeParse({
|
||
name, description, timezone, blocks, recycle_policy: recyclePolicy,
|
||
});
|
||
|
||
if (!result.success) {
|
||
setFieldErrors(extractErrors(result.error));
|
||
return;
|
||
}
|
||
|
||
setFieldErrors({});
|
||
onSubmit(channel.id, {
|
||
name,
|
||
description,
|
||
timezone,
|
||
schedule_config: { blocks },
|
||
recycle_policy: recyclePolicy,
|
||
});
|
||
};
|
||
|
||
const addBlock = (startMins = 20 * 60, durationMins = 60) => {
|
||
const block = defaultBlock(startMins, durationMins);
|
||
setBlocks((prev) => [...prev, block]);
|
||
setSelectedBlockId(block.id);
|
||
};
|
||
|
||
const updateBlock = (idx: number, block: ProgrammingBlock) =>
|
||
setBlocks((prev) => prev.map((b, i) => (i === idx ? block : b)));
|
||
|
||
const removeBlock = (idx: number) => {
|
||
setBlocks((prev) => {
|
||
const next = prev.filter((_, i) => i !== idx);
|
||
if (selectedBlockId === prev[idx].id) setSelectedBlockId(null);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||
<SheetContent
|
||
side="right"
|
||
className="flex w-full flex-col gap-0 border-zinc-800 bg-zinc-900 p-0 text-zinc-100 sm:max-w-2xl"
|
||
>
|
||
<SheetHeader className="border-b border-zinc-800 px-6 py-4">
|
||
<SheetTitle className="text-zinc-100">Edit channel</SheetTitle>
|
||
</SheetHeader>
|
||
|
||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col overflow-hidden">
|
||
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-4">
|
||
{/* Basic info */}
|
||
<section className="space-y-3">
|
||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Basic info</h3>
|
||
|
||
<Field label="Name" error={fieldErrors["name"]}>
|
||
<TextInput
|
||
required
|
||
value={name}
|
||
onChange={setName}
|
||
placeholder="90s Sitcom Network"
|
||
error={!!fieldErrors["name"]}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="Timezone" hint="IANA timezone, e.g. America/New_York" error={fieldErrors["timezone"]}>
|
||
<TextInput
|
||
required
|
||
value={timezone}
|
||
onChange={setTimezone}
|
||
placeholder="UTC"
|
||
error={!!fieldErrors["timezone"]}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="Description">
|
||
<textarea
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
rows={2}
|
||
placeholder="Nothing but classic sitcoms, all day"
|
||
className="w-full resize-none 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"
|
||
/>
|
||
</Field>
|
||
</section>
|
||
|
||
{/* Programming blocks */}
|
||
<section className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||
Programming blocks
|
||
</h3>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="xs"
|
||
onClick={() => addBlock()}
|
||
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
|
||
>
|
||
<Plus className="size-3" />
|
||
Add block
|
||
</Button>
|
||
</div>
|
||
|
||
<BlockTimeline
|
||
blocks={blocks}
|
||
selectedId={selectedBlockId}
|
||
onSelect={setSelectedBlockId}
|
||
onChange={setBlocks}
|
||
onCreateBlock={(startMins, durationMins) => addBlock(startMins, durationMins)}
|
||
/>
|
||
|
||
{blocks.length === 0 && (
|
||
<p className="rounded-md border border-dashed border-zinc-700 px-4 py-6 text-center text-xs text-zinc-600">
|
||
No blocks yet. Drag on the timeline or click Add block.
|
||
</p>
|
||
)}
|
||
|
||
<div className="space-y-2">
|
||
{blocks.map((block, idx) => (
|
||
<BlockEditor
|
||
key={block.id}
|
||
block={block}
|
||
index={idx}
|
||
isSelected={block.id === selectedBlockId}
|
||
color={BLOCK_COLORS[idx % BLOCK_COLORS.length]}
|
||
errors={fieldErrors}
|
||
onChange={(b) => updateBlock(idx, b)}
|
||
onRemove={() => removeBlock(idx)}
|
||
onSelect={() => setSelectedBlockId(block.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
{/* Recycle policy */}
|
||
<section className="space-y-3">
|
||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Recycle policy</h3>
|
||
<RecyclePolicyEditor
|
||
policy={recyclePolicy}
|
||
errors={fieldErrors}
|
||
onChange={setRecyclePolicy}
|
||
/>
|
||
</section>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="flex items-center justify-between border-t border-zinc-800 px-6 py-4">
|
||
{(error || Object.keys(fieldErrors).length > 0) && (
|
||
<p className="text-xs text-red-400">
|
||
{error ?? "Please fix the errors above"}
|
||
</p>
|
||
)}
|
||
<div className="ml-auto flex gap-2">
|
||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||
Cancel
|
||
</Button>
|
||
<Button type="submit" disabled={isPending}>
|
||
{isPending ? "Saving…" : "Save changes"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|