msg
This commit is contained in:
@@ -12,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 { AccountSettingsDialog } from "@/components/account-settings-dialog"
|
||||||
import { RoomBackground } from "@/components/room-background"
|
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"
|
||||||
@@ -20,10 +21,12 @@ import type { CalendarEvent, User } from "@/lib/types"
|
|||||||
type DialogState =
|
type DialogState =
|
||||||
| { type: "none" }
|
| { type: "none" }
|
||||||
| { type: "create-event" }
|
| { type: "create-event" }
|
||||||
|
| { type: "edit-event"; event: CalendarEvent }
|
||||||
| { type: "event-detail"; event: CalendarEvent }
|
| { type: "event-detail"; event: CalendarEvent }
|
||||||
| { type: "create-room" }
|
| { type: "create-room" }
|
||||||
| { type: "join-room" }
|
| { type: "join-room" }
|
||||||
| { type: "room-settings" }
|
| { type: "room-settings" }
|
||||||
|
| { type: "account-settings" }
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { user, loading, logout } = useAuth()
|
const { user, loading, logout } = useAuth()
|
||||||
@@ -105,6 +108,7 @@ function CalendarApp({ user, onLogout }: { user: User; onLogout: () => void }) {
|
|||||||
onJoinRoom={() => setDialog({ type: "join-room" })}
|
onJoinRoom={() => setDialog({ type: "join-room" })}
|
||||||
user={user}
|
user={user}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
|
onOpenAccountSettings={() => setDialog({ type: "account-settings" })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -162,11 +166,12 @@ function CalendarApp({ user, onLogout }: { user: User; onLogout: () => void }) {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dialog.type === "create-event" && (
|
{(dialog.type === "create-event" || dialog.type === "edit-event") && (
|
||||||
<CreateEventDialog
|
<CreateEventDialog
|
||||||
rooms={rooms}
|
rooms={rooms}
|
||||||
defaultRoomId={activeRoomId}
|
defaultRoomId={activeRoomId}
|
||||||
defaultDate={selectedDate}
|
defaultDate={selectedDate}
|
||||||
|
editingEvent={dialog.type === "edit-event" ? dialog.event : undefined}
|
||||||
onClose={() => setDialog({ type: "none" })}
|
onClose={() => setDialog({ type: "none" })}
|
||||||
onCreated={() => {
|
onCreated={() => {
|
||||||
setDialog({ type: "none" })
|
setDialog({ type: "none" })
|
||||||
@@ -182,6 +187,7 @@ function CalendarApp({ user, onLogout }: { user: User; onLogout: () => void }) {
|
|||||||
currentUserId={user.id}
|
currentUserId={user.id}
|
||||||
onClose={() => setDialog({ type: "none" })}
|
onClose={() => setDialog({ type: "none" })}
|
||||||
onChanged={refreshAll}
|
onChanged={refreshAll}
|
||||||
|
onEdit={() => setDialog({ type: "edit-event", event: dialog.event })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -220,6 +226,14 @@ function CalendarApp({ user, onLogout }: { user: User; onLogout: () => void }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{dialog.type === "account-settings" && (
|
||||||
|
<AccountSettingsDialog
|
||||||
|
user={user}
|
||||||
|
onClose={() => setDialog({ type: "none" })}
|
||||||
|
onLogout={onLogout}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { LogOut, Mail, MailCheck, MailWarning, UserRound } from "lucide-react"
|
||||||
|
import { GlassModal, fieldClass, labelClass } from "./glass-modal"
|
||||||
|
import type { User } from "@/lib/types"
|
||||||
|
|
||||||
|
interface AccountSettingsDialogProps {
|
||||||
|
user: User
|
||||||
|
onClose: () => void
|
||||||
|
onLogout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountSettingsDialog({ user, onClose, onLogout }: AccountSettingsDialogProps) {
|
||||||
|
return (
|
||||||
|
<GlassModal title="Account settings" onClose={onClose}>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-blue-500 text-xl font-bold text-white">
|
||||||
|
{user.username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-lg font-semibold text-white">{user.username}</p>
|
||||||
|
<p className="truncate text-sm text-white/60">{user.email ?? "No email on file"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Username</label>
|
||||||
|
<div className={`${fieldClass} flex items-center gap-2 opacity-80`}>
|
||||||
|
<UserRound className="h-4 w-4 text-white/60" />
|
||||||
|
{user.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Email</label>
|
||||||
|
<div className={`${fieldClass} flex items-center gap-2 opacity-80`}>
|
||||||
|
<Mail className="h-4 w-4 text-white/60" />
|
||||||
|
{user.email ?? "—"}
|
||||||
|
</div>
|
||||||
|
{user.email && (
|
||||||
|
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-white/60">
|
||||||
|
{user.email_verified ? (
|
||||||
|
<>
|
||||||
|
<MailCheck className="h-3.5 w-3.5 text-emerald-300" /> Verified
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MailWarning className="h-3.5 w-3.5 text-amber-300" /> Not verified
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-white/15 pt-4">
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="flex items-center gap-1.5 rounded-xl border border-white/20 bg-white/10 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" /> Sign out
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,21 +6,23 @@ import { Loader2 } from "lucide-react"
|
|||||||
import { GlassModal, fieldClass, labelClass } from "./glass-modal"
|
import { GlassModal, fieldClass, labelClass } from "./glass-modal"
|
||||||
import * as api from "@/lib/api"
|
import * as api from "@/lib/api"
|
||||||
import { ApiError } from "@/lib/api"
|
import { ApiError } from "@/lib/api"
|
||||||
import type { EventType, EventVisibility, Room } from "@/lib/types"
|
import type { CalendarEvent, EventType, EventVisibility, Room } from "@/lib/types"
|
||||||
import { toBackendDateTime, toInputDateTime } from "@/lib/date-utils"
|
import { parseDateTime, toBackendDateTime, toDateParam, toInputTime } from "@/lib/date-utils"
|
||||||
|
|
||||||
interface CreateEventDialogProps {
|
interface CreateEventDialogProps {
|
||||||
rooms: Room[]
|
rooms: Room[]
|
||||||
defaultRoomId: number | null
|
defaultRoomId: number | null
|
||||||
defaultDate: Date
|
defaultDate: Date
|
||||||
|
/** When set, the dialog edits this event instead of creating a new one. */
|
||||||
|
editingEvent?: CalendarEvent
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onCreated: () => void
|
onCreated: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_OPTIONS: { value: EventType; label: string }[] = [
|
const TYPE_OPTIONS: { value: EventType; label: string }[] = [
|
||||||
{ value: "proposal", label: "Proposal (members vote to confirm)" },
|
{ value: "proposal", label: "Proposal (members vote to confirm)" },
|
||||||
{ value: "busy", label: "Busy (blocked time)" },
|
{ value: "busy", label: "Busy — room sees it as busy, no details" },
|
||||||
{ value: "private", label: "Private" },
|
{ value: "private", label: "Private — hidden from the room" },
|
||||||
]
|
]
|
||||||
|
|
||||||
const VISIBILITY_OPTIONS: { value: EventVisibility; label: string }[] = [
|
const VISIBILITY_OPTIONS: { value: EventVisibility; label: string }[] = [
|
||||||
@@ -35,27 +37,49 @@ export function CreateEventDialog({
|
|||||||
rooms,
|
rooms,
|
||||||
defaultRoomId,
|
defaultRoomId,
|
||||||
defaultDate,
|
defaultDate,
|
||||||
|
editingEvent,
|
||||||
onClose,
|
onClose,
|
||||||
onCreated,
|
onCreated,
|
||||||
}: CreateEventDialogProps) {
|
}: CreateEventDialogProps) {
|
||||||
const initialRoom = defaultRoomId ?? rooms[0]?.id ?? 0
|
const isEditing = !!editingEvent
|
||||||
|
const initialRoom = editingEvent?.room_id ?? defaultRoomId ?? rooms[0]?.id ?? 0
|
||||||
|
|
||||||
const defaultStart = new Date(defaultDate)
|
// Start time defaults to "now", rounded up to the next quarter hour.
|
||||||
defaultStart.setHours(9, 0, 0, 0)
|
const now = new Date()
|
||||||
const defaultEnd = new Date(defaultDate)
|
const defaultStartTime = new Date(now)
|
||||||
defaultEnd.setHours(10, 0, 0, 0)
|
defaultStartTime.setMinutes(Math.ceil(now.getMinutes() / 15) * 15, 0, 0)
|
||||||
|
const defaultEndTime = new Date(defaultStartTime)
|
||||||
|
defaultEndTime.setHours(defaultEndTime.getHours() + 1)
|
||||||
|
|
||||||
|
const editStart = editingEvent ? parseDateTime(editingEvent.start_time) : null
|
||||||
|
const editEnd = editingEvent ? parseDateTime(editingEvent.end_time) : null
|
||||||
|
|
||||||
const [roomId, setRoomId] = useState<number>(initialRoom)
|
const [roomId, setRoomId] = useState<number>(initialRoom)
|
||||||
const [title, setTitle] = useState("")
|
const [title, setTitle] = useState(editingEvent?.title ?? "")
|
||||||
const [description, setDescription] = useState("")
|
const [description, setDescription] = useState(editingEvent?.description ?? "")
|
||||||
const [type, setType] = useState<EventType>("proposal")
|
const [type, setType] = useState<EventType>(editingEvent?.type ?? "proposal")
|
||||||
const [visibility, setVisibility] = useState<EventVisibility>("room")
|
const [visibility, setVisibility] = useState<EventVisibility>(editingEvent?.visibility ?? "room")
|
||||||
const [start, setStart] = useState(toInputDateTime(defaultStart))
|
const [date, setDate] = useState(toDateParam(editStart ?? defaultDate))
|
||||||
const [end, setEnd] = useState(toInputDateTime(defaultEnd))
|
const [multiDay, setMultiDay] = useState(
|
||||||
const [minPeople, setMinPeople] = useState(1)
|
editStart && editEnd ? toDateParam(editStart) !== toDateParam(editEnd) : false,
|
||||||
|
)
|
||||||
|
const [endDate, setEndDate] = useState(toDateParam(editEnd ?? editStart ?? defaultDate))
|
||||||
|
const [startTime, setStartTime] = useState(toInputTime(editStart ?? defaultStartTime))
|
||||||
|
const [endTime, setEndTime] = useState(toInputTime(editEnd ?? defaultEndTime))
|
||||||
|
const [minPeople, setMinPeople] = useState(editingEvent?.min_people ?? 1)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
// Busy/private events are always visible to the room as just "busy" (no
|
||||||
|
// details) or not visible at all — that's implied by the type, so the
|
||||||
|
// visibility choice only makes sense for proposals.
|
||||||
|
const handleTypeChange = (value: EventType) => {
|
||||||
|
setType(value)
|
||||||
|
if (value === "busy") setVisibility("busy_only")
|
||||||
|
else if (value === "private") setVisibility("private")
|
||||||
|
else setVisibility("room")
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -64,38 +88,51 @@ export function CreateEventDialog({
|
|||||||
setError("Please select a room.")
|
setError("Please select a room.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const startDate = new Date(start)
|
const startDate = new Date(`${date}T${startTime}`)
|
||||||
const endDate = new Date(end)
|
const endDate2 = new Date(`${multiDay ? endDate : date}T${endTime}`)
|
||||||
if (endDate <= startDate) {
|
if (endDate2 <= startDate) {
|
||||||
setError("End time must be after the start time.")
|
setError("End time must be after the start time.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
room_id: roomId,
|
||||||
|
type,
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
start_time: toBackendDateTime(startDate),
|
||||||
|
end_time: toBackendDateTime(endDate2),
|
||||||
|
visibility: type === "proposal" ? visibility : type === "busy" ? "busy_only" : "private",
|
||||||
|
min_people: type === "proposal" ? minPeople : 0,
|
||||||
|
} as const
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await api.createEvent({
|
if (isEditing && editingEvent) {
|
||||||
room_id: roomId,
|
await api.updateEvent(editingEvent.id, payload)
|
||||||
type,
|
} else {
|
||||||
title: title.trim(),
|
await api.createEvent({ ...payload, status: type === "proposal" ? "proposed" : "confirmed" })
|
||||||
description: description.trim(),
|
}
|
||||||
start_time: toBackendDateTime(startDate),
|
|
||||||
end_time: toBackendDateTime(endDate),
|
|
||||||
visibility,
|
|
||||||
status: type === "proposal" ? "proposed" : "confirmed",
|
|
||||||
min_people: type === "proposal" ? minPeople : 0,
|
|
||||||
})
|
|
||||||
onCreated()
|
onCreated()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : "Could not create the event.")
|
setError(
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.message
|
||||||
|
: isEditing
|
||||||
|
? "Could not save the changes."
|
||||||
|
: "Could not create the event.",
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dialogTitle = isEditing ? "Edit event" : "Create event"
|
||||||
|
|
||||||
if (rooms.length === 0) {
|
if (rooms.length === 0) {
|
||||||
return (
|
return (
|
||||||
<GlassModal title="Create event" onClose={onClose}>
|
<GlassModal title={dialogTitle} onClose={onClose}>
|
||||||
<p className="text-sm text-white/80">
|
<p className="text-sm text-white/80">
|
||||||
You need to be in a room before creating an event. Create or join a room first.
|
You need to be in a room before creating an event. Create or join a room first.
|
||||||
</p>
|
</p>
|
||||||
@@ -104,7 +141,7 @@ export function CreateEventDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassModal title="Create event" onClose={onClose}>
|
<GlassModal title={dialogTitle} onClose={onClose} maxWidth="max-w-lg">
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass} htmlFor="event-room">
|
<label className={labelClass} htmlFor="event-room">
|
||||||
@@ -152,33 +189,83 @@ export function CreateEventDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="rounded-xl border border-white/15 bg-white/5 p-3">
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass} htmlFor="event-start">
|
<label className={labelClass} htmlFor="event-date">
|
||||||
Start
|
Date
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="event-start"
|
id="event-date"
|
||||||
type="datetime-local"
|
type="date"
|
||||||
required
|
required
|
||||||
value={start}
|
value={date}
|
||||||
onChange={(e) => setStart(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setDate(e.target.value)
|
||||||
|
if (!multiDay) setEndDate(e.target.value)
|
||||||
|
}}
|
||||||
className={`${fieldClass} [color-scheme:dark]`}
|
className={`${fieldClass} [color-scheme:dark]`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className={labelClass} htmlFor="event-end">
|
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||||
End
|
<div>
|
||||||
</label>
|
<label className={labelClass} htmlFor="event-start-time">
|
||||||
<input
|
Start time
|
||||||
id="event-end"
|
</label>
|
||||||
type="datetime-local"
|
<input
|
||||||
required
|
id="event-start-time"
|
||||||
value={end}
|
type="time"
|
||||||
onChange={(e) => setEnd(e.target.value)}
|
required
|
||||||
className={`${fieldClass} [color-scheme:dark]`}
|
lang="nl-NL"
|
||||||
/>
|
value={startTime}
|
||||||
|
onChange={(e) => setStartTime(e.target.value)}
|
||||||
|
className={`${fieldClass} [color-scheme:dark]`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass} htmlFor="event-end-time">
|
||||||
|
End time
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="event-end-time"
|
||||||
|
type="time"
|
||||||
|
required
|
||||||
|
lang="nl-NL"
|
||||||
|
value={endTime}
|
||||||
|
onChange={(e) => setEndTime(e.target.value)}
|
||||||
|
className={`${fieldClass} [color-scheme:dark]`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="mt-3 flex items-center gap-2 text-xs text-white/70">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={multiDay}
|
||||||
|
onChange={(e) => {
|
||||||
|
setMultiDay(e.target.checked)
|
||||||
|
if (!e.target.checked) setEndDate(date)
|
||||||
|
}}
|
||||||
|
className="h-3.5 w-3.5 rounded border-white/30 bg-white/10"
|
||||||
|
/>
|
||||||
|
Ends on a different day
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{multiDay && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<label className={labelClass} htmlFor="event-end-date">
|
||||||
|
End date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="event-end-date"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className={`${fieldClass} [color-scheme:dark]`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -188,7 +275,7 @@ export function CreateEventDialog({
|
|||||||
<select
|
<select
|
||||||
id="event-type"
|
id="event-type"
|
||||||
value={type}
|
value={type}
|
||||||
onChange={(e) => setType(e.target.value as EventType)}
|
onChange={(e) => handleTypeChange(e.target.value as EventType)}
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
>
|
>
|
||||||
{TYPE_OPTIONS.map((o) => (
|
{TYPE_OPTIONS.map((o) => (
|
||||||
@@ -218,23 +305,25 @@ export function CreateEventDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
{type === "proposal" && (
|
||||||
<label className={labelClass} htmlFor="event-visibility">
|
<div>
|
||||||
Visibility
|
<label className={labelClass} htmlFor="event-visibility">
|
||||||
</label>
|
Visibility
|
||||||
<select
|
</label>
|
||||||
id="event-visibility"
|
<select
|
||||||
value={visibility}
|
id="event-visibility"
|
||||||
onChange={(e) => setVisibility(e.target.value as EventVisibility)}
|
value={visibility}
|
||||||
className={fieldClass}
|
onChange={(e) => setVisibility(e.target.value as EventVisibility)}
|
||||||
>
|
className={fieldClass}
|
||||||
{VISIBILITY_OPTIONS.map((o) => (
|
>
|
||||||
<option key={o.value} value={o.value} className={optionClass}>
|
{VISIBILITY_OPTIONS.map((o) => (
|
||||||
{o.label}
|
<option key={o.value} value={o.value} className={optionClass}>
|
||||||
</option>
|
{o.label}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
</div>
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-xl border border-red-400/40 bg-red-500/20 px-4 py-2.5 text-sm">
|
<div className="rounded-xl border border-red-400/40 bg-red-500/20 px-4 py-2.5 text-sm">
|
||||||
@@ -256,7 +345,7 @@ export function CreateEventDialog({
|
|||||||
className="flex items-center gap-2 rounded-xl bg-blue-500 px-5 py-2.5 text-sm font-medium transition-colors hover:bg-blue-600 disabled:opacity-70"
|
className="flex items-center gap-2 rounded-xl bg-blue-500 px-5 py-2.5 text-sm font-medium transition-colors hover:bg-blue-600 disabled:opacity-70"
|
||||||
>
|
>
|
||||||
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
Create
|
{isEditing ? "Save changes" : "Create"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Ban,
|
Ban,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
UserRound,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { GlassModal } from "./glass-modal"
|
import { GlassModal } from "./glass-modal"
|
||||||
import * as api from "@/lib/api"
|
import * as api from "@/lib/api"
|
||||||
import { ApiError } from "@/lib/api"
|
import { ApiError } from "@/lib/api"
|
||||||
|
import { useRoomMembers } from "@/lib/hooks"
|
||||||
import type { CalendarEvent, Room, SignupStatus } from "@/lib/types"
|
import type { CalendarEvent, Room, SignupStatus } from "@/lib/types"
|
||||||
import { getRoomColor } from "@/lib/room-theme"
|
import { getRoomColor } from "@/lib/room-theme"
|
||||||
import { parseDateTime, formatDayLabel, formatTime } from "@/lib/date-utils"
|
import { parseDateTime, formatDayLabel, formatTime } from "@/lib/date-utils"
|
||||||
@@ -28,6 +31,7 @@ interface EventDetailDialogProps {
|
|||||||
currentUserId: number
|
currentUserId: number
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onChanged: () => void
|
onChanged: () => void
|
||||||
|
onEdit: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_STYLES: Record<string, string> = {
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
@@ -43,6 +47,7 @@ export function EventDetailDialog({
|
|||||||
currentUserId,
|
currentUserId,
|
||||||
onClose,
|
onClose,
|
||||||
onChanged,
|
onChanged,
|
||||||
|
onEdit,
|
||||||
}: EventDetailDialogProps) {
|
}: EventDetailDialogProps) {
|
||||||
const [busy, setBusy] = useState<string | null>(null)
|
const [busy, setBusy] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -52,11 +57,16 @@ export function EventDetailDialog({
|
|||||||
["responses", event.id],
|
["responses", event.id],
|
||||||
() => api.getEventResponses(event.id),
|
() => api.getEventResponses(event.id),
|
||||||
)
|
)
|
||||||
|
const { members } = useRoomMembers(event.room_id)
|
||||||
|
|
||||||
const start = parseDateTime(event.start_time)
|
const start = parseDateTime(event.start_time)
|
||||||
const end = parseDateTime(event.end_time)
|
const end = parseDateTime(event.end_time)
|
||||||
const color = getRoomColor(event.room_id)
|
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 =
|
const canManage =
|
||||||
event.creator_id === currentUserId || room?.role === "admin" || room?.role === "owner"
|
event.creator_id === currentUserId || room?.role === "admin" || room?.role === "owner"
|
||||||
|
|
||||||
@@ -79,6 +89,20 @@ export function EventDetailDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) =>
|
const respond = (status: SignupStatus) =>
|
||||||
run(`respond-${status}`, () => api.respondToEvent(event.id, status), true)
|
run(`respond-${status}`, () => api.respondToEvent(event.id, status), true)
|
||||||
|
|
||||||
@@ -94,13 +118,15 @@ export function EventDetailDialog({
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className={`h-3 w-3 rounded-sm ${color.dot}`} />
|
<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="text-sm text-white/80">{room?.name ?? `Room #${event.room_id}`}</span>
|
||||||
<span
|
{event.type === "proposal" && (
|
||||||
className={`ml-auto rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${
|
<span
|
||||||
STATUS_STYLES[event.status] ?? STATUS_STYLES.draft
|
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>
|
{event.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 text-sm text-white/90">
|
<div className="space-y-2 text-sm text-white/90">
|
||||||
@@ -116,6 +142,10 @@ export function EventDetailDialog({
|
|||||||
<DoorClosed className="h-4 w-4 text-white/70" />
|
<DoorClosed className="h-4 w-4 text-white/70" />
|
||||||
{event.type} · {event.visibility.replace("_", " ")}
|
{event.type} · {event.visibility.replace("_", " ")}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="flex items-center gap-2">
|
||||||
|
<UserRound className="h-4 w-4 text-white/70" />
|
||||||
|
Created by {creatorLabel}
|
||||||
|
</p>
|
||||||
{event.type === "proposal" && (
|
{event.type === "proposal" && (
|
||||||
<p className="flex items-center gap-2">
|
<p className="flex items-center gap-2">
|
||||||
<Users className="h-4 w-4 text-white/70" />
|
<Users className="h-4 w-4 text-white/70" />
|
||||||
@@ -130,8 +160,8 @@ export function EventDetailDialog({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* RSVP */}
|
{/* RSVP — only relevant for proposals, busy/private have no attendees to respond */}
|
||||||
{event.status !== "cancelled" && (
|
{event.type === "proposal" && event.status !== "cancelled" && (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-sm font-medium text-white/90">Your response</p>
|
<p className="mb-2 text-sm font-medium text-white/90">Your response</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -174,22 +204,31 @@ export function EventDetailDialog({
|
|||||||
{canManage && (
|
{canManage && (
|
||||||
<div className="flex flex-col gap-3 border-t border-white/15 pt-4">
|
<div className="flex flex-col gap-3 border-t border-white/15 pt-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{event.status !== "confirmed" && event.status !== "cancelled" && (
|
<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
|
<button
|
||||||
onClick={() => run("confirm", () => api.confirmEvent(event.id))}
|
onClick={() => run("confirm", () => api.confirmEvent(event.id))}
|
||||||
disabled={busy !== null}
|
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"
|
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
|
<CheckCircle2 className="h-4 w-4" /> Confirm event
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{event.status !== "cancelled" && (
|
{event.type === "proposal" && event.status !== "cancelled" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => run("cancel", () => api.cancelEvent(event.id))}
|
onClick={() => run("cancel", () => api.cancelEvent(event.id))}
|
||||||
disabled={busy !== null}
|
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"
|
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
|
<Ban className="h-4 w-4" /> Cancel event
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -214,7 +253,7 @@ export function EventDetailDialog({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{/* Destructive action sits on top, off the "usual" spot. */}
|
{/* Destructive action sits on top, off the "usual" spot. */}
|
||||||
<button
|
<button
|
||||||
onClick={() => run("delete", () => api.deleteEvent(event.id))}
|
onClick={handleDelete}
|
||||||
disabled={busy !== null}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -239,6 +278,16 @@ export function EventDetailDialog({
|
|||||||
</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>
|
</div>
|
||||||
</GlassModal>
|
</GlassModal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface SidebarProps {
|
|||||||
onJoinRoom: () => void
|
onJoinRoom: () => void
|
||||||
user: User | null
|
user: User | null
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
|
onOpenAccountSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const WEEKDAY_MINI = ["S", "M", "T", "W", "T", "F", "S"]
|
const WEEKDAY_MINI = ["S", "M", "T", "W", "T", "F", "S"]
|
||||||
@@ -51,6 +52,7 @@ export function Sidebar({
|
|||||||
onJoinRoom,
|
onJoinRoom,
|
||||||
user,
|
user,
|
||||||
onLogout,
|
onLogout,
|
||||||
|
onOpenAccountSettings,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const [miniMonth, setMiniMonth] = useState(() => {
|
const [miniMonth, setMiniMonth] = useState(() => {
|
||||||
const d = new Date(selectedDate)
|
const d = new Date(selectedDate)
|
||||||
@@ -200,13 +202,18 @@ export function Sidebar({
|
|||||||
|
|
||||||
{/* User footer */}
|
{/* User footer */}
|
||||||
<div className="mt-4 flex items-center gap-3 border-t border-white/20 pt-4">
|
<div className="mt-4 flex items-center gap-3 border-t border-white/20 pt-4">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-blue-500 font-bold text-white">
|
<button
|
||||||
{user?.username?.charAt(0).toUpperCase() ?? "U"}
|
onClick={onOpenAccountSettings}
|
||||||
</div>
|
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg p-1 text-left transition-colors hover:bg-white/10"
|
||||||
<div className="min-w-0 flex-1">
|
>
|
||||||
<p className="truncate text-sm font-medium text-white">{user?.username}</p>
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-blue-500 font-bold text-white">
|
||||||
<p className="truncate text-xs text-white/60">{user?.email}</p>
|
{user?.username?.charAt(0).toUpperCase() ?? "U"}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-white">{user?.username}</p>
|
||||||
|
<p className="truncate text-xs text-white/60">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
className="rounded-lg p-2 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
|
className="rounded-lg p-2 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export function WeekView({ weekStart, events, myResponses, onEventClick }: WeekV
|
|||||||
<button
|
<button
|
||||||
key={pe.event.id + "-" + i}
|
key={pe.event.id + "-" + i}
|
||||||
onClick={() => onEventClick(pe.event)}
|
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} ${
|
className={`animate-fade-in absolute flex flex-col items-stretch justify-start 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.dashed ? "border border-dashed border-white/70" : ""
|
||||||
} ${style.outline ? "border-2 border-white/80" : ""} ${
|
} ${style.outline ? "border-2 border-white/80" : ""} ${
|
||||||
style.muted ? "opacity-60" : ""
|
style.muted ? "opacity-60" : ""
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ export function toInputDateTime(date: Date): string {
|
|||||||
return toBackendDateTime(date).replace(" ", "T")
|
return toBackendDateTime(date).replace(" ", "T")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For <input type="time"> values ("HH:MM").
|
||||||
|
export function toInputTime(date: Date): string {
|
||||||
|
const h = String(date.getHours()).padStart(2, "0")
|
||||||
|
const mi = String(date.getMinutes()).padStart(2, "0")
|
||||||
|
return `${h}:${mi}`
|
||||||
|
}
|
||||||
|
|
||||||
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", hour12: false })
|
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false })
|
||||||
|
|||||||
@@ -12,10 +12,15 @@ export function useRooms() {
|
|||||||
// activeRoomId === null => all rooms combined via /events
|
// activeRoomId === null => all rooms combined via /events
|
||||||
export function useEvents(activeRoomId: number | null, from?: string, to?: string) {
|
export function useEvents(activeRoomId: number | null, from?: string, to?: string) {
|
||||||
const key = activeRoomId === null ? ["events", from, to] : ["room-events", activeRoomId, from, to]
|
const key = activeRoomId === null ? ["events", from, to] : ["room-events", activeRoomId, from, to]
|
||||||
const { data, error, isLoading, mutate } = useSWR(key, () =>
|
const { data, error, isLoading, mutate } = useSWR(
|
||||||
activeRoomId === null
|
key,
|
||||||
? api.getUserEvents(from, to)
|
() =>
|
||||||
: api.getRoomEvents(activeRoomId, from, to),
|
activeRoomId === null
|
||||||
|
? api.getUserEvents(from, to)
|
||||||
|
: api.getRoomEvents(activeRoomId, from, to),
|
||||||
|
// Keep showing the previous week/room's events while the new key loads,
|
||||||
|
// instead of flashing an empty/loading state on every switch.
|
||||||
|
{ keepPreviousData: true },
|
||||||
)
|
)
|
||||||
return { events: data ?? [], error, isLoading, mutate }
|
return { events: data ?? [], error, isLoading, mutate }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
### Create event
|
### Create event
|
||||||
- [x] Different colors for different events
|
- [x] Different colors for different events
|
||||||
- [ ] Make the create-event popup slightly wider
|
- [x] Make the create-event popup slightly wider
|
||||||
- [ ] Improve the placement of the time fields
|
- [x] Improve the placement of the time fields
|
||||||
- [ ] Start time should default to the current time
|
- [x] Start time should default to the current time
|
||||||
|
|
||||||
### Week view
|
### Week view
|
||||||
- [ ] Events longer than 1 hour should have their title at the top, not vertically centered
|
- [x] Events longer than 1 hour should have their title at the top, not vertically centered
|
||||||
- [ ] Fix the week-view flicker: the view sometimes disappears for ~10 ms
|
- [x] Fix the week-view flicker: the view sometimes disappears for ~10 ms
|
||||||
|
|
||||||
### Room customization
|
### Room customization
|
||||||
- [ ] Allow admins/owners to upload their own background image for the room
|
- [~] Allow admins/owners to upload their own background image for the room
|
||||||
- [ ] Support images up to 1080p
|
- [~] Support images up to 1080p
|
||||||
- [ ] Store and load the background image per room
|
- [~] Store and load the background image per room
|
||||||
Reference in New Issue
Block a user