Files
k-tv/k-tv-frontend/app/(main)/dashboard/components/block-timeline.tsx
Gabriel Kaszewski 477de2c49d 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.
2026-03-11 21:14:42 +01:00

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>
);
}