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:
2026-03-11 21:14:42 +01:00
parent b813594059
commit 477de2c49d
8 changed files with 781 additions and 179 deletions

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