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:
211
k-tv-frontend/app/(main)/dashboard/components/schedule-sheet.tsx
Normal file
211
k-tv-frontend/app/(main)/dashboard/components/schedule-sheet.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user