Files
k-tv/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx
Gabriel Kaszewski 81df6eb8ff feat: add access control to channels with various modes
- Introduced AccessMode enum to define channel access levels: Public, PasswordProtected, AccountRequired, and OwnerOnly.
- Updated Channel and ProgrammingBlock entities to include access_mode and access_password_hash fields.
- Enhanced create and update channel functionality to handle access mode and password.
- Implemented access checks in channel routes based on the defined access modes.
- Modified frontend components to support channel creation and editing with access control options.
- Added ChannelPasswordModal for handling password input when accessing restricted channels.
- Updated API calls to include channel and block passwords as needed.
- Created database migrations to add access_mode and access_password_hash columns to channels table.
2026-03-14 01:45:10 +01:00

967 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 {
AccessMode,
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 accessModeSchema = z.enum(["public", "password_protected", "account_required", "owner_only"]);
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(),
access_mode: accessModeSchema.optional(),
access_password: z.string().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"),
}),
auto_schedule: z.boolean(),
access_mode: accessModeSchema.optional(),
access_password: z.string().optional(),
});
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,
access_mode: "public",
};
}
// ---------------------------------------------------------------------------
// 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>
)}
{/* Block-level access control */}
<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">Block access</p>
<NativeSelect
value={block.access_mode ?? "public"}
onChange={(v) => onChange({ ...block, access_mode: v as AccessMode })}
>
<option value="public">Public</option>
<option value="password_protected">Password protected</option>
<option value="account_required">Account required</option>
<option value="owner_only">Owner only</option>
</NativeSelect>
{(block.access_mode === "password_protected") && (
<input
type="password"
placeholder="Block password (leave blank to keep existing)"
value={block.access_password ?? ""}
onChange={(e) => onChange({ ...block, access_password: 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"
/>
)}
</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.01.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;
auto_schedule: boolean;
access_mode?: AccessMode;
access_password?: string;
},
) => 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 [autoSchedule, setAutoSchedule] = useState(false);
const [accessMode, setAccessMode] = useState<AccessMode>("public");
const [accessPassword, setAccessPassword] = useState("");
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);
setAutoSchedule(channel.auto_schedule);
setAccessMode(channel.access_mode ?? "public");
setAccessPassword("");
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,
auto_schedule: autoSchedule, access_mode: accessMode, access_password: accessPassword,
});
if (!result.success) {
setFieldErrors(extractErrors(result.error));
return;
}
setFieldErrors({});
onSubmit(channel.id, {
name,
description,
timezone,
schedule_config: { blocks },
recycle_policy: recyclePolicy,
auto_schedule: autoSchedule,
access_mode: accessMode !== "public" ? accessMode : "public",
access_password: accessPassword || "",
});
};
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>
<label className="flex items-center justify-between gap-3 cursor-pointer rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2.5">
<div>
<p className="text-sm text-zinc-200">Auto-generate schedule</p>
<p className="text-[11px] text-zinc-600">Automatically regenerate when the schedule is about to expire</p>
</div>
<button
type="button"
role="switch"
aria-checked={autoSchedule}
onClick={() => setAutoSchedule((v) => !v)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none ${autoSchedule ? "bg-zinc-300" : "bg-zinc-700"}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${autoSchedule ? "translate-x-4" : "translate-x-0"}`}
/>
</button>
</label>
<Field label="Channel access">
<NativeSelect value={accessMode} onChange={(v) => { setAccessMode(v as AccessMode); setAccessPassword(""); }}>
<option value="public">Public</option>
<option value="password_protected">Password protected</option>
<option value="account_required">Account required</option>
<option value="owner_only">Owner only</option>
</NativeSelect>
</Field>
{accessMode === "password_protected" && (
<Field label="Channel password" hint="Leave blank to keep existing password">
<TextInput
value={accessPassword}
onChange={setAccessPassword}
placeholder="New password…"
/>
</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>
);
}