295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
"use client"
|
||
|
||
import { useState } from "react"
|
||
import useSWR from "swr"
|
||
import {
|
||
Clock,
|
||
Calendar as CalendarIcon,
|
||
DoorClosed,
|
||
Users,
|
||
Check,
|
||
X,
|
||
HelpCircle,
|
||
Trash2,
|
||
CheckCircle2,
|
||
Ban,
|
||
Loader2,
|
||
Pencil,
|
||
UserRound,
|
||
} from "lucide-react"
|
||
import { GlassModal } from "./glass-modal"
|
||
import * as api from "@/lib/api"
|
||
import { ApiError } from "@/lib/api"
|
||
import { useRoomMembers } from "@/lib/hooks"
|
||
import type { CalendarEvent, Room, SignupStatus } from "@/lib/types"
|
||
import { getRoomColor } from "@/lib/room-theme"
|
||
import { parseDateTime, formatDayLabel, formatTime } from "@/lib/date-utils"
|
||
|
||
interface EventDetailDialogProps {
|
||
event: CalendarEvent
|
||
room?: Room
|
||
currentUserId: number
|
||
onClose: () => void
|
||
onChanged: () => void
|
||
onEdit: () => void
|
||
}
|
||
|
||
const STATUS_STYLES: Record<string, string> = {
|
||
confirmed: "bg-emerald-500/30 text-emerald-100 border-emerald-400/40",
|
||
proposed: "bg-amber-500/30 text-amber-100 border-amber-400/40",
|
||
draft: "bg-white/20 text-white border-white/30",
|
||
cancelled: "bg-red-500/30 text-red-100 border-red-400/40",
|
||
}
|
||
|
||
export function EventDetailDialog({
|
||
event,
|
||
room,
|
||
currentUserId,
|
||
onClose,
|
||
onChanged,
|
||
onEdit,
|
||
}: EventDetailDialogProps) {
|
||
const [busy, setBusy] = useState<string | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [deleteConfirming, setDeleteConfirming] = useState(false)
|
||
|
||
const { data: responses, mutate: mutateResponses } = useSWR(
|
||
["responses", event.id],
|
||
() => api.getEventResponses(event.id),
|
||
)
|
||
const { members } = useRoomMembers(event.room_id)
|
||
|
||
const start = parseDateTime(event.start_time)
|
||
const end = parseDateTime(event.end_time)
|
||
const color = getRoomColor(event.room_id)
|
||
|
||
const creator = members.find((m) => m.id === event.creator_id)
|
||
const creatorLabel =
|
||
event.creator_id === currentUserId ? "you" : (creator?.username ?? `member #${event.creator_id}`)
|
||
|
||
const canManage =
|
||
event.creator_id === currentUserId || room?.role === "admin" || room?.role === "owner"
|
||
|
||
const myResponse = responses?.find((r) => r.user_id === currentUserId)?.status
|
||
const goingCount = responses?.filter((r) => r.status === "going").length ?? 0
|
||
const maybeCount = responses?.filter((r) => r.status === "maybe").length ?? 0
|
||
const declinedCount = responses?.filter((r) => r.status === "declined").length ?? 0
|
||
|
||
const run = async (key: string, fn: () => Promise<unknown>, refetchResponses = false) => {
|
||
setBusy(key)
|
||
setError(null)
|
||
try {
|
||
await fn()
|
||
if (refetchResponses) await mutateResponses()
|
||
onChanged()
|
||
} catch (err) {
|
||
setError(err instanceof ApiError ? err.message : "Action failed.")
|
||
} finally {
|
||
setBusy(null)
|
||
}
|
||
}
|
||
|
||
const handleDelete = async () => {
|
||
setBusy("delete")
|
||
setError(null)
|
||
try {
|
||
await api.deleteEvent(event.id)
|
||
onChanged()
|
||
onClose()
|
||
} catch (err) {
|
||
setError(err instanceof ApiError ? err.message : "Action failed.")
|
||
} finally {
|
||
setBusy(null)
|
||
}
|
||
}
|
||
|
||
const respond = (status: SignupStatus) =>
|
||
run(`respond-${status}`, () => api.respondToEvent(event.id, status), true)
|
||
|
||
const rsvpButtons: { status: SignupStatus; label: string; icon: typeof Check }[] = [
|
||
{ status: "going", label: "Going", icon: Check },
|
||
{ status: "maybe", label: "Maybe", icon: HelpCircle },
|
||
{ status: "declined", label: "Can't go", icon: X },
|
||
]
|
||
|
||
return (
|
||
<GlassModal title={event.title} onClose={onClose}>
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className={`h-3 w-3 rounded-sm ${color.dot}`} />
|
||
<span className="text-sm text-white/80">{room?.name ?? `Room #${event.room_id}`}</span>
|
||
{event.type === "proposal" && (
|
||
<span
|
||
className={`ml-auto rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${
|
||
STATUS_STYLES[event.status] ?? STATUS_STYLES.draft
|
||
}`}
|
||
>
|
||
{event.status}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2 text-sm text-white/90">
|
||
<p className="flex items-center gap-2">
|
||
<CalendarIcon className="h-4 w-4 text-white/70" />
|
||
{formatDayLabel(start)}
|
||
</p>
|
||
<p className="flex items-center gap-2">
|
||
<Clock className="h-4 w-4 text-white/70" />
|
||
{formatTime(start)} – {formatTime(end)}
|
||
</p>
|
||
<p className="flex items-center gap-2 capitalize">
|
||
<DoorClosed className="h-4 w-4 text-white/70" />
|
||
{event.type} · {event.visibility.replace("_", " ")}
|
||
</p>
|
||
<p className="flex items-center gap-2">
|
||
<UserRound className="h-4 w-4 text-white/70" />
|
||
Created by {creatorLabel}
|
||
</p>
|
||
{event.type === "proposal" && (
|
||
<p className="flex items-center gap-2">
|
||
<Users className="h-4 w-4 text-white/70" />
|
||
{goingCount} going · needs {event.min_people}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{event.description && (
|
||
<p className="rounded-xl border border-white/15 bg-white/5 p-3 text-sm text-white/85">
|
||
{event.description}
|
||
</p>
|
||
)}
|
||
|
||
{/* RSVP — only relevant for proposals, busy/private have no attendees to respond */}
|
||
{event.type === "proposal" && event.status !== "cancelled" && (
|
||
<div>
|
||
<p className="mb-2 text-sm font-medium text-white/90">Your response</p>
|
||
<div className="flex gap-2">
|
||
{rsvpButtons.map(({ status, label, icon: Icon }) => {
|
||
const active = myResponse === status
|
||
return (
|
||
<button
|
||
key={status}
|
||
onClick={() => respond(status)}
|
||
disabled={busy !== null}
|
||
className={`flex flex-1 items-center justify-center gap-1.5 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors disabled:opacity-70 ${
|
||
active
|
||
? "border-white/40 bg-white/30 text-white"
|
||
: "border-white/20 bg-white/10 text-white/80 hover:bg-white/20"
|
||
}`}
|
||
>
|
||
{busy === `respond-${status}` ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Icon className="h-4 w-4" />
|
||
)}
|
||
{label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
<p className="mt-2 text-xs text-white/60">
|
||
{goingCount} going · {maybeCount} maybe · {declinedCount} declined
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="rounded-xl border border-red-400/40 bg-red-500/20 px-4 py-2.5 text-sm">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Management actions */}
|
||
{canManage && (
|
||
<div className="flex flex-col gap-3 border-t border-white/15 pt-4">
|
||
<div className="flex flex-wrap gap-2">
|
||
<button
|
||
onClick={onEdit}
|
||
disabled={busy !== null}
|
||
className="flex items-center gap-1.5 rounded-xl bg-white/10 px-3 py-2 text-sm font-medium transition-colors hover:bg-white/20 disabled:opacity-70"
|
||
>
|
||
<Pencil className="h-4 w-4" /> Edit event
|
||
</button>
|
||
{/* Confirm/cancel only make sense for proposals — busy/private
|
||
blocks are already "confirmed" the moment they're created. */}
|
||
{event.type === "proposal" && event.status !== "confirmed" && event.status !== "cancelled" && (
|
||
<button
|
||
onClick={() => run("confirm", () => api.confirmEvent(event.id))}
|
||
disabled={busy !== null}
|
||
className="flex items-center gap-1.5 rounded-xl bg-emerald-500/80 px-3 py-2 text-sm font-medium transition-colors hover:bg-emerald-500 disabled:opacity-70"
|
||
>
|
||
<CheckCircle2 className="h-4 w-4" /> Confirm event
|
||
</button>
|
||
)}
|
||
{event.type === "proposal" && event.status !== "cancelled" && (
|
||
<button
|
||
onClick={() => run("cancel", () => api.cancelEvent(event.id))}
|
||
disabled={busy !== null}
|
||
className="flex items-center gap-1.5 rounded-xl bg-white/10 px-3 py-2 text-sm font-medium transition-colors hover:bg-white/20 disabled:opacity-70"
|
||
>
|
||
<Ban className="h-4 w-4" /> Cancel event
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Delete lives in its own fixed spot, separate from Confirm/Cancel,
|
||
so its position never shifts depending on which of those show. */}
|
||
<div className="border-t border-white/10 pt-3">
|
||
{!deleteConfirming ? (
|
||
<button
|
||
onClick={() => setDeleteConfirming(true)}
|
||
disabled={busy !== null}
|
||
className="flex items-center gap-1.5 rounded-xl border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm font-medium text-red-200 transition-colors hover:bg-red-500/20 disabled:opacity-70"
|
||
>
|
||
<Trash2 className="h-4 w-4" /> Delete event
|
||
</button>
|
||
) : (
|
||
<div className="rounded-xl border border-red-400/40 bg-red-500/10 p-3">
|
||
<p className="mb-3 text-sm text-white/90">
|
||
Weet je zeker dat je dit evenement wilt verwijderen? Dit kan niet ongedaan
|
||
worden gemaakt.
|
||
</p>
|
||
<div className="flex flex-col gap-2">
|
||
{/* Destructive action sits on top, off the "usual" spot. */}
|
||
<button
|
||
onClick={handleDelete}
|
||
disabled={busy !== null}
|
||
className="flex items-center justify-center gap-1.5 rounded-xl bg-red-500 px-3 py-2.5 text-sm font-medium transition-colors hover:bg-red-600 disabled:opacity-70"
|
||
>
|
||
{busy === "delete" ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Trash2 className="h-4 w-4" />
|
||
)}
|
||
Ja, verwijderen
|
||
</button>
|
||
{/* Safe action sits below, in the spot people click fast out of habit. */}
|
||
<button
|
||
onClick={() => setDeleteConfirming(false)}
|
||
disabled={busy !== null}
|
||
className="rounded-xl border border-white/20 bg-white/10 px-3 py-2.5 text-sm font-medium text-white transition-colors hover:bg-white/20 disabled:opacity-70"
|
||
>
|
||
OK, annuleren
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Always-available close action, bottom right. */}
|
||
<div className="flex justify-end pt-1">
|
||
<button
|
||
onClick={onClose}
|
||
className="rounded-xl border-2 border-emerald-400/80 bg-white/5 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-500/20"
|
||
>
|
||
OK
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</GlassModal>
|
||
)
|
||
}
|