new frontend

ik ben zo cool
This commit is contained in:
ben
2026-08-19 09:33:48 +02:00
parent 1822dea7e3
commit bf601846db
104 changed files with 16741 additions and 0 deletions
@@ -0,0 +1,225 @@
"use client"
import { useMemo } from "react"
import type { CalendarEvent } from "@/lib/types"
import { getRoomColor } from "@/lib/room-theme"
import {
addDays,
isSameDay,
parseDateTime,
WEEKDAY_LABELS,
} from "@/lib/date-utils"
const HOUR_HEIGHT = 64 // px per hour
interface WeekViewProps {
weekStart: Date
events: CalendarEvent[]
showRoomColors: boolean
onEventClick: (event: CalendarEvent) => void
}
interface PositionedEvent {
event: CalendarEvent
start: Date
end: Date
top: number
height: number
lane: number
lanes: number
}
// Assign overlapping events to side-by-side lanes within a single day.
function layoutDayEvents(
events: { event: CalendarEvent; start: Date; end: Date }[],
hoursStart: number,
): PositionedEvent[] {
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime())
const result: PositionedEvent[] = []
let cluster: typeof sorted = []
let clusterEnd = 0
const flush = () => {
if (cluster.length === 0) return
// Greedy lane assignment within the cluster.
const laneEnds: number[] = []
const withLanes = cluster.map((item) => {
let lane = laneEnds.findIndex((end) => end <= item.start.getTime())
if (lane === -1) {
lane = laneEnds.length
laneEnds.push(item.end.getTime())
} else {
laneEnds[lane] = item.end.getTime()
}
return { ...item, lane }
})
const lanes = laneEnds.length
for (const item of withLanes) {
const startHrs = item.start.getHours() + item.start.getMinutes() / 60
const endHrs = item.end.getHours() + item.end.getMinutes() / 60
const safeEnd = Math.max(endHrs, startHrs + 0.5)
result.push({
event: item.event,
start: item.start,
end: item.end,
top: (startHrs - hoursStart) * HOUR_HEIGHT,
height: (safeEnd - startHrs) * HOUR_HEIGHT,
lane: item.lane,
lanes,
})
}
cluster = []
clusterEnd = 0
}
for (const item of sorted) {
if (cluster.length === 0 || item.start.getTime() < clusterEnd) {
cluster.push(item)
clusterEnd = Math.max(clusterEnd, item.end.getTime())
} else {
flush()
cluster.push(item)
clusterEnd = item.end.getTime()
}
}
flush()
return result
}
export function WeekView({ weekStart, events, showRoomColors, onEventClick }: WeekViewProps) {
const days = useMemo(
() => Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)),
[weekStart],
)
// Determine visible hour range from events (clamped to a sensible default).
const { hoursStart, hoursEnd } = useMemo(() => {
let min = 8
let max = 18
for (const e of events) {
const s = parseDateTime(e.start_time)
const en = parseDateTime(e.end_time)
if (!isNaN(s.getTime())) min = Math.min(min, s.getHours())
if (!isNaN(en.getTime())) {
const endHr = en.getMinutes() > 0 ? en.getHours() + 1 : en.getHours()
max = Math.max(max, endHr)
}
}
return { hoursStart: Math.max(0, min), hoursEnd: Math.min(24, Math.max(max, min + 6)) }
}, [events])
const timeSlots = useMemo(
() => Array.from({ length: hoursEnd - hoursStart }, (_, i) => hoursStart + i),
[hoursStart, hoursEnd],
)
const positionedByDay = useMemo(() => {
return days.map((day) => {
const dayEvents = events
.map((event) => ({
event,
start: parseDateTime(event.start_time),
end: parseDateTime(event.end_time),
}))
.filter((x) => !isNaN(x.start.getTime()) && isSameDay(x.start, day))
return layoutDayEvents(dayEvents, hoursStart)
})
}, [days, events, hoursStart])
const today = new Date()
const gridHeight = (hoursEnd - hoursStart) * HOUR_HEIGHT
const formatHour = (h: number) => {
if (h === 0) return "12 AM"
if (h === 12) return "12 PM"
return h > 12 ? `${h - 12} PM` : `${h} AM`
}
return (
<div className="h-full overflow-auto rounded-xl border border-white/20 bg-white/20 shadow-xl backdrop-blur-lg">
{/* Week Header */}
<div className="sticky top-0 z-10 grid grid-cols-[4rem_repeat(7,1fr)] border-b border-white/20 bg-white/10 backdrop-blur-lg">
<div className="p-2" />
{days.map((date, i) => {
const isToday = isSameDay(date, today)
return (
<div key={i} className="border-l border-white/20 p-2 text-center">
<div className="text-xs font-medium text-white/70">{WEEKDAY_LABELS[i]}</div>
<div
className={`mx-auto mt-1 flex h-8 w-8 items-center justify-center text-lg font-medium text-white ${
isToday ? "rounded-full bg-blue-500" : ""
}`}
>
{date.getDate()}
</div>
</div>
)
})}
</div>
{/* Time grid */}
<div className="grid grid-cols-[4rem_repeat(7,1fr)]">
{/* Time labels */}
<div>
{timeSlots.map((hour) => (
<div
key={hour}
className="border-b border-white/10 pr-2 text-right text-xs text-white/70"
style={{ height: HOUR_HEIGHT }}
>
{formatHour(hour)}
</div>
))}
</div>
{/* Day columns */}
{days.map((day, dayIndex) => (
<div
key={dayIndex}
className="relative border-l border-white/20"
style={{ height: gridHeight }}
>
{timeSlots.map((hour) => (
<div
key={hour}
className="border-b border-white/10"
style={{ height: HOUR_HEIGHT }}
/>
))}
{positionedByDay[dayIndex].map((pe, i) => {
const color = showRoomColors ? getRoomColor(pe.event.room_id) : { bg: "bg-blue-500" }
const cancelled = pe.event.status === "cancelled"
const tentative = pe.event.status === "draft" || pe.event.status === "proposed"
const widthPct = 100 / pe.lanes
return (
<button
key={pe.event.id + "-" + i}
onClick={() => onEventClick(pe.event)}
className={`absolute overflow-hidden rounded-md p-1.5 text-left text-xs text-white shadow-md transition-all duration-150 ease-in-out hover:z-20 hover:-translate-y-0.5 hover:shadow-lg ${color.bg} ${
cancelled ? "opacity-50 line-through" : ""
} ${tentative ? "border border-dashed border-white/70" : ""}`}
style={{
top: pe.top,
height: pe.height,
left: `calc(${pe.lane * widthPct}% + 2px)`,
width: `calc(${widthPct}% - 4px)`,
}}
>
<div className="truncate font-medium">{pe.event.title}</div>
{pe.height > 34 && (
<div className="mt-0.5 truncate text-[10px] opacity-80">
{pe.start.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
{" "}
{pe.end.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</div>
)}
</button>
)
})}
</div>
))}
</div>
</div>
)
}