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

@@ -0,0 +1,232 @@
"use client";
import { useRef, useState, useEffect } from "react";
import type { ProgrammingBlock } from "@/lib/types";
const SNAP_MINS = 15;
export const BLOCK_COLORS = [
"#3b82f6",
"#8b5cf6",
"#10b981",
"#f59e0b",
"#ef4444",
"#06b6d4",
"#6366f1",
"#ec4899",
];
export function timeToMins(time: string): number {
const [h, m] = time.split(":").map(Number);
return h * 60 + m;
}
export function minsToTime(mins: number): string {
const clamped = ((Math.round(mins / SNAP_MINS) * SNAP_MINS) % 1440 + 1440) % 1440;
return `${Math.floor(clamped / 60).toString().padStart(2, "0")}:${(clamped % 60).toString().padStart(2, "0")}:00`;
}
function snap(mins: number): number {
return Math.round(mins / SNAP_MINS) * SNAP_MINS;
}
type DragKind = "move" | "resize" | "create";
interface DragState {
kind: DragKind;
blockId?: string;
startMins: number;
offsetMins: number;
}
interface Draft {
id: string;
startMins: number;
durationMins: number;
}
interface BlockTimelineProps {
blocks: ProgrammingBlock[];
selectedId: string | null;
onSelect: (id: string | null) => void;
onChange: (blocks: ProgrammingBlock[]) => void;
onCreateBlock: (startMins: number, durationMins: number) => void;
}
export function BlockTimeline({
blocks,
selectedId,
onSelect,
onChange,
onCreateBlock,
}: BlockTimelineProps) {
const containerRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const blocksRef = useRef(blocks);
blocksRef.current = blocks;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onCreateRef = useRef(onCreateBlock);
onCreateRef.current = onCreateBlock;
const [draft, setDraft] = useState<Draft | null>(null);
const clientXToMins = (clientX: number): number => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return 0;
return Math.max(0, Math.min(1440, snap((clientX - rect.left) / rect.width * 1440)));
};
useEffect(() => {
const handleMove = (e: MouseEvent) => {
const drag = dragRef.current;
if (!drag) return;
const cur = clientXToMins(e.clientX);
const blks = blocksRef.current;
if (drag.kind === "move" && drag.blockId) {
const block = blks.find((b) => b.id === drag.blockId);
if (!block) return;
const newStart = snap(Math.max(0, Math.min(1440 - block.duration_mins, cur - drag.offsetMins)));
setDraft({ id: drag.blockId, startMins: newStart, durationMins: block.duration_mins });
} else if (drag.kind === "resize" && drag.blockId) {
const block = blks.find((b) => b.id === drag.blockId);
if (!block) return;
const newDuration = Math.max(SNAP_MINS, snap(cur - drag.startMins));
setDraft({ id: drag.blockId, startMins: drag.startMins, durationMins: newDuration });
} else if (drag.kind === "create") {
const start = Math.min(drag.startMins, cur);
const end = Math.max(drag.startMins, cur);
setDraft({ id: "__new__", startMins: start, durationMins: Math.max(SNAP_MINS, end - start) });
}
};
const handleUp = () => {
const drag = dragRef.current;
if (!drag) return;
dragRef.current = null;
setDraft((prev) => {
if (!prev) return null;
const blks = blocksRef.current;
if (drag.kind === "move" && drag.blockId) {
onChangeRef.current(blks.map((b) => b.id === drag.blockId ? { ...b, start_time: minsToTime(prev.startMins) } : b));
} else if (drag.kind === "resize" && drag.blockId) {
onChangeRef.current(blks.map((b) => b.id === drag.blockId ? { ...b, duration_mins: prev.durationMins } : b));
} else if (drag.kind === "create" && prev.durationMins >= SNAP_MINS) {
onCreateRef.current(prev.startMins, prev.durationMins);
}
return null;
});
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
return () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
};
}, []);
return (
<div className="select-none space-y-1">
{/* Hour labels */}
<div className="relative h-4">
{[0, 3, 6, 9, 12, 15, 18, 21, 24].map((h) => (
<span
key={h}
className="absolute -translate-x-1/2 text-[10px] text-zinc-600"
style={{ left: `${(h / 24) * 100}%` }}
>
{h.toString().padStart(2, "0")}
</span>
))}
</div>
{/* Timeline strip */}
<div
ref={containerRef}
className="relative h-16 cursor-crosshair rounded-md border border-zinc-700 bg-zinc-900 overflow-hidden"
onMouseDown={(e) => {
if (e.button !== 0) return;
const startMins = clientXToMins(e.clientX);
dragRef.current = { kind: "create", startMins, offsetMins: 0 };
setDraft({ id: "__new__", startMins, durationMins: SNAP_MINS });
}}
>
{/* Hour grid */}
{Array.from({ length: 25 }, (_, i) => (
<div
key={i}
className={`absolute inset-y-0 w-px ${i % 6 === 0 ? "bg-zinc-700" : "bg-zinc-800"}`}
style={{ left: `${(i / 24) * 100}%` }}
/>
))}
{/* Blocks */}
{blocks.map((block, idx) => {
const startMins = draft?.id === block.id ? draft.startMins : timeToMins(block.start_time);
const durationMins = draft?.id === block.id ? draft.durationMins : block.duration_mins;
const color = BLOCK_COLORS[idx % BLOCK_COLORS.length];
const isSelected = block.id === selectedId;
return (
<div
key={block.id}
title={`${block.name || "Untitled"} · ${minsToTime(startMins).slice(0, 5)} · ${durationMins}m`}
className={`absolute top-1.5 bottom-1.5 rounded flex items-center overflow-hidden ${isSelected ? "ring-2 ring-white ring-offset-1 ring-offset-zinc-900" : "opacity-75 hover:opacity-100"}`}
style={{
left: `${(startMins / 1440) * 100}%`,
width: `max(${(durationMins / 1440) * 100}%, 6px)`,
backgroundColor: color,
}}
onMouseDown={(e) => {
e.stopPropagation();
onSelect(block.id);
dragRef.current = {
kind: "move",
blockId: block.id,
startMins: timeToMins(block.start_time),
offsetMins: clientXToMins(e.clientX) - timeToMins(block.start_time),
};
}}
>
<span className="px-1.5 text-[10px] font-medium text-white truncate pointer-events-none flex-1">
{block.name || "Untitled"}
</span>
{/* Resize handle */}
<div
className="absolute right-0 inset-y-0 w-2 cursor-ew-resize"
onMouseDown={(e) => {
e.stopPropagation();
dragRef.current = {
kind: "resize",
blockId: block.id,
startMins: timeToMins(block.start_time),
offsetMins: 0,
};
}}
/>
</div>
);
})}
{/* Create preview */}
{draft?.id === "__new__" && (
<div
className="absolute top-1.5 bottom-1.5 rounded bg-zinc-400/40 border border-dashed border-zinc-400/60 pointer-events-none"
style={{
left: `${(draft.startMins / 1440) * 100}%`,
width: `max(${(draft.durationMins / 1440) * 100}%, 6px)`,
}}
/>
)}
</div>
<p className="text-[10px] text-zinc-600">
Drag to move · Drag right edge to resize · Click and drag empty space to create
</p>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import Link from "next/link";
import { Pencil, Trash2, RefreshCw, Tv2 } from "lucide-react";
import { Pencil, Trash2, RefreshCw, Tv2, CalendarDays } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ChannelResponse } from "@/lib/types";
@@ -9,6 +9,7 @@ interface ChannelCardProps {
onEdit: () => void;
onDelete: () => void;
onGenerateSchedule: () => void;
onViewSchedule: () => void;
}
export function ChannelCard({
@@ -17,6 +18,7 @@ export function ChannelCard({
onEdit,
onDelete,
onGenerateSchedule,
onViewSchedule,
}: ChannelCardProps) {
const blockCount = channel.schedule_config.blocks.length;
@@ -74,11 +76,18 @@ export function ChannelCard({
disabled={isGenerating}
className="flex-1"
>
<RefreshCw
className={`size-3.5 ${isGenerating ? "animate-spin" : ""}`}
/>
<RefreshCw className={`size-3.5 ${isGenerating ? "animate-spin" : ""}`} />
{isGenerating ? "Generating…" : "Generate schedule"}
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={onViewSchedule}
title="View schedule"
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
>
<CalendarDays className="size-3.5" />
</Button>
<Button
variant="outline"
size="icon-sm"

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}>

View File

@@ -0,0 +1,211 @@
"use client";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useActiveSchedule } from "@/hooks/use-channels";
import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types";
import { BLOCK_COLORS } from "./block-timeline";
// Stable color per block_id within a schedule
function makeColorMap(slots: ScheduledSlotResponse[]): Map<string, string> {
const seen = new Map<string, string>();
slots.forEach((slot) => {
if (!seen.has(slot.block_id)) {
seen.set(slot.block_id, BLOCK_COLORS[seen.size % BLOCK_COLORS.length]);
}
});
return seen;
}
interface DayRowProps {
label: string;
dayStart: Date;
slots: ScheduledSlotResponse[];
colorMap: Map<string, string>;
}
function DayRow({ label, dayStart, slots, colorMap }: DayRowProps) {
const DAY_MS = 24 * 60 * 60 * 1000;
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
// Only include slots that overlap this day
const daySlots = slots.filter((s) => {
const start = new Date(s.start_at);
const end = new Date(s.end_at);
return start < dayEnd && end > dayStart;
});
return (
<div className="space-y-1">
<p className="text-xs font-medium text-zinc-500">{label}</p>
<div className="relative h-12 rounded-md border border-zinc-700 bg-zinc-900 overflow-hidden">
{/* Hour grid */}
{Array.from({ length: 25 }, (_, i) => (
<div
key={i}
className={`absolute inset-y-0 w-px ${i % 6 === 0 ? "bg-zinc-700" : "bg-zinc-800"}`}
style={{ left: `${(i / 24) * 100}%` }}
/>
))}
{daySlots.map((slot) => {
const slotStart = new Date(slot.start_at);
const slotEnd = new Date(slot.end_at);
// Clamp to this day
const clampedStart = Math.max(slotStart.getTime(), dayStart.getTime());
const clampedEnd = Math.min(slotEnd.getTime(), dayEnd.getTime());
const leftPct = ((clampedStart - dayStart.getTime()) / DAY_MS) * 100;
const widthPct = ((clampedEnd - clampedStart) / DAY_MS) * 100;
const color = colorMap.get(slot.block_id) ?? "#6b7280";
const startTime = slotStart.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
const endTime = slotEnd.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
return (
<div
key={slot.id}
title={`${slot.item.title}\n${startTime} ${endTime}`}
className="absolute top-1 bottom-1 rounded flex items-center overflow-hidden opacity-80 hover:opacity-100 transition-opacity cursor-default"
style={{
left: `max(${leftPct}%, 0px)`,
width: `max(${widthPct}%, 3px)`,
backgroundColor: color,
}}
>
<span className="px-1.5 text-[10px] text-white truncate pointer-events-none">
{slot.item.title}
</span>
</div>
);
})}
</div>
{/* Hour labels */}
<div className="relative h-3">
{[0, 6, 12, 18, 24].map((h) => (
<span
key={h}
className="absolute -translate-x-1/2 text-[9px] text-zinc-600"
style={{ left: `${(h / 24) * 100}%` }}
>
{h.toString().padStart(2, "0")}:00
</span>
))}
</div>
</div>
);
}
interface ScheduleSheetProps {
channel: ChannelResponse | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProps) {
const { data: schedule, isLoading, error } = useActiveSchedule(channel?.id ?? "");
const colorMap = schedule ? makeColorMap(schedule.slots) : new Map();
// Build day rows from valid_from to valid_until
const days: { label: string; dayStart: Date }[] = [];
if (schedule) {
const start = new Date(schedule.valid_from);
// Start at midnight of the first day
const dayStart = new Date(start);
dayStart.setHours(0, 0, 0, 0);
const end = new Date(schedule.valid_until);
const cursor = new Date(dayStart);
while (cursor < end) {
days.push({
label: cursor.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }),
dayStart: new Date(cursor),
});
cursor.setDate(cursor.getDate() + 1);
}
}
const fmt = (iso: string) =>
new Date(iso).toLocaleString(undefined, {
weekday: "short", month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit", hour12: false,
});
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">
Schedule {channel?.name}
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{isLoading && (
<div className="flex items-center justify-center py-16">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-700 border-t-zinc-300" />
</div>
)}
{error && (
<div className="rounded-lg border border-red-900/50 bg-red-950/20 px-4 py-3 text-sm text-red-400">
No active schedule. Generate one from the dashboard.
</div>
)}
{schedule && (
<>
{/* Meta */}
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-zinc-500">
<span>Generation <span className="text-zinc-300">#{schedule.generation}</span></span>
<span>From <span className="text-zinc-300">{fmt(schedule.valid_from)}</span></span>
<span>Until <span className="text-zinc-300">{fmt(schedule.valid_until)}</span></span>
<span><span className="text-zinc-300">{schedule.slots.length}</span> slots</span>
</div>
{/* Timeline per day */}
<div className="space-y-5">
{days.map(({ label, dayStart }) => (
<DayRow
key={dayStart.toISOString()}
label={label}
dayStart={dayStart}
slots={schedule.slots}
colorMap={colorMap}
/>
))}
</div>
{/* Slot list */}
<div className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Slots</h3>
<div className="rounded-md border border-zinc-800 divide-y divide-zinc-800">
{schedule.slots.map((slot) => {
const color = colorMap.get(slot.block_id) ?? "#6b7280";
const start = new Date(slot.start_at).toLocaleString(undefined, {
weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false,
});
const durationMins = Math.round(
(new Date(slot.end_at).getTime() - new Date(slot.start_at).getTime()) / 60000
);
return (
<div key={slot.id} className="flex items-center gap-3 px-3 py-2.5">
<div className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-zinc-200">{slot.item.title}</p>
<p className="text-xs text-zinc-500">
{start} · {durationMins}m
</p>
</div>
</div>
);
})}
</div>
</div>
</>
)}
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { useState, useRef } from "react";
import { X } from "lucide-react";
interface TagInputProps {
values: string[];
onChange: (values: string[]) => void;
placeholder?: string;
}
export function TagInput({ values, onChange, placeholder }: TagInputProps) {
const [input, setInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const add = (raw: string) => {
const trimmed = raw.trim();
if (!trimmed || values.includes(trimmed)) return;
onChange([...values, trimmed]);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
add(input);
setInput("");
} else if (e.key === "Backspace" && input === "" && values.length > 0) {
onChange(values.slice(0, -1));
}
};
const handleBlur = () => {
if (input.trim()) {
add(input);
setInput("");
}
};
return (
<div
className="flex min-h-[38px] flex-wrap gap-1.5 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1.5 cursor-text focus-within:border-zinc-500"
onClick={() => inputRef.current?.focus()}
>
{values.map((tag) => (
<span
key={tag}
className="flex items-center gap-1 rounded bg-zinc-700 px-2 py-0.5 text-xs text-zinc-200"
>
{tag}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onChange(values.filter((v) => v !== tag));
}}
className="text-zinc-400 hover:text-zinc-100"
>
<X className="size-2.5" />
</button>
</span>
))}
<input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
placeholder={values.length === 0 ? placeholder : ""}
className="min-w-[80px] flex-1 bg-transparent text-sm text-zinc-100 placeholder:text-zinc-600 outline-none"
/>
</div>
);
}

View File

@@ -14,6 +14,7 @@ import { ChannelCard } from "./components/channel-card";
import { CreateChannelDialog } from "./components/create-channel-dialog";
import { DeleteChannelDialog } from "./components/delete-channel-dialog";
import { EditChannelSheet } from "./components/edit-channel-sheet";
import { ScheduleSheet } from "./components/schedule-sheet";
import type { ChannelResponse, ProgrammingBlock, RecyclePolicy } from "@/lib/types";
export default function DashboardPage() {
@@ -27,6 +28,7 @@ export default function DashboardPage() {
const [createOpen, setCreateOpen] = useState(false);
const [editChannel, setEditChannel] = useState<ChannelResponse | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ChannelResponse | null>(null);
const [scheduleChannel, setScheduleChannel] = useState<ChannelResponse | null>(null);
const handleCreate = (data: {
name: string;
@@ -114,6 +116,7 @@ export default function DashboardPage() {
onEdit={() => setEditChannel(channel)}
onDelete={() => setDeleteTarget(channel)}
onGenerateSchedule={() => generateSchedule.mutate(channel.id)}
onViewSchedule={() => setScheduleChannel(channel)}
/>
))}
</div>
@@ -137,6 +140,12 @@ export default function DashboardPage() {
error={updateChannel.error?.message}
/>
<ScheduleSheet
channel={scheduleChannel}
open={!!scheduleChannel}
onOpenChange={(open) => { if (!open) setScheduleChannel(null); }}
/>
{deleteTarget && (
<DeleteChannelDialog
channelName={deleteTarget.name}