"use client"; import { useRef, useState, useEffect, useLayoutEffect } 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); const onChangeRef = useRef(onChange); const onCreateRef = useRef(onCreateBlock); useLayoutEffect(() => { blocksRef.current = blocks; onChangeRef.current = onChange; 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

); }