Files
k-tv/k-tv-frontend/app/(main)/dashboard/components/schedule-sheet.tsx

232 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useActiveSchedule } from "@/hooks/use-channels";
import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types";
import { BLOCK_COLORS } from "./block-timeline";
// Stable color per block_id within a schedule
function makeColorMap(slots: ScheduledSlotResponse[]): Map<string, string> {
const seen = new Map<string, string>();
slots.forEach((slot) => {
if (!seen.has(slot.block_id)) {
seen.set(slot.block_id, BLOCK_COLORS[seen.size % BLOCK_COLORS.length]);
}
});
return seen;
}
interface DayRowProps {
label: string;
dayStart: Date;
slots: ScheduledSlotResponse[];
colorMap: Map<string, string>;
now: Date;
}
function DayRow({ label, dayStart, slots, colorMap, now }: DayRowProps) {
const DAY_MS = 24 * 60 * 60 * 1000;
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
const nowPct = ((now.getTime() - dayStart.getTime()) / DAY_MS) * 100;
// Only include slots that overlap this day
const daySlots = slots.filter((s) => {
const start = new Date(s.start_at);
const end = new Date(s.end_at);
return start < dayEnd && end > dayStart;
});
return (
<div className="space-y-1">
<p className="text-xs font-medium text-zinc-500">{label}</p>
<div className="relative h-12 rounded-md border border-zinc-700 bg-zinc-900 overflow-hidden">
{/* 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}%` }}
/>
))}
{/* Current time marker */}
{nowPct >= 0 && nowPct <= 100 && (
<div
className="absolute inset-y-0 z-10 w-0.5 bg-red-500"
style={{ left: `${nowPct}%` }}
>
<div className="absolute -top-0.5 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full bg-red-500" />
</div>
)}
{daySlots.map((slot) => {
const slotStart = new Date(slot.start_at);
const slotEnd = new Date(slot.end_at);
// Clamp to this day
const clampedStart = Math.max(slotStart.getTime(), dayStart.getTime());
const clampedEnd = Math.min(slotEnd.getTime(), dayEnd.getTime());
const leftPct = ((clampedStart - dayStart.getTime()) / DAY_MS) * 100;
const widthPct = ((clampedEnd - clampedStart) / DAY_MS) * 100;
const color = colorMap.get(slot.block_id) ?? "#6b7280";
const startTime = slotStart.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
const endTime = slotEnd.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
return (
<div
key={slot.id}
title={`${slot.item.title}\n${startTime} ${endTime}`}
className="absolute top-1 bottom-1 rounded flex items-center overflow-hidden opacity-80 hover:opacity-100 transition-opacity cursor-default"
style={{
left: `max(${leftPct}%, 0px)`,
width: `max(${widthPct}%, 3px)`,
backgroundColor: color,
}}
>
<span className="px-1.5 text-[10px] text-white truncate pointer-events-none">
{slot.item.title}
</span>
</div>
);
})}
</div>
{/* Hour labels */}
<div className="relative h-3">
{[0, 6, 12, 18, 24].map((h) => (
<span
key={h}
className="absolute -translate-x-1/2 text-[9px] text-zinc-600"
style={{ left: `${(h / 24) * 100}%` }}
>
{h.toString().padStart(2, "0")}:00
</span>
))}
</div>
</div>
);
}
interface ScheduleSheetProps {
channel: ChannelResponse | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProps) {
const { data: schedule, isLoading, error } = useActiveSchedule(channel?.id ?? "");
// Live clock for the current-time marker — updates every minute
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 60_000);
return () => clearInterval(id);
}, []);
const colorMap = schedule ? makeColorMap(schedule.slots) : new Map();
// Build day rows from valid_from to valid_until
const days: { label: string; dayStart: Date }[] = [];
if (schedule) {
const start = new Date(schedule.valid_from);
// Start at midnight of the first day
const dayStart = new Date(start);
dayStart.setHours(0, 0, 0, 0);
const end = new Date(schedule.valid_until);
const cursor = new Date(dayStart);
while (cursor < end) {
days.push({
label: cursor.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }),
dayStart: new Date(cursor),
});
cursor.setDate(cursor.getDate() + 1);
}
}
const fmt = (iso: string) =>
new Date(iso).toLocaleString(undefined, {
weekday: "short", month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit", hour12: false,
});
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 border-zinc-800 bg-zinc-900 p-0 text-zinc-100 sm:max-w-2xl"
>
<SheetHeader className="border-b border-zinc-800 px-6 py-4">
<SheetTitle className="text-zinc-100">
Schedule {channel?.name}
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{isLoading && (
<div className="flex items-center justify-center py-16">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-700 border-t-zinc-300" />
</div>
)}
{error && (
<div className="rounded-lg border border-red-900/50 bg-red-950/20 px-4 py-3 text-sm text-red-400">
No active schedule. Generate one from the dashboard.
</div>
)}
{schedule && (
<>
{/* Meta */}
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-zinc-500">
<span>Generation <span className="text-zinc-300">#{schedule.generation}</span></span>
<span>From <span className="text-zinc-300">{fmt(schedule.valid_from)}</span></span>
<span>Until <span className="text-zinc-300">{fmt(schedule.valid_until)}</span></span>
<span><span className="text-zinc-300">{schedule.slots.length}</span> slots</span>
</div>
{/* Timeline per day */}
<div className="space-y-5">
{days.map(({ label, dayStart }) => (
<DayRow
key={dayStart.toISOString()}
label={label}
dayStart={dayStart}
slots={schedule.slots}
colorMap={colorMap}
now={now}
/>
))}
</div>
{/* Slot list */}
<div className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Slots</h3>
<div className="rounded-md border border-zinc-800 divide-y divide-zinc-800">
{schedule.slots.map((slot) => {
const color = colorMap.get(slot.block_id) ?? "#6b7280";
const start = new Date(slot.start_at).toLocaleString(undefined, {
weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false,
});
const durationMins = Math.round(
(new Date(slot.end_at).getTime() - new Date(slot.start_at).getTime()) / 60000
);
return (
<div key={slot.id} className="flex items-center gap-3 px-3 py-2.5">
<div className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-zinc-200">{slot.item.title}</p>
<p className="text-xs text-zinc-500">
{start} · {durationMins}m
</p>
</div>
</div>
);
})}
</div>
</div>
</>
)}
</div>
</SheetContent>
</Sheet>
);
}