diff --git a/frontend(experimental)/newchat/app/page.tsx b/frontend(experimental)/newchat/app/page.tsx index 5238efe..9549c74 100644 --- a/frontend(experimental)/newchat/app/page.tsx +++ b/frontend(experimental)/newchat/app/page.tsx @@ -12,6 +12,7 @@ import { CreateEventDialog } from "@/components/create-event-dialog" import { EventDetailDialog } from "@/components/event-detail-dialog" import { CreateRoomDialog, JoinRoomDialog } from "@/components/room-dialogs" import { RoomSettingsDialog } from "@/components/room-settings-dialog" +import { AccountSettingsDialog } from "@/components/account-settings-dialog" import { RoomBackground } from "@/components/room-background" import { getRoomBackground } from "@/lib/room-theme" import { addDays, startOfWeek, toDateParam, formatMonthYear } from "@/lib/date-utils" @@ -20,10 +21,12 @@ import type { CalendarEvent, User } from "@/lib/types" type DialogState = | { type: "none" } | { type: "create-event" } + | { type: "edit-event"; event: CalendarEvent } | { type: "event-detail"; event: CalendarEvent } | { type: "create-room" } | { type: "join-room" } | { type: "room-settings" } + | { type: "account-settings" } export default function Home() { const { user, loading, logout } = useAuth() @@ -105,6 +108,7 @@ function CalendarApp({ user, onLogout }: { user: User; onLogout: () => void }) { onJoinRoom={() => setDialog({ type: "join-room" })} user={user} onLogout={onLogout} + onOpenAccountSettings={() => setDialog({ type: "account-settings" })} /> @@ -162,11 +166,12 @@ function CalendarApp({ user, onLogout }: { user: User; onLogout: () => void }) { - {dialog.type === "create-event" && ( + {(dialog.type === "create-event" || dialog.type === "edit-event") && ( setDialog({ type: "none" })} onCreated={() => { setDialog({ type: "none" }) @@ -182,6 +187,7 @@ function CalendarApp({ user, onLogout }: { user: User; onLogout: () => void }) { currentUserId={user.id} onClose={() => setDialog({ type: "none" })} 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" && ( + setDialog({ type: "none" })} + onLogout={onLogout} + /> + )} ) } \ No newline at end of file diff --git a/frontend(experimental)/newchat/components/account-settings-dialog.tsx b/frontend(experimental)/newchat/components/account-settings-dialog.tsx new file mode 100644 index 0000000..053309c --- /dev/null +++ b/frontend(experimental)/newchat/components/account-settings-dialog.tsx @@ -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 ( + +
+
+
+ {user.username.charAt(0).toUpperCase()} +
+
+

{user.username}

+

{user.email ?? "No email on file"}

+
+
+ +
+ +
+ + {user.username} +
+
+ +
+ +
+ + {user.email ?? "—"} +
+ {user.email && ( +

+ {user.email_verified ? ( + <> + Verified + + ) : ( + <> + Not verified + + )} +

+ )} +
+ +
+ + + +
+
+
+ ) +} diff --git a/frontend(experimental)/newchat/components/create-event-dialog.tsx b/frontend(experimental)/newchat/components/create-event-dialog.tsx index 385f513..214f9ee 100644 --- a/frontend(experimental)/newchat/components/create-event-dialog.tsx +++ b/frontend(experimental)/newchat/components/create-event-dialog.tsx @@ -6,21 +6,23 @@ import { Loader2 } from "lucide-react" import { GlassModal, fieldClass, labelClass } from "./glass-modal" import * as api from "@/lib/api" import { ApiError } from "@/lib/api" -import type { EventType, EventVisibility, Room } from "@/lib/types" -import { toBackendDateTime, toInputDateTime } from "@/lib/date-utils" +import type { CalendarEvent, EventType, EventVisibility, Room } from "@/lib/types" +import { parseDateTime, toBackendDateTime, toDateParam, toInputTime } from "@/lib/date-utils" interface CreateEventDialogProps { rooms: Room[] defaultRoomId: number | null defaultDate: Date + /** When set, the dialog edits this event instead of creating a new one. */ + editingEvent?: CalendarEvent onClose: () => void onCreated: () => void } const TYPE_OPTIONS: { value: EventType; label: string }[] = [ { value: "proposal", label: "Proposal (members vote to confirm)" }, - { value: "busy", label: "Busy (blocked time)" }, - { value: "private", label: "Private" }, + { value: "busy", label: "Busy — room sees it as busy, no details" }, + { value: "private", label: "Private — hidden from the room" }, ] const VISIBILITY_OPTIONS: { value: EventVisibility; label: string }[] = [ @@ -35,27 +37,49 @@ export function CreateEventDialog({ rooms, defaultRoomId, defaultDate, + editingEvent, onClose, onCreated, }: 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) - defaultStart.setHours(9, 0, 0, 0) - const defaultEnd = new Date(defaultDate) - defaultEnd.setHours(10, 0, 0, 0) + // Start time defaults to "now", rounded up to the next quarter hour. + const now = new Date() + const defaultStartTime = new Date(now) + 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(initialRoom) - const [title, setTitle] = useState("") - const [description, setDescription] = useState("") - const [type, setType] = useState("proposal") - const [visibility, setVisibility] = useState("room") - const [start, setStart] = useState(toInputDateTime(defaultStart)) - const [end, setEnd] = useState(toInputDateTime(defaultEnd)) - const [minPeople, setMinPeople] = useState(1) + const [title, setTitle] = useState(editingEvent?.title ?? "") + const [description, setDescription] = useState(editingEvent?.description ?? "") + const [type, setType] = useState(editingEvent?.type ?? "proposal") + const [visibility, setVisibility] = useState(editingEvent?.visibility ?? "room") + const [date, setDate] = useState(toDateParam(editStart ?? defaultDate)) + const [multiDay, setMultiDay] = useState( + 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(null) 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) => { e.preventDefault() setError(null) @@ -64,38 +88,51 @@ export function CreateEventDialog({ setError("Please select a room.") return } - const startDate = new Date(start) - const endDate = new Date(end) - if (endDate <= startDate) { + const startDate = new Date(`${date}T${startTime}`) + const endDate2 = new Date(`${multiDay ? endDate : date}T${endTime}`) + if (endDate2 <= startDate) { setError("End time must be after the start time.") 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) try { - await api.createEvent({ - room_id: roomId, - type, - title: title.trim(), - description: description.trim(), - start_time: toBackendDateTime(startDate), - end_time: toBackendDateTime(endDate), - visibility, - status: type === "proposal" ? "proposed" : "confirmed", - min_people: type === "proposal" ? minPeople : 0, - }) + if (isEditing && editingEvent) { + await api.updateEvent(editingEvent.id, payload) + } else { + await api.createEvent({ ...payload, status: type === "proposal" ? "proposed" : "confirmed" }) + } onCreated() onClose() } 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 { setSubmitting(false) } } + const dialogTitle = isEditing ? "Edit event" : "Create event" + if (rooms.length === 0) { return ( - +

You need to be in a room before creating an event. Create or join a room first.

@@ -104,7 +141,7 @@ export function CreateEventDialog({ } return ( - +
-
+
-
-
- - setEnd(e.target.value)} - className={`${fieldClass} [color-scheme:dark]`} - /> + +
+
+ + setStartTime(e.target.value)} + className={`${fieldClass} [color-scheme:dark]`} + /> +
+
+ + setEndTime(e.target.value)} + className={`${fieldClass} [color-scheme:dark]`} + /> +
+ + + + {multiDay && ( +
+ + setEndDate(e.target.value)} + className={`${fieldClass} [color-scheme:dark]`} + /> +
+ )}
@@ -188,7 +275,7 @@ export function CreateEventDialog({ setVisibility(e.target.value as EventVisibility)} - className={fieldClass} - > - {VISIBILITY_OPTIONS.map((o) => ( - - ))} - -
+ {type === "proposal" && ( +
+ + +
+ )} {error && (
@@ -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" > {submitting && } - Create + {isEditing ? "Save changes" : "Create"}
diff --git a/frontend(experimental)/newchat/components/event-detail-dialog.tsx b/frontend(experimental)/newchat/components/event-detail-dialog.tsx index fa3bcd4..9b800c2 100644 --- a/frontend(experimental)/newchat/components/event-detail-dialog.tsx +++ b/frontend(experimental)/newchat/components/event-detail-dialog.tsx @@ -14,10 +14,13 @@ import { 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" @@ -28,6 +31,7 @@ interface EventDetailDialogProps { currentUserId: number onClose: () => void onChanged: () => void + onEdit: () => void } const STATUS_STYLES: Record = { @@ -43,6 +47,7 @@ export function EventDetailDialog({ currentUserId, onClose, onChanged, + onEdit, }: EventDetailDialogProps) { const [busy, setBusy] = useState(null) const [error, setError] = useState(null) @@ -52,11 +57,16 @@ export function EventDetailDialog({ ["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" @@ -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) => run(`respond-${status}`, () => api.respondToEvent(event.id, status), true) @@ -94,13 +118,15 @@ export function EventDetailDialog({
{room?.name ?? `Room #${event.room_id}`} - - {event.status} - + {event.type === "proposal" && ( + + {event.status} + + )}
@@ -116,6 +142,10 @@ export function EventDetailDialog({ {event.type} · {event.visibility.replace("_", " ")}

+

+ + Created by {creatorLabel} +

{event.type === "proposal" && (

@@ -130,8 +160,8 @@ export function EventDetailDialog({

)} - {/* RSVP */} - {event.status !== "cancelled" && ( + {/* RSVP — only relevant for proposals, busy/private have no attendees to respond */} + {event.type === "proposal" && event.status !== "cancelled" && (

Your response

@@ -174,22 +204,31 @@ export function EventDetailDialog({ {canManage && (
- {event.status !== "confirmed" && event.status !== "cancelled" && ( + + {/* 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" && ( )} - {event.status !== "cancelled" && ( + {event.type === "proposal" && event.status !== "cancelled" && ( )}
@@ -214,7 +253,7 @@ export function EventDetailDialog({
{/* Destructive action sits on top, off the "usual" spot. */}
)} + + {/* Always-available close action, bottom right. */} +
+ +
) diff --git a/frontend(experimental)/newchat/components/sidebar.tsx b/frontend(experimental)/newchat/components/sidebar.tsx index 0f6ced2..962ce7d 100644 --- a/frontend(experimental)/newchat/components/sidebar.tsx +++ b/frontend(experimental)/newchat/components/sidebar.tsx @@ -28,6 +28,7 @@ interface SidebarProps { onJoinRoom: () => void user: User | null onLogout: () => void + onOpenAccountSettings: () => void } const WEEKDAY_MINI = ["S", "M", "T", "W", "T", "F", "S"] @@ -51,6 +52,7 @@ export function Sidebar({ onJoinRoom, user, onLogout, + onOpenAccountSettings, }: SidebarProps) { const [miniMonth, setMiniMonth] = useState(() => { const d = new Date(selectedDate) @@ -200,13 +202,18 @@ export function Sidebar({ {/* User footer */}
-
- {user?.username?.charAt(0).toUpperCase() ?? "U"} -
-
-

{user?.username}

-

{user?.email}

-
+