From 477de2c49d7dd47a4aec1f091e16550715daf62b Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 11 Mar 2026 21:14:42 +0100 Subject: [PATCH] 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. --- .../dashboard/components/block-timeline.tsx | 232 ++++++++++ .../dashboard/components/channel-card.tsx | 17 +- .../components/edit-channel-sheet.tsx | 406 ++++++++++-------- .../dashboard/components/schedule-sheet.tsx | 211 +++++++++ .../(main)/dashboard/components/tag-input.tsx | 73 ++++ k-tv-frontend/app/(main)/dashboard/page.tsx | 9 + k-tv-frontend/bun.lock | 9 +- k-tv-frontend/package.json | 3 +- 8 files changed, 781 insertions(+), 179 deletions(-) create mode 100644 k-tv-frontend/app/(main)/dashboard/components/block-timeline.tsx create mode 100644 k-tv-frontend/app/(main)/dashboard/components/schedule-sheet.tsx create mode 100644 k-tv-frontend/app/(main)/dashboard/components/tag-input.tsx diff --git a/k-tv-frontend/app/(main)/dashboard/components/block-timeline.tsx b/k-tv-frontend/app/(main)/dashboard/components/block-timeline.tsx new file mode 100644 index 0000000..1832be0 --- /dev/null +++ b/k-tv-frontend/app/(main)/dashboard/components/block-timeline.tsx @@ -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(null); + const dragRef = useRef(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(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 ( +
+ {/* Hour labels */} +
+ {[0, 3, 6, 9, 12, 15, 18, 21, 24].map((h) => ( + + {h.toString().padStart(2, "0")} + + ))} +
+ + {/* Timeline strip */} +
{ + 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) => ( +
+ ))} + + {/* 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 ( +
{ + 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), + }; + }} + > + + {block.name || "Untitled"} + + {/* Resize handle */} +
{ + e.stopPropagation(); + dragRef.current = { + kind: "resize", + blockId: block.id, + startMins: timeToMins(block.start_time), + offsetMins: 0, + }; + }} + /> +
+ ); + })} + + {/* Create preview */} + {draft?.id === "__new__" && ( +
+ )} +
+ +

+ Drag to move · Drag right edge to resize · Click and drag empty space to create +

+
+ ); +} diff --git a/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx b/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx index ba746cb..463b4f1 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { Pencil, Trash2, RefreshCw, Tv2 } from "lucide-react"; +import { Pencil, Trash2, RefreshCw, Tv2, CalendarDays } from "lucide-react"; import { Button } from "@/components/ui/button"; import type { ChannelResponse } from "@/lib/types"; @@ -9,6 +9,7 @@ interface ChannelCardProps { onEdit: () => void; onDelete: () => void; onGenerateSchedule: () => void; + onViewSchedule: () => void; } export function ChannelCard({ @@ -17,6 +18,7 @@ export function ChannelCard({ onEdit, onDelete, onGenerateSchedule, + onViewSchedule, }: ChannelCardProps) { const blockCount = channel.schedule_config.blocks.length; @@ -74,11 +76,18 @@ export function ChannelCard({ disabled={isGenerating} className="flex-1" > - + {isGenerating ? "Generating…" : "Generate schedule"} +