Files
k-tv/k-tv-frontend/app/(main)/dashboard/components/schedule-sheet.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

212 lines
8.0 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 { 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>;
}
function DayRow({ label, dayStart, slots, colorMap }: DayRowProps) {
const DAY_MS = 24 * 60 * 60 * 1000;
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
// 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}%` }}
/>
))}
{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 ?? "");
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}
/>
))}
</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>
);
}