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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user