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:
232
k-tv-frontend/app/(main)/dashboard/components/block-timeline.tsx
Normal file
232
k-tv-frontend/app/(main)/dashboard/components/block-timeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.0–1.0. Keep at least this fraction of the pool selectable even if cooldown hasn't expired"
|
hint="0.0–1.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}>
|
||||||
|
|||||||
211
k-tv-frontend/app/(main)/dashboard/components/schedule-sheet.tsx
Normal file
211
k-tv-frontend/app/(main)/dashboard/components/schedule-sheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
k-tv-frontend/app/(main)/dashboard/components/tag-input.tsx
Normal file
73
k-tv-frontend/app/(main)/dashboard/components/tag-input.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user