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.
This commit is contained in:
2026-03-11 21:14:42 +01:00
parent b813594059
commit 477de2c49d
8 changed files with 781 additions and 179 deletions

View File

@@ -1,14 +1,12 @@
"use client";
import { useState, useEffect } from "react";
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 { 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,
@@ -20,21 +18,90 @@ import type {
} from "@/lib/types";
// ---------------------------------------------------------------------------
// Sub-components (all dumb, no hooks)
// Zod schemas
// ---------------------------------------------------------------------------
interface FieldProps {
label: string;
hint?: string;
children: React.ReactNode;
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;
}
function Field({ label, hint, children }: FieldProps) {
// ---------------------------------------------------------------------------
// 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}
{hint && <p className="text-[11px] text-zinc-600">{hint}</p>}
{error ? (
<p className="text-[11px] text-red-400">{error}</p>
) : hint ? (
<p className="text-[11px] text-zinc-600">{hint}</p>
) : null}
</div>
);
}
@@ -44,11 +111,13 @@ function TextInput({
onChange,
placeholder,
required,
error,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
required?: boolean;
error?: boolean;
}) {
return (
<input
@@ -56,7 +125,7 @@ function TextInput({
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
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"}`}
/>
);
}
@@ -68,6 +137,7 @@ function NumberInput({
max,
step,
placeholder,
error,
}: {
value: number | "";
onChange: (v: number | "") => void;
@@ -75,6 +145,7 @@ function NumberInput({
max?: number;
step?: number | "any";
placeholder?: string;
error?: boolean;
}) {
return (
<input
@@ -84,10 +155,8 @@ function NumberInput({
step={step}
value={value}
placeholder={placeholder}
onChange={(e) =>
onChange(e.target.value === "" ? "" : Number(e.target.value))
}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
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"}`}
/>
);
}
@@ -113,7 +182,7 @@ function NativeSelect({
}
// ---------------------------------------------------------------------------
// Block editor
// Defaults
// ---------------------------------------------------------------------------
function defaultFilter(): MediaFilter {
@@ -128,53 +197,62 @@ function defaultFilter(): MediaFilter {
};
}
function defaultBlock(): ProgrammingBlock {
function defaultBlock(startMins = 20 * 60, durationMins = 60): ProgrammingBlock {
return {
id: crypto.randomUUID(),
name: "",
start_time: "20:00:00",
duration_mins: 60,
content: {
type: "algorithmic",
filter: defaultFilter(),
strategy: "random",
},
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, onChange, onRemove }: BlockEditorProps) {
const [expanded, setExpanded] = useState(true);
function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemove, onSelect }: BlockEditorProps) {
const [expanded, setExpanded] = useState(isSelected);
const elRef = useRef<HTMLDivElement>(null);
const setField = <K extends keyof ProgrammingBlock>(
key: K,
value: ProgrammingBlock[K],
) => onChange({ ...block, [key]: value });
// 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") => {
if (type === "algorithmic") {
onChange({
...block,
content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" },
});
} else {
onChange({ ...block, content: { type: "manual", items: [] } });
}
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 } },
});
onChange({ ...block, content: { ...content, filter: { ...content.filter, ...patch } } });
};
const setStrategy = (strategy: FillStrategy) => {
@@ -183,12 +261,15 @@ function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) {
};
return (
<div className="rounded-lg border border-zinc-700 bg-zinc-800/50">
{/* Block header */}
<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)}
onClick={() => { setExpanded((v) => !v); onSelect(); }}
className="flex flex-1 items-center gap-2 text-left text-sm font-medium text-zinc-200"
>
{expanded ? (
@@ -213,11 +294,12 @@ function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) {
{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">
<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">
@@ -240,30 +322,25 @@ function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) {
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)">
<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>
<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),
})
}
onChange={(v) => setFilter({ content_type: v === "" ? null : (v as ContentType) })}
>
<option value="">Any</option>
<option value="movie">Movie</option>
@@ -271,7 +348,6 @@ function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) {
<option value="short">Short</option>
</NativeSelect>
</Field>
<Field label="Strategy">
<NativeSelect
value={content.strategy}
@@ -284,73 +360,54 @@ function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) {
</Field>
</div>
<Field
label="Genres"
hint="Comma-separated, e.g. Comedy, Action"
>
<TextInput
value={content.filter.genres.join(", ")}
onChange={(v) =>
setFilter({
genres: v
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
placeholder="Comedy, Sci-Fi"
<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">
<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) })
}
onChange={(v) => setFilter({ decade: v === "" ? null : (v as number) })}
placeholder="1990"
error={!!errors[`${pfx}.content.filter.decade`]}
/>
</Field>
<Field label="Min duration (s)">
<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),
})
}
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)">
<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),
})
}
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, comma-separated"
>
<TextInput
value={content.filter.collections.join(", ")}
onChange={(v) =>
setFilter({
collections: v
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
placeholder="abc123"
<Field label="Collections" hint="Jellyfin library IDs">
<TagInput
values={content.filter.collections}
onChange={(v) => setFilter({ collections: v })}
placeholder="abc123…"
/>
</Field>
</div>
@@ -358,9 +415,7 @@ function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) {
{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>
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Item IDs</p>
<textarea
rows={3}
value={content.items.join("\n")}
@@ -369,19 +424,14 @@ function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) {
...block,
content: {
type: "manual",
items: e.target.value
.split("\n")
.map((s) => s.trim())
.filter(Boolean),
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>
<p className="text-[11px] text-zinc-600">One Jellyfin item ID per line, played in order.</p>
</div>
)}
</div>
@@ -394,40 +444,30 @@ function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) {
// Recycle policy editor
// ---------------------------------------------------------------------------
interface RecyclePolicyEditorProps {
function RecyclePolicyEditor({
policy,
errors,
onChange,
}: {
policy: RecyclePolicy;
errors: FieldErrors;
onChange: (policy: RecyclePolicy) => void;
}
function RecyclePolicyEditor({ policy, onChange }: RecyclePolicyEditorProps) {
}) {
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),
})
}
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"
>
<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),
})
}
onChange={(v) => onChange({ ...policy, cooldown_generations: v === "" ? null : (v as number) })}
min={0}
placeholder="3"
/>
@@ -435,20 +475,17 @@ function RecyclePolicyEditor({ policy, onChange }: RecyclePolicyEditorProps) {
</div>
<Field
label="Min available ratio"
hint="0.01.0. Keep at least this fraction of the pool selectable even if cooldown hasn't expired"
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),
})
}
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>
@@ -494,8 +531,9 @@ export function EditChannelSheet({
cooldown_generations: null,
min_available_ratio: 0.1,
});
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
// Sync from channel whenever it changes or sheet opens
useEffect(() => {
if (channel) {
setName(channel.name);
@@ -503,12 +541,25 @@ export function EditChannelSheet({
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,
@@ -518,50 +569,56 @@ export function EditChannelSheet({
});
};
const addBlock = () => setBlocks((prev) => [...prev, defaultBlock()]);
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) => prev.filter((_, i) => i !== idx));
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-xl"
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"
>
<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>
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Basic info</h3>
<Field label="Name">
<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">
<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>
@@ -586,7 +643,7 @@ export function EditChannelSheet({
type="button"
variant="outline"
size="xs"
onClick={addBlock}
onClick={() => addBlock()}
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
>
<Plus className="size-3" />
@@ -594,9 +651,17 @@ export function EditChannelSheet({
</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. Gaps between blocks show no-signal.
No blocks yet. Drag on the timeline or click Add block.
</p>
)}
@@ -605,8 +670,13 @@ export function EditChannelSheet({
<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>
@@ -614,11 +684,10 @@ export function EditChannelSheet({
{/* Recycle policy */}
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
Recycle policy
</h3>
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Recycle policy</h3>
<RecyclePolicyEditor
policy={recyclePolicy}
errors={fieldErrors}
onChange={setRecyclePolicy}
/>
</section>
@@ -626,14 +695,13 @@ export function EditChannelSheet({
{/* Footer */}
<div className="flex items-center justify-between border-t border-zinc-800 px-6 py-4">
{error && <p className="text-xs text-red-400">{error}</p>}
{(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}
>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={isPending}>
Cancel
</Button>
<Button type="submit" disabled={isPending}>