Files
k-tv/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx
Gabriel Kaszewski 477de2c49d feat: add schedule sheet and tag input components
- Implemented ScheduleSheet component to display channel schedules with a timeline view.
- Added DayRow subcomponent for rendering daily schedule slots with color coding.
- Integrated ScheduleSheet into the DashboardPage for viewing schedules of selected channels.
- Created TagInput component for managing tags with add and remove functionality.
- Updated package dependencies to include zod version 4.3.6.
2026-03-11 21:14:42 +01:00

717 lines
24 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 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()),
});
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()),
}),
]),
});
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: [],
};
}
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" },
};
}
// ---------------------------------------------------------------------------
// 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" && (
<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) })}
>
<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>
<Field label="Genres" hint="Press Enter or comma to add">
<TagInput
values={content.filter.genres}
onChange={(v) => setFilter({ genres: v })}
placeholder="Comedy, Sci-Fi…"
/>
</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>
<Field label="Collections" hint="Jellyfin library IDs">
<TagInput
values={content.filter.collections}
onChange={(v) => setFilter({ collections: v })}
placeholder="abc123…"
/>
</Field>
</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.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;
},
) => 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>
);
}