- 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.
233 lines
7.8 KiB
TypeScript
233 lines
7.8 KiB
TypeScript
"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>
|
|
);
|
|
}
|