Files
calendar/frontend(experimental)/newchat/components/event-detail-dialog.tsx
T
2026-08-19 12:10:02 +02:00

246 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useState } from "react"
import useSWR from "swr"
import {
Clock,
Calendar as CalendarIcon,
DoorClosed,
Users,
Check,
X,
HelpCircle,
Trash2,
CheckCircle2,
Ban,
Loader2,
} from "lucide-react"
import { GlassModal } from "./glass-modal"
import * as api from "@/lib/api"
import { ApiError } from "@/lib/api"
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
}
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,
}: 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 start = parseDateTime(event.start_time)
const end = parseDateTime(event.end_time)
const color = getRoomColor(event.room_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 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>
<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>
{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 */}
{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">
{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
</button>
)}
{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
</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={() => run("delete", () => api.deleteEvent(event.id))}
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>
)}
</div>
</GlassModal>
)
}