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 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 { Button } from "@/components/ui/button";
import type { ChannelResponse } from "@/lib/types"; import type { ChannelResponse } from "@/lib/types";
@@ -9,6 +9,7 @@ interface ChannelCardProps {
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onGenerateSchedule: () => void; onGenerateSchedule: () => void;
onViewSchedule: () => void;
} }
export function ChannelCard({ export function ChannelCard({
@@ -17,6 +18,7 @@ export function ChannelCard({
onEdit, onEdit,
onDelete, onDelete,
onGenerateSchedule, onGenerateSchedule,
onViewSchedule,
}: ChannelCardProps) { }: ChannelCardProps) {
const blockCount = channel.schedule_config.blocks.length; const blockCount = channel.schedule_config.blocks.length;
@@ -74,11 +76,18 @@ export function ChannelCard({
disabled={isGenerating} disabled={isGenerating}
className="flex-1" className="flex-1"
> >
<RefreshCw <RefreshCw className={`size-3.5 ${isGenerating ? "animate-spin" : ""}`} />
className={`size-3.5 ${isGenerating ? "animate-spin" : ""}`}
/>
{isGenerating ? "Generating…" : "Generate schedule"} {isGenerating ? "Generating…" : "Generate schedule"}
</Button> </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 <Button
variant="outline" variant="outline"
size="icon-sm" size="icon-sm"

View File

@@ -1,14 +1,12 @@
"use client"; "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 { Trash2, Plus, ChevronDown, ChevronUp } from "lucide-react";
import { import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TagInput } from "./tag-input";
import { BlockTimeline, BLOCK_COLORS, timeToMins, minsToTime } from "./block-timeline";
import type { import type {
ChannelResponse, ChannelResponse,
ProgrammingBlock, ProgrammingBlock,
@@ -20,21 +18,90 @@ import type {
} from "@/lib/types"; } from "@/lib/types";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sub-components (all dumb, no hooks) // Zod schemas
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface FieldProps { const mediaFilterSchema = z.object({
label: string; content_type: z.enum(["movie", "episode", "short"]).nullable().optional(),
hint?: string; genres: z.array(z.string()),
children: React.ReactNode; 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 ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-xs font-medium text-zinc-400">{label}</label> <label className="block text-xs font-medium text-zinc-400">{label}</label>
{children} {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> </div>
); );
} }
@@ -44,11 +111,13 @@ function TextInput({
onChange, onChange,
placeholder, placeholder,
required, required,
error,
}: { }: {
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
placeholder?: string; placeholder?: string;
required?: boolean; required?: boolean;
error?: boolean;
}) { }) {
return ( return (
<input <input
@@ -56,7 +125,7 @@ function TextInput({
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} 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, max,
step, step,
placeholder, placeholder,
error,
}: { }: {
value: number | ""; value: number | "";
onChange: (v: number | "") => void; onChange: (v: number | "") => void;
@@ -75,6 +145,7 @@ function NumberInput({
max?: number; max?: number;
step?: number | "any"; step?: number | "any";
placeholder?: string; placeholder?: string;
error?: boolean;
}) { }) {
return ( return (
<input <input
@@ -84,10 +155,8 @@ function NumberInput({
step={step} step={step}
value={value} value={value}
placeholder={placeholder} placeholder={placeholder}
onChange={(e) => onChange={(e) => onChange(e.target.value === "" ? "" : Number(e.target.value))}
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"}`}
}
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"
/> />
); );
} }
@@ -113,7 +182,7 @@ function NativeSelect({
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Block editor // Defaults
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function defaultFilter(): MediaFilter { function defaultFilter(): MediaFilter {
@@ -128,53 +197,62 @@ function defaultFilter(): MediaFilter {
}; };
} }
function defaultBlock(): ProgrammingBlock { function defaultBlock(startMins = 20 * 60, durationMins = 60): ProgrammingBlock {
return { return {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: "", name: "",
start_time: "20:00:00", start_time: minsToTime(startMins),
duration_mins: 60, duration_mins: durationMins,
content: { content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" },
type: "algorithmic",
filter: defaultFilter(),
strategy: "random",
},
}; };
} }
// ---------------------------------------------------------------------------
// BlockEditor (detail form for a single block)
// ---------------------------------------------------------------------------
interface BlockEditorProps { interface BlockEditorProps {
block: ProgrammingBlock; block: ProgrammingBlock;
index: number;
isSelected: boolean;
color: string;
errors: FieldErrors;
onChange: (block: ProgrammingBlock) => void; onChange: (block: ProgrammingBlock) => void;
onRemove: () => void; onRemove: () => void;
onSelect: () => void;
} }
function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) { function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemove, onSelect }: BlockEditorProps) {
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(isSelected);
const elRef = useRef<HTMLDivElement>(null);
const setField = <K extends keyof ProgrammingBlock>( // Scroll into view when selected
key: K, useEffect(() => {
value: ProgrammingBlock[K], if (isSelected) {
) => onChange({ ...block, [key]: value }); 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 content = block.content;
const pfx = `blocks.${index}`;
const setContentType = (type: "algorithmic" | "manual") => { const setContentType = (type: "algorithmic" | "manual") => {
if (type === "algorithmic") { onChange({
onChange({ ...block,
...block, content:
content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" }, type === "algorithmic"
}); ? { type: "algorithmic", filter: defaultFilter(), strategy: "random" }
} else { : { type: "manual", items: [] },
onChange({ ...block, content: { type: "manual", items: [] } }); });
}
}; };
const setFilter = (patch: Partial<MediaFilter>) => { const setFilter = (patch: Partial<MediaFilter>) => {
if (content.type !== "algorithmic") return; if (content.type !== "algorithmic") return;
onChange({ onChange({ ...block, content: { ...content, filter: { ...content.filter, ...patch } } });
...block,
content: { ...content, filter: { ...content.filter, ...patch } },
});
}; };
const setStrategy = (strategy: FillStrategy) => { const setStrategy = (strategy: FillStrategy) => {
@@ -183,12 +261,15 @@ function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) {
}; };
return ( return (
<div className="rounded-lg border border-zinc-700 bg-zinc-800/50"> <div
{/* Block header */} 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="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 <button
type="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" className="flex flex-1 items-center gap-2 text-left text-sm font-medium text-zinc-200"
> >
{expanded ? ( {expanded ? (
@@ -213,11 +294,12 @@ function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) {
{expanded && ( {expanded && (
<div className="space-y-3 border-t border-zinc-700 px-3 py-3"> <div className="space-y-3 border-t border-zinc-700 px-3 py-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Block name"> <Field label="Block name" error={errors[`${pfx}.name`]}>
<TextInput <TextInput
value={block.name} value={block.name}
onChange={(v) => setField("name", v)} onChange={(v) => setField("name", v)}
placeholder="Evening Sitcoms" placeholder="Evening Sitcoms"
error={!!errors[`${pfx}.name`]}
/> />
</Field> </Field>
<Field label="Content type"> <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" 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>
<Field label="Duration (minutes)"> <Field label="Duration (minutes)" error={errors[`${pfx}.duration_mins`]}>
<NumberInput <NumberInput
value={block.duration_mins} value={block.duration_mins}
onChange={(v) => setField("duration_mins", v === "" ? 60 : v)} onChange={(v) => setField("duration_mins", v === "" ? 60 : v)}
min={1} min={1}
error={!!errors[`${pfx}.duration_mins`]}
/> />
</Field> </Field>
</div> </div>
{content.type === "algorithmic" && ( {content.type === "algorithmic" && (
<div className="space-y-3 rounded-md border border-zinc-700/50 bg-zinc-800 p-3"> <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"> <p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Filter</p>
Filter
</p>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Media type"> <Field label="Media type">
<NativeSelect <NativeSelect
value={content.filter.content_type ?? ""} value={content.filter.content_type ?? ""}
onChange={(v) => onChange={(v) => setFilter({ content_type: v === "" ? null : (v as ContentType) })}
setFilter({
content_type: v === "" ? null : (v as ContentType),
})
}
> >
<option value="">Any</option> <option value="">Any</option>
<option value="movie">Movie</option> <option value="movie">Movie</option>
@@ -271,7 +348,6 @@ function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) {
<option value="short">Short</option> <option value="short">Short</option>
</NativeSelect> </NativeSelect>
</Field> </Field>
<Field label="Strategy"> <Field label="Strategy">
<NativeSelect <NativeSelect
value={content.strategy} value={content.strategy}
@@ -284,73 +360,54 @@ function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) {
</Field> </Field>
</div> </div>
<Field <Field label="Genres" hint="Press Enter or comma to add">
label="Genres" <TagInput
hint="Comma-separated, e.g. Comedy, Action" values={content.filter.genres}
> onChange={(v) => setFilter({ genres: v })}
<TextInput placeholder="Comedy, Sci-Fi…"
value={content.filter.genres.join(", ")} />
onChange={(v) => </Field>
setFilter({
genres: v <Field label="Tags" hint="Press Enter or comma to add">
.split(",") <TagInput
.map((s) => s.trim()) values={content.filter.tags}
.filter(Boolean), onChange={(v) => setFilter({ tags: v })}
}) placeholder="classic, family…"
}
placeholder="Comedy, Sci-Fi"
/> />
</Field> </Field>
<div className="grid grid-cols-3 gap-3"> <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 <NumberInput
value={content.filter.decade ?? ""} value={content.filter.decade ?? ""}
onChange={(v) => onChange={(v) => setFilter({ decade: v === "" ? null : (v as number) })}
setFilter({ decade: v === "" ? null : (v as number) })
}
placeholder="1990" placeholder="1990"
error={!!errors[`${pfx}.content.filter.decade`]}
/> />
</Field> </Field>
<Field label="Min duration (s)"> <Field label="Min duration (s)" error={errors[`${pfx}.content.filter.min_duration_secs`]}>
<NumberInput <NumberInput
value={content.filter.min_duration_secs ?? ""} value={content.filter.min_duration_secs ?? ""}
onChange={(v) => onChange={(v) => setFilter({ min_duration_secs: v === "" ? null : (v as number) })}
setFilter({
min_duration_secs: v === "" ? null : (v as number),
})
}
placeholder="1200" placeholder="1200"
error={!!errors[`${pfx}.content.filter.min_duration_secs`]}
/> />
</Field> </Field>
<Field label="Max duration (s)"> <Field label="Max duration (s)" error={errors[`${pfx}.content.filter.max_duration_secs`]}>
<NumberInput <NumberInput
value={content.filter.max_duration_secs ?? ""} value={content.filter.max_duration_secs ?? ""}
onChange={(v) => onChange={(v) => setFilter({ max_duration_secs: v === "" ? null : (v as number) })}
setFilter({
max_duration_secs: v === "" ? null : (v as number),
})
}
placeholder="3600" placeholder="3600"
error={!!errors[`${pfx}.content.filter.max_duration_secs`]}
/> />
</Field> </Field>
</div> </div>
<Field <Field label="Collections" hint="Jellyfin library IDs">
label="Collections" <TagInput
hint="Jellyfin library IDs, comma-separated" values={content.filter.collections}
> onChange={(v) => setFilter({ collections: v })}
<TextInput placeholder="abc123…"
value={content.filter.collections.join(", ")}
onChange={(v) =>
setFilter({
collections: v
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
placeholder="abc123"
/> />
</Field> </Field>
</div> </div>
@@ -358,9 +415,7 @@ function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) {
{content.type === "manual" && ( {content.type === "manual" && (
<div className="space-y-2 rounded-md border border-zinc-700/50 bg-zinc-800 p-3"> <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"> <p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Item IDs</p>
Item IDs
</p>
<textarea <textarea
rows={3} rows={3}
value={content.items.join("\n")} value={content.items.join("\n")}
@@ -369,19 +424,14 @@ function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) {
...block, ...block,
content: { content: {
type: "manual", type: "manual",
items: e.target.value items: e.target.value.split("\n").map((s) => s.trim()).filter(Boolean),
.split("\n")
.map((s) => s.trim())
.filter(Boolean),
}, },
}) })
} }
placeholder={"abc123\ndef456\nghi789"} 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" 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"> <p className="text-[11px] text-zinc-600">One Jellyfin item ID per line, played in order.</p>
One Jellyfin item ID per line, played in order.
</p>
</div> </div>
)} )}
</div> </div>
@@ -394,40 +444,30 @@ function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) {
// Recycle policy editor // Recycle policy editor
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface RecyclePolicyEditorProps { function RecyclePolicyEditor({
policy,
errors,
onChange,
}: {
policy: RecyclePolicy; policy: RecyclePolicy;
errors: FieldErrors;
onChange: (policy: RecyclePolicy) => void; onChange: (policy: RecyclePolicy) => void;
} }) {
function RecyclePolicyEditor({ policy, onChange }: RecyclePolicyEditorProps) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Cooldown (days)" hint="Don't replay within N days"> <Field label="Cooldown (days)" hint="Don't replay within N days">
<NumberInput <NumberInput
value={policy.cooldown_days ?? ""} value={policy.cooldown_days ?? ""}
onChange={(v) => onChange={(v) => onChange({ ...policy, cooldown_days: v === "" ? null : (v as number) })}
onChange({
...policy,
cooldown_days: v === "" ? null : (v as number),
})
}
min={0} min={0}
placeholder="7" placeholder="7"
/> />
</Field> </Field>
<Field <Field label="Cooldown (generations)" hint="Don't replay within N schedules">
label="Cooldown (generations)"
hint="Don't replay within N schedules"
>
<NumberInput <NumberInput
value={policy.cooldown_generations ?? ""} value={policy.cooldown_generations ?? ""}
onChange={(v) => onChange={(v) => onChange({ ...policy, cooldown_generations: v === "" ? null : (v as number) })}
onChange({
...policy,
cooldown_generations: v === "" ? null : (v as number),
})
}
min={0} min={0}
placeholder="3" placeholder="3"
/> />
@@ -435,20 +475,17 @@ function RecyclePolicyEditor({ policy, onChange }: RecyclePolicyEditorProps) {
</div> </div>
<Field <Field
label="Min available ratio" 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 <NumberInput
value={policy.min_available_ratio} value={policy.min_available_ratio}
onChange={(v) => onChange={(v) => onChange({ ...policy, min_available_ratio: v === "" ? 0.1 : (v as number) })}
onChange({
...policy,
min_available_ratio: v === "" ? 0.1 : (v as number),
})
}
min={0} min={0}
max={1} max={1}
step={0.01} step={0.01}
placeholder="0.1" placeholder="0.1"
error={!!errors["recycle_policy.min_available_ratio"]}
/> />
</Field> </Field>
</div> </div>
@@ -494,8 +531,9 @@ export function EditChannelSheet({
cooldown_generations: null, cooldown_generations: null,
min_available_ratio: 0.1, 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(() => { useEffect(() => {
if (channel) { if (channel) {
setName(channel.name); setName(channel.name);
@@ -503,12 +541,25 @@ export function EditChannelSheet({
setTimezone(channel.timezone); setTimezone(channel.timezone);
setBlocks(channel.schedule_config.blocks); setBlocks(channel.schedule_config.blocks);
setRecyclePolicy(channel.recycle_policy); setRecyclePolicy(channel.recycle_policy);
setSelectedBlockId(null);
setFieldErrors({});
} }
}, [channel]); }, [channel]);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!channel) return; 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, { onSubmit(channel.id, {
name, name,
description, 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) => const updateBlock = (idx: number, block: ProgrammingBlock) =>
setBlocks((prev) => prev.map((b, i) => (i === idx ? block : b))); setBlocks((prev) => prev.map((b, i) => (i === idx ? block : b)));
const removeBlock = (idx: number) => const removeBlock = (idx: number) => {
setBlocks((prev) => prev.filter((_, i) => i !== idx)); setBlocks((prev) => {
const next = prev.filter((_, i) => i !== idx);
if (selectedBlockId === prev[idx].id) setSelectedBlockId(null);
return next;
});
};
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent <SheetContent
side="right" 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"> <SheetHeader className="border-b border-zinc-800 px-6 py-4">
<SheetTitle className="text-zinc-100">Edit channel</SheetTitle> <SheetTitle className="text-zinc-100">Edit channel</SheetTitle>
</SheetHeader> </SheetHeader>
<form <form onSubmit={handleSubmit} className="flex flex-1 flex-col overflow-hidden">
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-4"> <div className="flex-1 space-y-6 overflow-y-auto px-6 py-4">
{/* Basic info */} {/* Basic info */}
<section className="space-y-3"> <section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500"> <h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Basic info</h3>
Basic info
</h3>
<Field label="Name"> <Field label="Name" error={fieldErrors["name"]}>
<TextInput <TextInput
required required
value={name} value={name}
onChange={setName} onChange={setName}
placeholder="90s Sitcom Network" placeholder="90s Sitcom Network"
error={!!fieldErrors["name"]}
/> />
</Field> </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 <TextInput
required required
value={timezone} value={timezone}
onChange={setTimezone} onChange={setTimezone}
placeholder="UTC" placeholder="UTC"
error={!!fieldErrors["timezone"]}
/> />
</Field> </Field>
@@ -586,7 +643,7 @@ export function EditChannelSheet({
type="button" type="button"
variant="outline" variant="outline"
size="xs" size="xs"
onClick={addBlock} onClick={() => addBlock()}
className="border-zinc-700 text-zinc-300 hover:text-zinc-100" className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
> >
<Plus className="size-3" /> <Plus className="size-3" />
@@ -594,9 +651,17 @@ export function EditChannelSheet({
</Button> </Button>
</div> </div>
<BlockTimeline
blocks={blocks}
selectedId={selectedBlockId}
onSelect={setSelectedBlockId}
onChange={setBlocks}
onCreateBlock={(startMins, durationMins) => addBlock(startMins, durationMins)}
/>
{blocks.length === 0 && ( {blocks.length === 0 && (
<p className="rounded-md border border-dashed border-zinc-700 px-4 py-6 text-center text-xs text-zinc-600"> <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> </p>
)} )}
@@ -605,8 +670,13 @@ export function EditChannelSheet({
<BlockEditor <BlockEditor
key={block.id} key={block.id}
block={block} block={block}
index={idx}
isSelected={block.id === selectedBlockId}
color={BLOCK_COLORS[idx % BLOCK_COLORS.length]}
errors={fieldErrors}
onChange={(b) => updateBlock(idx, b)} onChange={(b) => updateBlock(idx, b)}
onRemove={() => removeBlock(idx)} onRemove={() => removeBlock(idx)}
onSelect={() => setSelectedBlockId(block.id)}
/> />
))} ))}
</div> </div>
@@ -614,11 +684,10 @@ export function EditChannelSheet({
{/* Recycle policy */} {/* Recycle policy */}
<section className="space-y-3"> <section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500"> <h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Recycle policy</h3>
Recycle policy
</h3>
<RecyclePolicyEditor <RecyclePolicyEditor
policy={recyclePolicy} policy={recyclePolicy}
errors={fieldErrors}
onChange={setRecyclePolicy} onChange={setRecyclePolicy}
/> />
</section> </section>
@@ -626,14 +695,13 @@ export function EditChannelSheet({
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between border-t border-zinc-800 px-6 py-4"> <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"> <div className="ml-auto flex gap-2">
<Button <Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={isPending}>
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isPending}> <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 { CreateChannelDialog } from "./components/create-channel-dialog";
import { DeleteChannelDialog } from "./components/delete-channel-dialog"; import { DeleteChannelDialog } from "./components/delete-channel-dialog";
import { EditChannelSheet } from "./components/edit-channel-sheet"; import { EditChannelSheet } from "./components/edit-channel-sheet";
import { ScheduleSheet } from "./components/schedule-sheet";
import type { ChannelResponse, ProgrammingBlock, RecyclePolicy } from "@/lib/types"; import type { ChannelResponse, ProgrammingBlock, RecyclePolicy } from "@/lib/types";
export default function DashboardPage() { export default function DashboardPage() {
@@ -27,6 +28,7 @@ export default function DashboardPage() {
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editChannel, setEditChannel] = useState<ChannelResponse | null>(null); const [editChannel, setEditChannel] = useState<ChannelResponse | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ChannelResponse | null>(null); const [deleteTarget, setDeleteTarget] = useState<ChannelResponse | null>(null);
const [scheduleChannel, setScheduleChannel] = useState<ChannelResponse | null>(null);
const handleCreate = (data: { const handleCreate = (data: {
name: string; name: string;
@@ -114,6 +116,7 @@ export default function DashboardPage() {
onEdit={() => setEditChannel(channel)} onEdit={() => setEditChannel(channel)}
onDelete={() => setDeleteTarget(channel)} onDelete={() => setDeleteTarget(channel)}
onGenerateSchedule={() => generateSchedule.mutate(channel.id)} onGenerateSchedule={() => generateSchedule.mutate(channel.id)}
onViewSchedule={() => setScheduleChannel(channel)}
/> />
))} ))}
</div> </div>
@@ -137,6 +140,12 @@ export default function DashboardPage() {
error={updateChannel.error?.message} error={updateChannel.error?.message}
/> />
<ScheduleSheet
channel={scheduleChannel}
open={!!scheduleChannel}
onOpenChange={(open) => { if (!open) setScheduleChannel(null); }}
/>
{deleteTarget && ( {deleteTarget && (
<DeleteChannelDialog <DeleteChannelDialog
channelName={deleteTarget.name} channelName={deleteTarget.name}

View File

@@ -28,6 +28,7 @@
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^4.3.6",
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -1545,7 +1546,7 @@
"yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
@@ -1563,8 +1564,6 @@
"@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
@@ -1607,8 +1606,6 @@
"eslint-plugin-react/resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="], "eslint-plugin-react/resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="],
"eslint-plugin-react-hooks/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -1635,6 +1632,8 @@
"router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],

View File

@@ -32,7 +32,8 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"vaul": "^1.1.2" "vaul": "^1.1.2",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",