Files
k-tv/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx
Gabriel Kaszewski 8f42164bce feat: add local files provider with indexing and rescan functionality
- Implemented LocalFilesProvider to manage local video files.
- Added LocalIndex for in-memory and SQLite-backed indexing of video files.
- Introduced scanning functionality to detect video files and extract metadata.
- Added API endpoints for listing collections, genres, and series based on provider capabilities.
- Enhanced existing routes to check for provider capabilities before processing requests.
- Updated frontend to utilize provider capabilities for conditional rendering of UI elements.
- Implemented rescan functionality to refresh the local files index.
- Added database migration for local files index schema.
2026-03-14 03:44:32 +01:00

1085 lines
39 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,
LogoPosition,
ProgrammingBlock,
BlockContent,
FillStrategy,
ContentType,
MediaFilter,
ProviderCapabilities,
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;
capabilities?: ProviderCapabilities;
}
function AlgorithmicFilterEditor({
content,
pfx,
errors,
setFilter,
setStrategy,
capabilities,
}: AlgorithmicFilterEditorProps) {
const [showGenres, setShowGenres] = useState(false);
const { data: collections, isLoading: loadingCollections } = useCollections();
const { data: series, isLoading: loadingSeries } = useSeries(undefined, {
enabled: capabilities?.series !== false,
});
const { data: genreOptions } = useGenres(content.filter.content_type ?? undefined, {
enabled: capabilities?.genres !== false,
});
const isEpisode = content.filter.content_type === "episode";
const collectionLabel =
capabilities?.collections && !capabilities?.series && !capabilities?.genres
? "Directory"
: "Library";
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 when provider supports it */}
{isEpisode && capabilities?.series !== false && (
<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/Directory — real collection names when the provider supports it */}
<Field
label={collectionLabel}
hint={
loadingCollections
? `Loading ${collectionLabel.toLowerCase()}s…`
: collections
? `Scope this block to one ${collectionLabel.toLowerCase()}`
: `Enter a provider ${collectionLabel.toLowerCase()} 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 — only shown when provider supports it */}
{capabilities?.genres !== false && (
<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;
capabilities?: ProviderCapabilities;
}
function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemove, onSelect, capabilities }: 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}
capabilities={capabilities}
/>
{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;
logo?: string | null;
logo_position?: LogoPosition;
logo_opacity?: number;
},
) => void;
isPending: boolean;
error?: string | null;
capabilities?: ProviderCapabilities;
}
export function EditChannelSheet({
channel,
open,
onOpenChange,
onSubmit,
isPending,
error,
capabilities,
}: 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 [logo, setLogo] = useState<string | null>(null);
const [logoPosition, setLogoPosition] = useState<LogoPosition>("top_right");
const [logoOpacity, setLogoOpacity] = useState(100);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const fileInputRef = useRef<HTMLInputElement>(null);
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("");
setLogo(channel.logo ?? null);
setLogoPosition(channel.logo_position ?? "top_right");
setLogoOpacity(Math.round((channel.logo_opacity ?? 1) * 100));
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 || "",
logo: logo,
logo_position: logoPosition,
logo_opacity: logoOpacity / 100,
});
};
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>
{/* Logo */}
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Logo</h3>
<div className="space-y-3 rounded-md border border-zinc-700/50 bg-zinc-800/50 p-3">
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="xs"
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
onClick={() => fileInputRef.current?.click()}
>
Upload image
</Button>
{logo && (
<Button
type="button"
variant="ghost"
size="xs"
className="text-zinc-500 hover:text-red-400"
onClick={() => setLogo(null)}
>
Clear
</Button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*,.svg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setLogo(ev.target?.result as string ?? null);
reader.readAsDataURL(file);
e.target.value = "";
}}
/>
</div>
<Field label="URL or SVG markup" hint="Paste a remote URL, data URI, or inline SVG">
<textarea
rows={3}
value={logo ?? ""}
onChange={(e) => setLogo(e.target.value || null)}
placeholder="https://… or <svg>…</svg>"
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"
/>
</Field>
{logo && (
<div className="flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900 p-3">
{logo.trimStart().startsWith("<") ? (
<div dangerouslySetInnerHTML={{ __html: logo }} className="h-12 w-auto" />
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={logo} alt="" className="h-12 w-auto object-contain" />
)}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Position">
<NativeSelect value={logoPosition} onChange={(v) => setLogoPosition(v as LogoPosition)}>
<option value="top_left">Top left</option>
<option value="top_right">Top right</option>
<option value="bottom_left">Bottom left</option>
<option value="bottom_right">Bottom right</option>
</NativeSelect>
</Field>
<Field label={`Opacity (${logoOpacity}%)`}>
<input
type="range"
min={0}
max={100}
value={logoOpacity}
onChange={(e) => setLogoOpacity(Number(e.target.value))}
className="w-full accent-zinc-400 mt-2"
/>
</Field>
</div>
</div>
</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)}
capabilities={capabilities}
/>
))}
</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>
);
}