ik hou van kaas
This commit is contained in:
@@ -18,8 +18,8 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="bg-background">
|
<html lang="en" className="h-full overflow-hidden bg-background">
|
||||||
<body className={inter.className}>
|
<body className={`${inter.className} h-full overflow-hidden`}>
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<AuthProvider>{children}</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import Image from "next/image"
|
|
||||||
import { Menu, Plus, Loader2 } from "lucide-react"
|
import { Menu, Plus, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
import { useAuth } from "@/lib/auth-context"
|
import { useAuth } from "@/lib/auth-context"
|
||||||
import { useRooms, useEvents } from "@/lib/hooks"
|
import { useRooms, useEvents, useMyResponses } from "@/lib/hooks"
|
||||||
import { AuthScreen } from "@/components/auth-screen"
|
import { AuthScreen } from "@/components/auth-screen"
|
||||||
import { Sidebar } from "@/components/sidebar"
|
import { Sidebar } from "@/components/sidebar"
|
||||||
import { WeekView } from "@/components/week-view"
|
import { WeekView } from "@/components/week-view"
|
||||||
@@ -13,6 +12,7 @@ import { CreateEventDialog } from "@/components/create-event-dialog"
|
|||||||
import { EventDetailDialog } from "@/components/event-detail-dialog"
|
import { EventDetailDialog } from "@/components/event-detail-dialog"
|
||||||
import { CreateRoomDialog, JoinRoomDialog } from "@/components/room-dialogs"
|
import { CreateRoomDialog, JoinRoomDialog } from "@/components/room-dialogs"
|
||||||
import { RoomSettingsDialog } from "@/components/room-settings-dialog"
|
import { RoomSettingsDialog } from "@/components/room-settings-dialog"
|
||||||
|
import { RoomBackground } from "@/components/room-background"
|
||||||
import { getRoomBackground } from "@/lib/room-theme"
|
import { getRoomBackground } from "@/lib/room-theme"
|
||||||
import { addDays, startOfWeek, toDateParam, formatMonthYear } from "@/lib/date-utils"
|
import { addDays, startOfWeek, toDateParam, formatMonthYear } from "@/lib/date-utils"
|
||||||
import type { CalendarEvent, User } from "@/lib/types"
|
import type { CalendarEvent, User } from "@/lib/types"
|
||||||
@@ -62,6 +62,8 @@ function CalendarApp({ user, onLogout }: { user: User; onLogout: () => void }) {
|
|||||||
mutate: mutateEvents,
|
mutate: mutateEvents,
|
||||||
} = useEvents(activeRoomId, from, to)
|
} = useEvents(activeRoomId, from, to)
|
||||||
|
|
||||||
|
const { myResponses } = useMyResponses(events, user.id)
|
||||||
|
|
||||||
const activeRoom = rooms.find((r) => r.id === activeRoomId)
|
const activeRoom = rooms.find((r) => r.id === activeRoomId)
|
||||||
const background = getRoomBackground(activeRoomId)
|
const background = getRoomBackground(activeRoomId)
|
||||||
|
|
||||||
@@ -71,17 +73,11 @@ function CalendarApp({ user, onLogout }: { user: User; onLogout: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen w-full overflow-hidden bg-slate-900">
|
<div className="relative h-screen w-full overflow-hidden bg-slate-900">
|
||||||
<Image
|
<RoomBackground src={background} />
|
||||||
src={background || "/placeholder.svg"}
|
|
||||||
alt=""
|
|
||||||
fill
|
|
||||||
priority
|
|
||||||
className="object-cover transition-all duration-700"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-black/35" />
|
<div className="absolute inset-0 bg-black/35" />
|
||||||
|
|
||||||
<div className="relative z-10 flex min-h-screen w-full">
|
<div className="relative z-10 flex h-full w-full">
|
||||||
{/* Mobile sidebar backdrop */}
|
{/* Mobile sidebar backdrop */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -112,7 +108,7 @@ function CalendarApp({ user, onLogout }: { user: User; onLogout: () => void }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main className="flex min-h-screen flex-1 flex-col overflow-hidden">
|
<main className="flex h-full flex-1 flex-col overflow-hidden">
|
||||||
<header className="flex items-center justify-between gap-3 border-b border-white/10 bg-white/5 px-4 py-3 backdrop-blur-md md:px-6">
|
<header className="flex items-center justify-between gap-3 border-b border-white/10 bg-white/5 px-4 py-3 backdrop-blur-md md:px-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -149,7 +145,7 @@ function CalendarApp({ user, onLogout }: { user: User; onLogout: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
<div className="min-h-0 flex-1 overflow-auto p-3 md:p-6">
|
||||||
{eventsLoading ? (
|
{eventsLoading ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-white/60" />
|
<Loader2 className="h-6 w-6 animate-spin text-white/60" />
|
||||||
@@ -158,7 +154,7 @@ function CalendarApp({ user, onLogout }: { user: User; onLogout: () => void }) {
|
|||||||
<WeekView
|
<WeekView
|
||||||
weekStart={weekStart}
|
weekStart={weekStart}
|
||||||
events={events}
|
events={events}
|
||||||
showRoomColors={activeRoomId === null}
|
myResponses={myResponses}
|
||||||
onEventClick={(event) => setDialog({ type: "event-detail", event })}
|
onEventClick={(event) => setDialog({ type: "event-detail", event })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useEffect, useMemo, useRef } from "react"
|
||||||
import type { CalendarEvent } from "@/lib/types"
|
import type { CalendarEvent, SignupStatus } from "@/lib/types"
|
||||||
import { getRoomColor } from "@/lib/room-theme"
|
import { STRIPE_STYLE, getEventStyle } from "@/lib/room-theme"
|
||||||
import {
|
import { addDays, isSameDay, parseDateTime, WEEKDAY_LABELS } from "@/lib/date-utils"
|
||||||
addDays,
|
|
||||||
isSameDay,
|
|
||||||
parseDateTime,
|
|
||||||
WEEKDAY_LABELS,
|
|
||||||
} from "@/lib/date-utils"
|
|
||||||
|
|
||||||
const HOUR_HEIGHT = 64 // px per hour
|
const HOUR_HEIGHT = 64 // px per hour
|
||||||
|
|
||||||
interface WeekViewProps {
|
interface WeekViewProps {
|
||||||
weekStart: Date
|
weekStart: Date
|
||||||
events: CalendarEvent[]
|
events: CalendarEvent[]
|
||||||
showRoomColors: boolean
|
myResponses: Record<number, SignupStatus | undefined>
|
||||||
onEventClick: (event: CalendarEvent) => void
|
onEventClick: (event: CalendarEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,10 +24,20 @@ interface PositionedEvent {
|
|||||||
lanes: number
|
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.
|
// Assign overlapping events to side-by-side lanes within a single day.
|
||||||
function layoutDayEvents(
|
function layoutDayEvents(
|
||||||
events: { event: CalendarEvent; start: Date; end: Date }[],
|
events: { event: CalendarEvent; start: Date; end: Date }[],
|
||||||
hoursStart: number,
|
|
||||||
): PositionedEvent[] {
|
): PositionedEvent[] {
|
||||||
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime())
|
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime())
|
||||||
const result: PositionedEvent[] = []
|
const result: PositionedEvent[] = []
|
||||||
@@ -62,7 +67,7 @@ function layoutDayEvents(
|
|||||||
event: item.event,
|
event: item.event,
|
||||||
start: item.start,
|
start: item.start,
|
||||||
end: item.end,
|
end: item.end,
|
||||||
top: (startHrs - hoursStart) * HOUR_HEIGHT,
|
top: startHrs * HOUR_HEIGHT,
|
||||||
height: (safeEnd - startHrs) * HOUR_HEIGHT,
|
height: (safeEnd - startHrs) * HOUR_HEIGHT,
|
||||||
lane: item.lane,
|
lane: item.lane,
|
||||||
lanes,
|
lanes,
|
||||||
@@ -86,32 +91,18 @@ function layoutDayEvents(
|
|||||||
return result
|
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(
|
const days = useMemo(
|
||||||
() => Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)),
|
() => Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)),
|
||||||
[weekStart],
|
[weekStart],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Determine visible hour range from events (clamped to a sensible default).
|
// Full 24-hour day, always.
|
||||||
const { hoursStart, hoursEnd } = useMemo(() => {
|
const timeSlots = useMemo(() => Array.from({ length: 24 }, (_, i) => i), [])
|
||||||
let min = 8
|
const gridHeight = 24 * HOUR_HEIGHT
|
||||||
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(() => {
|
const positionedByDay = useMemo(() => {
|
||||||
return days.map((day) => {
|
return days.map((day) => {
|
||||||
@@ -122,21 +113,34 @@ export function WeekView({ weekStart, events, showRoomColors, onEventClick }: We
|
|||||||
end: parseDateTime(event.end_time),
|
end: parseDateTime(event.end_time),
|
||||||
}))
|
}))
|
||||||
.filter((x) => !isNaN(x.start.getTime()) && isSameDay(x.start, day))
|
.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 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) => {
|
// Auto-scroll so the current time sits vertically centered in the
|
||||||
if (h === 0) return "12 AM"
|
// scrollable area, once per mount (and whenever the visible week
|
||||||
if (h === 12) return "12 PM"
|
// changes to include today).
|
||||||
return h > 12 ? `${h - 12} PM` : `${h} AM`
|
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 (
|
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 */}
|
{/* 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="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" />
|
<div className="p-2" />
|
||||||
@@ -158,7 +162,7 @@ export function WeekView({ weekStart, events, showRoomColors, onEventClick }: We
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time grid */}
|
{/* Time grid */}
|
||||||
<div className="grid grid-cols-[4rem_repeat(7,1fr)]">
|
<div className="relative grid grid-cols-[4rem_repeat(7,1fr)]">
|
||||||
{/* Time labels */}
|
{/* Time labels */}
|
||||||
<div>
|
<div>
|
||||||
{timeSlots.map((hour) => (
|
{timeSlots.map((hour) => (
|
||||||
@@ -167,13 +171,15 @@ export function WeekView({ weekStart, events, showRoomColors, onEventClick }: We
|
|||||||
className="border-b border-white/10 pr-2 text-right text-xs text-white/70"
|
className="border-b border-white/10 pr-2 text-right text-xs text-white/70"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
{formatHour(hour)}
|
{formatHourLabel(hour)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Day columns */}
|
{/* Day columns */}
|
||||||
{days.map((day, dayIndex) => (
|
{days.map((day, dayIndex) => {
|
||||||
|
const isToday = isSameDay(day, today)
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={dayIndex}
|
key={dayIndex}
|
||||||
className="relative border-l border-white/20"
|
className="relative border-l border-white/20"
|
||||||
@@ -187,38 +193,56 @@ export function WeekView({ weekStart, events, showRoomColors, onEventClick }: We
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* 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="-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) => {
|
{positionedByDay[dayIndex].map((pe, i) => {
|
||||||
const color = showRoomColors ? getRoomColor(pe.event.room_id) : { bg: "bg-blue-500" }
|
const style = getEventStyle(pe.event, myResponses[pe.event.id])
|
||||||
const cancelled = pe.event.status === "cancelled"
|
const cancelled = pe.event.status === "cancelled"
|
||||||
const tentative = pe.event.status === "draft" || pe.event.status === "proposed"
|
|
||||||
const widthPct = 100 / pe.lanes
|
const widthPct = 100 / pe.lanes
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={pe.event.id + "-" + i}
|
key={pe.event.id + "-" + i}
|
||||||
onClick={() => onEventClick(pe.event)}
|
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} ${
|
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} ${
|
||||||
cancelled ? "opacity-50 line-through" : ""
|
style.dashed ? "border border-dashed border-white/70" : ""
|
||||||
} ${tentative ? "border border-dashed border-white/70" : ""}`}
|
} ${style.outline ? "border-2 border-white/80" : ""} ${
|
||||||
|
style.muted ? "opacity-60" : ""
|
||||||
|
}`}
|
||||||
style={{
|
style={{
|
||||||
top: pe.top,
|
top: pe.top,
|
||||||
height: pe.height,
|
height: pe.height,
|
||||||
left: `calc(${pe.lane * widthPct}% + 2px)`,
|
left: `calc(${pe.lane * widthPct}% + 2px)`,
|
||||||
width: `calc(${widthPct}% - 4px)`,
|
width: `calc(${widthPct}% - 4px)`,
|
||||||
|
...(style.striped ? STRIPE_STYLE : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="truncate font-medium">{pe.event.title}</div>
|
<div className={cancelled ? "opacity-50" : undefined}>
|
||||||
|
<div className={`truncate font-medium ${cancelled ? "line-through" : ""}`}>
|
||||||
|
{pe.event.title}
|
||||||
|
</div>
|
||||||
{pe.height > 34 && (
|
{pe.height > 34 && (
|
||||||
<div className="mt-0.5 truncate text-[10px] opacity-80">
|
<div className="mt-0.5 truncate text-[10px] opacity-80">
|
||||||
{pe.start.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
{formatMilitaryTime(pe.start)}
|
||||||
{" – "}
|
{" – "}
|
||||||
{pe.end.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
{formatMilitaryTime(pe.end)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function toInputDateTime(date: Date): string {
|
|||||||
|
|
||||||
export function formatTime(date: Date): string {
|
export function formatTime(date: Date): string {
|
||||||
if (isNaN(date.getTime())) return "--:--"
|
if (isNaN(date.getTime())) return "--:--"
|
||||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDayLabel(date: Date): string {
|
export function formatDayLabel(date: Date): string {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import useSWR from "swr"
|
import useSWR from "swr"
|
||||||
import * as api from "./api"
|
import * as api from "./api"
|
||||||
|
import type { CalendarEvent, SignupStatus } from "./types"
|
||||||
|
|
||||||
export function useRooms() {
|
export function useRooms() {
|
||||||
const { data, error, isLoading, mutate } = useSWR("rooms", () => api.getRooms())
|
const { data, error, isLoading, mutate } = useSWR("rooms", () => api.getRooms())
|
||||||
@@ -34,3 +35,31 @@ export function useRoomPermissions(roomId: number | null) {
|
|||||||
)
|
)
|
||||||
return { permissions: data, error, isLoading, mutate }
|
return { permissions: data, error, isLoading, mutate }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bulk-fetch the current user's own RSVP status for every visible proposal
|
||||||
|
// event, so the week view can color-code events the user has declined.
|
||||||
|
// Only proposal-type events carry RSVPs, so we skip busy/private blocks.
|
||||||
|
export function useMyResponses(events: CalendarEvent[], currentUserId: number | null) {
|
||||||
|
const proposalIds = events
|
||||||
|
.filter((e) => e.type === "proposal")
|
||||||
|
.map((e) => e.id)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
|
||||||
|
const key =
|
||||||
|
currentUserId !== null && proposalIds.length > 0
|
||||||
|
? ["my-responses", currentUserId, proposalIds.join(",")]
|
||||||
|
: null
|
||||||
|
|
||||||
|
const { data, isLoading } = useSWR(key, async () => {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
proposalIds.map(async (id) => {
|
||||||
|
const responses = await api.getEventResponses(id)
|
||||||
|
const mine = responses.find((r) => r.user_id === currentUserId)?.status
|
||||||
|
return [id, mine] as [number, SignupStatus | undefined]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return Object.fromEntries(entries) as Record<number, SignupStatus | undefined>
|
||||||
|
})
|
||||||
|
|
||||||
|
return { myResponses: data ?? {}, isLoading }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// Deterministic per-room theming: background image + accent color.
|
// Deterministic per-room theming: background image + accent color.
|
||||||
|
|
||||||
|
import type { CSSProperties } from "react"
|
||||||
|
import type { CalendarEvent, SignupStatus } from "./types"
|
||||||
|
|
||||||
const ROOM_BACKGROUNDS = [
|
const ROOM_BACKGROUNDS = [
|
||||||
"/backgrounds/room-1.png",
|
"/backgrounds/room-1.png",
|
||||||
"/backgrounds/room-2.png",
|
"/backgrounds/room-2.png",
|
||||||
@@ -27,6 +30,85 @@ export function getRoomBackground(roomId: number | null): string {
|
|||||||
return ROOM_BACKGROUNDS[roomId % ROOM_BACKGROUNDS.length]
|
return ROOM_BACKGROUNDS[roomId % ROOM_BACKGROUNDS.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRoomColor(roomId: number) {
|
// Guarded against null/undefined/NaN room ids (e.g. personal blocks that
|
||||||
return ROOM_COLORS[roomId % ROOM_COLORS.length]
|
// arrive without a valid room_id) so we never hand back an undefined color,
|
||||||
|
// which previously rendered as no background color at all.
|
||||||
|
export function getRoomColor(roomId: number | null | undefined) {
|
||||||
|
const id = typeof roomId === "number" && Number.isFinite(roomId) ? roomId : 0
|
||||||
|
const idx = ((id % ROOM_COLORS.length) + ROOM_COLORS.length) % ROOM_COLORS.length
|
||||||
|
return ROOM_COLORS[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Event coloring by type / status / RSVP ----------
|
||||||
|
//
|
||||||
|
// Confirmed and busy events each pick a color from a small themed pool,
|
||||||
|
// deterministically by room so the same room always looks the same.
|
||||||
|
// Private, proposed, declined and cancelled get one fixed look each so
|
||||||
|
// they're recognizable at a glance regardless of room.
|
||||||
|
|
||||||
|
export interface EventStyle {
|
||||||
|
bg: string
|
||||||
|
ring: string
|
||||||
|
/** Dashed border — signals "not yet settled" (pending proposal). */
|
||||||
|
dashed?: boolean
|
||||||
|
/** Diagonal hatch pattern — signals "you've declined this". */
|
||||||
|
striped?: boolean
|
||||||
|
/** Reduced opacity — signals "you're not going". */
|
||||||
|
muted?: boolean
|
||||||
|
/** Outline only, transparent fill — signals "private, details hidden". */
|
||||||
|
outline?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIRMED_PALETTE = [
|
||||||
|
{ bg: "bg-emerald-500", ring: "ring-emerald-400" },
|
||||||
|
{ bg: "bg-blue-500", ring: "ring-blue-400" },
|
||||||
|
{ bg: "bg-purple-500", ring: "ring-purple-400" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const BUSY_PALETTE = [
|
||||||
|
{ bg: "bg-orange-500", ring: "ring-orange-400" },
|
||||||
|
{ bg: "bg-yellow-400", ring: "ring-yellow-300" },
|
||||||
|
{ bg: "bg-red-500", ring: "ring-red-400" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function pickFromPalette(
|
||||||
|
palette: { bg: string; ring: string }[],
|
||||||
|
roomId: number | null | undefined,
|
||||||
|
) {
|
||||||
|
const id = typeof roomId === "number" && Number.isFinite(roomId) ? roomId : 0
|
||||||
|
const idx = ((id % palette.length) + palette.length) % palette.length
|
||||||
|
return palette[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIVATE_STYLE: EventStyle = { bg: "bg-white/10", ring: "ring-white/60", outline: true }
|
||||||
|
const PROPOSED_STYLE: EventStyle = { bg: "bg-white/25", ring: "ring-white/50", dashed: true }
|
||||||
|
const CANCELLED_STYLE: EventStyle = { bg: "bg-red-500", ring: "ring-red-400" }
|
||||||
|
const DECLINED_STYLE: EventStyle = {
|
||||||
|
bg: "bg-neutral-500",
|
||||||
|
ring: "ring-neutral-400",
|
||||||
|
striped: true,
|
||||||
|
muted: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline CSS for diagonal hatch patterns — Tailwind has no built-in
|
||||||
|
// striped-background utility, so this is applied via style prop.
|
||||||
|
export const STRIPE_STYLE: CSSProperties = {
|
||||||
|
backgroundImage:
|
||||||
|
"repeating-linear-gradient(135deg, rgba(255,255,255,0.18) 0px, rgba(255,255,255,0.18) 6px, transparent 6px, transparent 12px)",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEventStyle(
|
||||||
|
event: Pick<CalendarEvent, "type" | "status" | "room_id">,
|
||||||
|
myResponse: SignupStatus | undefined,
|
||||||
|
): EventStyle {
|
||||||
|
if (event.type === "private") return PRIVATE_STYLE
|
||||||
|
if (event.type === "busy") return pickFromPalette(BUSY_PALETTE, event.room_id)
|
||||||
|
|
||||||
|
// Proposal-type events: colored by where they stand.
|
||||||
|
if (event.status === "cancelled") return CANCELLED_STYLE
|
||||||
|
if (myResponse === "declined") return DECLINED_STYLE
|
||||||
|
if (event.status === "proposed" || event.status === "draft") return PROPOSED_STYLE
|
||||||
|
|
||||||
|
// Confirmed proposal: pick from the confirmed palette, by room.
|
||||||
|
return pickFromPalette(CONFIRMED_PALETTE, event.room_id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ module.exports = {
|
|||||||
"./pages/**/*.{ts,tsx}",
|
"./pages/**/*.{ts,tsx}",
|
||||||
"./components/**/*.{ts,tsx}",
|
"./components/**/*.{ts,tsx}",
|
||||||
"./app/**/*.{ts,tsx}",
|
"./app/**/*.{ts,tsx}",
|
||||||
|
"./lib/**/*.{ts,tsx}",
|
||||||
"./src/**/*.{ts,tsx}",
|
"./src/**/*.{ts,tsx}",
|
||||||
"*.{js,ts,jsx,tsx,mdx}",
|
"*.{js,ts,jsx,tsx,mdx}",
|
||||||
],
|
],
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user