ik hou van kaas
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Image from "next/image"
|
||||
|
||||
interface RoomBackgroundProps {
|
||||
src: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
interface Layer {
|
||||
id: number
|
||||
src: string
|
||||
}
|
||||
|
||||
// Stacks background images and crossfades between them whenever `src`
|
||||
// changes. Each new layer only starts fading in once it has actually
|
||||
// finished loading, and layers below a fully-faded-in layer are dropped
|
||||
// on transitionend rather than a guessed timeout — this keeps rapid
|
||||
// room switches (and slow/cold-cache images) visually consistent.
|
||||
export function RoomBackground({ src, durationMs = 700 }: RoomBackgroundProps) {
|
||||
const idRef = useRef(0)
|
||||
const [layers, setLayers] = useState<Layer[]>(() => [{ id: idRef.current, src }])
|
||||
const [revealed, setRevealed] = useState<Set<number>>(() => new Set([idRef.current]))
|
||||
|
||||
useEffect(() => {
|
||||
setLayers((prev) => {
|
||||
if (prev[prev.length - 1]?.src === src) return prev
|
||||
idRef.current += 1
|
||||
return [...prev, { id: idRef.current, src }]
|
||||
})
|
||||
}, [src])
|
||||
|
||||
const reveal = (id: number) => {
|
||||
setRevealed((prev) => {
|
||||
if (prev.has(id)) return prev
|
||||
const next = new Set(prev)
|
||||
next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleTopLayerTransitionEnd = (id: number) => {
|
||||
// Once the newest layer has finished fading in, everything below it
|
||||
// is fully hidden and can be dropped.
|
||||
setLayers((prev) => {
|
||||
const idx = prev.findIndex((l) => l.id === id)
|
||||
if (idx <= 0) return prev
|
||||
return prev.slice(idx)
|
||||
})
|
||||
}
|
||||
|
||||
const topId = layers[layers.length - 1]?.id
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{layers.map((layer, i) => {
|
||||
const isTop = i === layers.length - 1
|
||||
const isRevealed = revealed.has(layer.id)
|
||||
return (
|
||||
<Image
|
||||
key={layer.id}
|
||||
src={layer.src || "/placeholder.svg"}
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
className="object-cover transition-opacity ease-in-out"
|
||||
style={{
|
||||
transitionDuration: `${durationMs}ms`,
|
||||
opacity: isRevealed ? 1 : 0,
|
||||
}}
|
||||
onLoadingComplete={() => reveal(layer.id)}
|
||||
onTransitionEnd={() => {
|
||||
if (isTop && layer.id === topId) handleTopLayerTransitionEnd(layer.id)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,16 @@
|
||||
"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"
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
import type { CalendarEvent, SignupStatus } from "@/lib/types"
|
||||
import { STRIPE_STYLE, getEventStyle } 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
|
||||
myResponses: Record<number, SignupStatus | undefined>
|
||||
onEventClick: (event: CalendarEvent) => void
|
||||
}
|
||||
|
||||
@@ -29,10 +24,20 @@ interface PositionedEvent {
|
||||
lanes: number
|
||||
}
|
||||
|
||||
// Military (24h) time, e.g. "09:00", "14:30".
|
||||
function formatMilitaryTime(date: Date): string {
|
||||
const h = String(date.getHours()).padStart(2, "0")
|
||||
const m = String(date.getMinutes()).padStart(2, "0")
|
||||
return `${h}:${m}`
|
||||
}
|
||||
|
||||
function formatHourLabel(h: number): string {
|
||||
return `${String(h % 24).padStart(2, "0")}:00`
|
||||
}
|
||||
|
||||
// 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[] = []
|
||||
@@ -62,7 +67,7 @@ function layoutDayEvents(
|
||||
event: item.event,
|
||||
start: item.start,
|
||||
end: item.end,
|
||||
top: (startHrs - hoursStart) * HOUR_HEIGHT,
|
||||
top: startHrs * HOUR_HEIGHT,
|
||||
height: (safeEnd - startHrs) * HOUR_HEIGHT,
|
||||
lane: item.lane,
|
||||
lanes,
|
||||
@@ -86,32 +91,18 @@ function layoutDayEvents(
|
||||
return result
|
||||
}
|
||||
|
||||
export function WeekView({ weekStart, events, showRoomColors, onEventClick }: WeekViewProps) {
|
||||
export function WeekView({ weekStart, events, myResponses, onEventClick }: WeekViewProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const hasAutoScrolled = useRef(false)
|
||||
|
||||
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],
|
||||
)
|
||||
// Full 24-hour day, always.
|
||||
const timeSlots = useMemo(() => Array.from({ length: 24 }, (_, i) => i), [])
|
||||
const gridHeight = 24 * HOUR_HEIGHT
|
||||
|
||||
const positionedByDay = useMemo(() => {
|
||||
return days.map((day) => {
|
||||
@@ -122,21 +113,34 @@ export function WeekView({ weekStart, events, showRoomColors, onEventClick }: We
|
||||
end: parseDateTime(event.end_time),
|
||||
}))
|
||||
.filter((x) => !isNaN(x.start.getTime()) && isSameDay(x.start, day))
|
||||
return layoutDayEvents(dayEvents, hoursStart)
|
||||
return layoutDayEvents(dayEvents)
|
||||
})
|
||||
}, [days, events, hoursStart])
|
||||
}, [days, events])
|
||||
|
||||
const today = new Date()
|
||||
const gridHeight = (hoursEnd - hoursStart) * HOUR_HEIGHT
|
||||
const nowMinutes = today.getHours() * 60 + today.getMinutes()
|
||||
const nowTop = (nowMinutes / 60) * HOUR_HEIGHT
|
||||
const todayColumnIndex = days.findIndex((d) => isSameDay(d, today))
|
||||
|
||||
const formatHour = (h: number) => {
|
||||
if (h === 0) return "12 AM"
|
||||
if (h === 12) return "12 PM"
|
||||
return h > 12 ? `${h - 12} PM` : `${h} AM`
|
||||
}
|
||||
// Auto-scroll so the current time sits vertically centered in the
|
||||
// scrollable area, once per mount (and whenever the visible week
|
||||
// changes to include today).
|
||||
useEffect(() => {
|
||||
if (!scrollRef.current) return
|
||||
if (todayColumnIndex === -1) return
|
||||
if (hasAutoScrolled.current) return
|
||||
|
||||
const container = scrollRef.current
|
||||
const target = nowTop - container.clientHeight / 2
|
||||
container.scrollTop = Math.max(0, target)
|
||||
hasAutoScrolled.current = true
|
||||
}, [todayColumnIndex, nowTop])
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto rounded-xl border border-white/20 bg-white/20 shadow-xl backdrop-blur-lg">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
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" />
|
||||
@@ -158,7 +162,7 @@ export function WeekView({ weekStart, events, showRoomColors, onEventClick }: We
|
||||
</div>
|
||||
|
||||
{/* Time grid */}
|
||||
<div className="grid grid-cols-[4rem_repeat(7,1fr)]">
|
||||
<div className="relative grid grid-cols-[4rem_repeat(7,1fr)]">
|
||||
{/* Time labels */}
|
||||
<div>
|
||||
{timeSlots.map((hour) => (
|
||||
@@ -167,58 +171,78 @@ export function WeekView({ weekStart, events, showRoomColors, onEventClick }: We
|
||||
className="border-b border-white/10 pr-2 text-right text-xs text-white/70"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
>
|
||||
{formatHour(hour)}
|
||||
{formatHourLabel(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 }}
|
||||
/>
|
||||
))}
|
||||
{days.map((day, dayIndex) => {
|
||||
const isToday = isSameDay(day, today)
|
||||
return (
|
||||
<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)`,
|
||||
}}
|
||||
{/* Current-time indicator line */}
|
||||
{isToday && (
|
||||
<div
|
||||
className="pointer-events-none absolute left-0 right-0 z-10 flex items-center"
|
||||
style={{ top: nowTop }}
|
||||
>
|
||||
<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 className="-ml-1 h-2 w-2 shrink-0 rounded-full bg-red-500" />
|
||||
<div className="h-px flex-1 bg-red-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{positionedByDay[dayIndex].map((pe, i) => {
|
||||
const style = getEventStyle(pe.event, myResponses[pe.event.id])
|
||||
const cancelled = pe.event.status === "cancelled"
|
||||
const widthPct = 100 / pe.lanes
|
||||
return (
|
||||
<button
|
||||
key={pe.event.id + "-" + i}
|
||||
onClick={() => onEventClick(pe.event)}
|
||||
className={`animate-fade-in 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 ${style.bg} ${
|
||||
style.dashed ? "border border-dashed border-white/70" : ""
|
||||
} ${style.outline ? "border-2 border-white/80" : ""} ${
|
||||
style.muted ? "opacity-60" : ""
|
||||
}`}
|
||||
style={{
|
||||
top: pe.top,
|
||||
height: pe.height,
|
||||
left: `calc(${pe.lane * widthPct}% + 2px)`,
|
||||
width: `calc(${widthPct}% - 4px)`,
|
||||
...(style.striped ? STRIPE_STYLE : {}),
|
||||
}}
|
||||
>
|
||||
<div className={cancelled ? "opacity-50" : undefined}>
|
||||
<div className={`truncate font-medium ${cancelled ? "line-through" : ""}`}>
|
||||
{pe.event.title}
|
||||
</div>
|
||||
{pe.height > 34 && (
|
||||
<div className="mt-0.5 truncate text-[10px] opacity-80">
|
||||
{formatMilitaryTime(pe.start)}
|
||||
{" – "}
|
||||
{formatMilitaryTime(pe.end)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user