From ec80f8dca474280f624794e1a4a504b2e5c82348 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 6 Aug 2026 18:23:39 +0200 Subject: [PATCH] updates js/css/html --- app.js | 1440 ++++++++++++++++++++++++++++++---------------------- index.html | 28 +- style.css | 382 +++++++++++++- 3 files changed, 1229 insertions(+), 621 deletions(-) diff --git a/app.js b/app.js index ae4c3f2..c43f528 100644 --- a/app.js +++ b/app.js @@ -1,30 +1,19 @@ -const API_BASE = - "/api"; - +const API_BASE = "/api"; function getToken(){ - return localStorage.getItem("token"); - } - function setToken(token){ - localStorage.setItem("token", token); - } - function clearToken(){ - localStorage.removeItem("token"); - } - async function apiFetch(path, options = {}){ let headers = @@ -32,81 +21,76 @@ async function apiFetch(path, options = {}){ ? { ...options.headers } : {}; - headers["Content-Type"] = "application/json"; - - let token = - getToken(); - + let token = getToken(); if(token){ - headers["Authorization"] = "Bearer " + token; - } - let response = await fetch( API_BASE + path, - { - ...options, - headers - } + { ...options, headers } ); - if(response.status === 401){ - clearToken(); - showLogin(); - throw new Error("not authenticated"); - } - let data = - await response.json() - .catch(() => null); - + await response.json().catch(() => null); if(!response.ok){ - let message = (data && data.detail) - ? data.detail + ? (typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail)) : "request failed"; - throw new Error(message); - } - return data; - } +/* small helpers */ + +function escapeHtml(value){ + return String(value == null ? "" : value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function toBackendTime(localValue){ + // datetime-local gives "YYYY-MM-DDTHH:MM" -> "YYYY-MM-DD HH:MM:00" + if(!localValue){ + return null; + } + let value = localValue.replace("T", " "); + if(value.length === 16){ + value += ":00"; + } + return value; +} + // ---------- auth ---------- function showLogin(){ - document.getElementById("app").style.display = "none"; document.getElementById("login-screen").style.display = "flex"; - } - function showApp(){ - document.getElementById("login-screen").style.display = "none"; document.getElementById("app").style.display = "grid"; - } @@ -114,52 +98,31 @@ async function handleLogin(event){ event.preventDefault(); - - let username = - document.getElementById("login-username").value; - - let password = - document.getElementById("login-password").value; - - let errorBox = - document.getElementById("login-error"); - + let username = document.getElementById("login-username").value; + let password = document.getElementById("login-password").value; + let errorBox = document.getElementById("login-error"); errorBox.textContent = ""; - try{ - let result = - await apiFetch( - "/login", - { - method: "POST", - body: JSON.stringify({ username, password }) - } - ); - + await apiFetch("/login", { + method: "POST", + body: JSON.stringify({ username, password }) + }); if(result.status !== "success"){ - errorBox.textContent = "invalid username or password"; return; - } - setToken(result.access_token); - showApp(); - showPage("dashboard"); } catch(error){ - errorBox.textContent = error.message; - } - } @@ -167,62 +130,71 @@ async function handleRegister(event){ event.preventDefault(); - - let username = - document.getElementById("login-username").value; - - let password = - document.getElementById("login-password").value; - - let errorBox = - document.getElementById("login-error"); - + let username = document.getElementById("login-username").value; + let password = document.getElementById("login-password").value; + let errorBox = document.getElementById("login-error"); errorBox.textContent = ""; - try{ + await apiFetch("/create-user", { + method: "POST", + body: JSON.stringify({ username, password }) + }); - await apiFetch( - "/create-user", - { - method: "POST", - body: JSON.stringify({ username, password }) - } - ); - - - errorBox.style.color = "green"; + errorBox.classList.add("success"); + errorBox.classList.remove("error"); errorBox.textContent = "account created, you can log in now"; } catch(error){ - - errorBox.style.color = ""; + errorBox.classList.remove("success"); + errorBox.classList.add("error"); errorBox.textContent = error.message; - } - } function logout(){ - clearToken(); - showLogin(); - } +// ---------- shared state ---------- + + +let calendarWeekOffset = 0; +let cachedDashboardEvents = []; +let cachedRooms = []; +let currentUserId = null; +let currentUsername = null; + + +async function ensureMe(){ + if(currentUserId !== null){ + return; + } + let me = await apiFetch("/me"); + currentUserId = me.id; + currentUsername = me.username; +} + +async function ensureRooms(force){ + if(cachedRooms.length && !force){ + return cachedRooms; + } + let roomsData = await apiFetch("/rooms"); + cachedRooms = roomsData.rooms || []; + return cachedRooms; +} + // ---------- page router ---------- function showPage(page){ - let content = - document.getElementById("content"); - + let content = document.getElementById("content"); document.querySelectorAll("nav a") .forEach(link => link.classList.remove("active")); @@ -231,115 +203,50 @@ function showPage(page){ document.querySelector(`nav a[data-page="${page}"]`); if(activeLink){ - activeLink.classList.add("active"); - - } - - - if(page === "dashboard"){ - - loadDashboard(); - - } - - - if(page === "rooms"){ - - loadRooms(); - - } - - - if(page === "events"){ - - loadEvents(); - - } - - - if(page === "settings"){ - - content.innerHTML = ` -

Settings

- -
- Account preferences. -

- -
- `; - } + if(page === "dashboard"){ loadDashboard(); } + if(page === "rooms"){ loadRooms(); } + if(page === "events"){ loadEvents(); } + if(page === "settings"){ loadSettings(); } } - // ---------- dashboard ---------- -let calendarWeekOffset = 0; - -let cachedDashboardEvents = []; - -let currentUserId = null; - - async function loadDashboard(){ - let content = - document.getElementById("content"); - + let content = document.getElementById("content"); content.innerHTML = `

Dashboard

Loading...
`; - calendarWeekOffset = 0; - try{ + await ensureMe(); + let rooms = await ensureRooms(true); - let me = - await apiFetch("/me"); - - currentUserId = me.id; - - let roomsData = - await apiFetch("/rooms"); - - let rooms = - roomsData.rooms || []; - - - let eventsData = - await apiFetch("/events"); - - cachedDashboardEvents = - eventsData.events || []; - + let eventsData = await apiFetch("/events"); + cachedDashboardEvents = eventsData.events || []; let roomsHtml = rooms.length ? rooms.map(room => ` -
- ${room.room_name} -
+
${escapeHtml(room.room_name)}
`).join("") : `

You have no rooms yet.

`; - content.innerHTML = `

Dashboard

-

Welcome back, ${me.username}

-

- Overview of your calendar. -

+

Welcome back, ${escapeHtml(currentUsername)}

+

Overview of your calendar.

@@ -353,59 +260,33 @@ async function loadDashboard(){

Create a room

-
- - - - - - - + + + + +
-

Join a room

-
- - - + + -
-

`; } catch(error){ - content.innerHTML = `

Dashboard

-
Could not load dashboard: ${error.message}
+
Could not load dashboard: ${escapeHtml(error.message)}
`; - } - } @@ -413,41 +294,23 @@ async function handleCreateRoom(event){ event.preventDefault(); - - let roomName = - document.getElementById("create-room-name").value; - - let inviteCode = - document.getElementById("create-room-code").value; - - let messageBox = - document.getElementById("create-room-message"); - + let roomName = document.getElementById("create-room-name").value; + let inviteCode = document.getElementById("create-room-code").value; + let messageBox = document.getElementById("create-room-message"); try{ - - await apiFetch( - "/create-room", - { - method: "POST", - body: JSON.stringify({ - room_name: roomName, - invite_code: inviteCode - }) - } - ); - + await apiFetch("/create-room", { + method: "POST", + body: JSON.stringify({ room_name: roomName, invite_code: inviteCode }) + }); messageBox.textContent = "room created"; - + await ensureRooms(true); loadDashboard(); } catch(error){ - messageBox.textContent = error.message; - } - } @@ -455,116 +318,90 @@ async function handleJoinRoom(event){ event.preventDefault(); - - let inviteCode = - document.getElementById("join-room-code").value; - - let messageBox = - document.getElementById("join-room-message"); - + let inviteCode = document.getElementById("join-room-code").value; + let messageBox = document.getElementById("join-room-message"); try{ - - await apiFetch( - "/join-room", - { - method: "POST", - body: JSON.stringify({ invite_code: inviteCode }) - } - ); - + await apiFetch("/join-room", { + method: "POST", + body: JSON.stringify({ invite_code: inviteCode }) + }); messageBox.textContent = "joined room"; - + await ensureRooms(true); loadDashboard(); } catch(error){ - messageBox.textContent = error.message; - } - } - // ---------- calendar (week view) ---------- const CALENDAR_SLOT_HOURS = 2; const CALENDAR_SLOT_HEIGHT = 48; - const CALENDAR_START_HOUR = 0; - const CALENDAR_END_HOUR = 24; const PIXELS_PER_MINUTE = CALENDAR_SLOT_HEIGHT / (CALENDAR_SLOT_HOURS * 60); + function isEventCancelled(event){ - - let status = - (event.status || "").toLowerCase(); - + let status = (event.status || "").toLowerCase(); return ( status === "cancelled" || status === "canceled" || status === "declined" ); - } function getEventColorClass(event, currentUserId){ - let isOwnEvent = - event.creator_id === currentUserId; - - let isPrivateOther = - event.visibility === "private" && !isOwnEvent; - + let isOwnEvent = event.creator_id === currentUserId; + let isPrivateOther = event.visibility === "private" && !isOwnEvent; if(isPrivateOther){ - return "event-private"; - } + let status = (event.status || "").toLowerCase(); + let type = (event.type || "").toLowerCase(); - if((event.status || "").toLowerCase() === "confirmed"){ + if(status === "proposed"){ + return "event-proposed"; + } + if(type === "busy"){ + return "event-busy"; + } + + if(status === "confirmed"){ return "event-confirmed"; - } - return "event-busy"; - } function getWeekStart(offset){ - let today = - new Date(); + let today = new Date(); - let base = - new Date( - today.getFullYear(), - today.getMonth(), - today.getDate() + (offset * 7) - ); - - let dayOfWeek = - base.getDay(); - - let mondayDiff = - (dayOfWeek === 0) ? -6 : (1 - dayOfWeek); + let base = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + (offset * 7) + ); + let dayOfWeek = base.getDay(); + let mondayDiff = (dayOfWeek === 0) ? -6 : (1 - dayOfWeek); base.setDate(base.getDate() + mondayDiff); return base; - } @@ -572,561 +409,969 @@ function changeCalendarWeek(delta){ calendarWeekOffset += delta; - - let calendarCard = - document.getElementById("calendar-card"); + let calendarCard = document.getElementById("calendar-card"); if(calendarCard){ - calendarCard.innerHTML = buildWeekCalendarHtml(cachedDashboardEvents, calendarWeekOffset); - } - } function buildWeekCalendarHtml(events, offset){ - let weekStart = - getWeekStart(offset); - - let today = - new Date(); - - let dayNames = - ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; - + let weekStart = getWeekStart(offset); + let today = new Date(); + let dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; let days = []; - for(let i = 0; i < 7; i++){ - - let d = - new Date(weekStart); - + let d = new Date(weekStart); d.setDate(weekStart.getDate() + i); - days.push(d); - } - let rangeLabel = days[0].toLocaleDateString("default", { day: "numeric", month: "short" }) + " - " + days[6].toLocaleDateString("default", { day: "numeric", month: "short", year: "numeric" }); - - let totalMinutes = - (CALENDAR_END_HOUR - CALENDAR_START_HOUR) * 60; + let totalMinutes = (CALENDAR_END_HOUR - CALENDAR_START_HOUR) * 60; let trackHeight = ((CALENDAR_END_HOUR - CALENDAR_START_HOUR) / CALENDAR_SLOT_HOURS) * CALENDAR_SLOT_HEIGHT; - // time gutter - let hourRows = ""; - - for( - let h = CALENDAR_START_HOUR; - h < CALENDAR_END_HOUR; - h += CALENDAR_SLOT_HOURS - ){ - + for(let h = CALENDAR_START_HOUR; h < CALENDAR_END_HOUR; h += CALENDAR_SLOT_HOURS){ hourRows += ` -
+
${String(h).padStart(2, "0")}:00
`; - } - // day columns - let dayColumns = days.map(day => { let dayEvents = events.filter(event => { - if(!event.start_time || isEventCancelled(event)){ - return false; - } - - let eventStart = - new Date(event.start_time); - + let eventStart = new Date(event.start_time); return ( eventStart.getFullYear() === day.getFullYear() && eventStart.getMonth() === day.getMonth() && eventStart.getDate() === day.getDate() ); - }) .sort((a, b) => new Date(a.start_time) - new Date(b.start_time)); - - // assign overlap layers: an event gets the lowest layer - // whose most recent block has already ended - let layerEnds = []; let placed = dayEvents.map(event => { - function parseLocalDate(dateString){ - let parts = dateString.split(/[- :]/); + function parseLocalDate(dateString){ + let parts = dateString.split(/[- :T]/); return new Date( - parts[0], - parts[1] - 1, - parts[2], - parts[3], - parts[4] + parts[0], parts[1] - 1, parts[2], + parts[3] || 0, parts[4] || 0 ); } let start = parseLocalDate(event.start_time); - let end = event.end_time ? parseLocalDate(event.end_time) : new Date(start.getTime() + 30 * 60000); - let startMinutes = - start.getHours() * 60 + start.getMinutes(); - - let endMinutes = - Math.max( - startMinutes + 15, - end.getHours() * 60 + end.getMinutes() - ); - + let startMinutes = start.getHours() * 60 + start.getMinutes(); + let endMinutes = Math.max( + startMinutes + 15, + end.getHours() * 60 + end.getMinutes() + ); let layer = 0; - while(layerEnds[layer] !== undefined && layerEnds[layer] > startMinutes){ - layer++; - } - layerEnds[layer] = endMinutes; - return { event, startMinutes, endMinutes, layer }; - }); - - let maxLayer = - placed.reduce((m, p) => Math.max(m, p.layer), 0); - + let maxLayer = placed.reduce((m, p) => Math.max(m, p.layer), 0); let blocksHtml = placed.map(p => { - let topPx = - (p.startMinutes - CALENDAR_START_HOUR * 60) - * PIXELS_PER_MINUTE; + let topPx = (p.startMinutes - CALENDAR_START_HOUR * 60) * PIXELS_PER_MINUTE; + let heightPx = (p.endMinutes - p.startMinutes) * PIXELS_PER_MINUTE; + let leftPct = p.layer * 14; + let widthPct = 100 - (maxLayer * 14); - - let heightPx = - (p.endMinutes - p.startMinutes) - * PIXELS_PER_MINUTE; - - let heightPct = - ((p.endMinutes - p.startMinutes) / totalMinutes) * 100; - - let leftPct = - p.layer * 14; - - let widthPct = - 100 - (maxLayer * 14); - - let colorClass = - getEventColorClass(p.event, currentUserId); + let colorClass = getEventColorClass(p.event, currentUserId); let timeLabel = new Date(p.event.start_time) .toLocaleTimeString("nl-NL", { - hour: "2-digit", - minute: "2-digit", - hour12: false + hour: "2-digit", minute: "2-digit", hour12: false }); - - console.log({ - title: p.event.title, - startMinutes: p.startMinutes, - endMinutes: p.endMinutes, - topPx, - heightPx - }); + return `
${timeLabel} - ${p.event.title} + ${escapeHtml(p.event.title)}
`; - }).join(""); - let isToday = - ( - day.getFullYear() === today.getFullYear() - && day.getMonth() === today.getMonth() - && day.getDate() === today.getDate() - ); - + day.getFullYear() === today.getFullYear() + && day.getMonth() === today.getMonth() + && day.getDate() === today.getDate(); return `
-
${dayNames[days.indexOf(day)]}
${day.getDate()}
-
${blocksHtml}
-
`; - }).join(""); - return `
- - - +

${rangeLabel}

- - - +
- - - confirmed - - - - busy - - - - private (others) - - + confirmed + proposed + busy + private (others)
-
-
- ${hourRows} -
+
${hourRows}
- -
- ${dayColumns} -
- +
${dayColumns}
`; - } - // ---------- rooms page ---------- async function loadRooms(){ - let content = - document.getElementById("content"); - + let content = document.getElementById("content"); content.innerHTML = `

Rooms

Loading...
`; - try{ + await ensureMe(); + let rooms = await ensureRooms(true); - let roomsData = - await apiFetch("/rooms"); - - let rooms = - roomsData.rooms || []; - + let searchCard = ` +
+

Find a room by invite code

+
+ + + +
+
+
+ `; if(rooms.length === 0){ - content.innerHTML = `

Rooms

-
- Your shared rooms appear here. -
+ ${searchCard} +
Your shared rooms appear here.
`; - return; - } - let roomsHtml = - rooms.map(room => ` + rooms.map(room => { + let isOwner = room.owner_id === currentUserId; + return `
-

${room.room_name}

-

invite code: ${room.invite_code || "-"}

- +
+

${escapeHtml(room.room_name)}

+ ${isOwner ? `owner` : ""} +
+

invite code: ${escapeHtml(room.invite_code || "-")}

+
+
+ + + ${isOwner + ? `` + : ``} +
+

- `).join(""); - + `; + }).join(""); content.innerHTML = `

Rooms

+ ${searchCard} ${roomsHtml} `; } catch(error){ - content.innerHTML = `

Rooms

-
Could not load rooms: ${error.message}
+
Could not load rooms: ${escapeHtml(error.message)}
+ `; + } +} + + +async function handleSearchRoom(event){ + + event.preventDefault(); + + let inviteCode = document.getElementById("search-room-code").value; + let resultBox = document.getElementById("search-room-result"); + + resultBox.textContent = "searching..."; + + try{ + let data = await apiFetch("/search-room", { + method: "POST", + body: JSON.stringify({ invite_code: inviteCode }) + }); + + let room = data.room || data; + + if(!room || (!room.room_name && !room.id)){ + resultBox.textContent = "no room found for that code"; + return; + } + + resultBox.innerHTML = ` +
+ ${escapeHtml(room.room_name || "room")} +
+ +
+
`; + } catch(error){ + resultBox.textContent = error.message; } +} + +async function handleJoinRoomByCode(inviteCode){ + try{ + await apiFetch("/join-room", { + method: "POST", + body: JSON.stringify({ invite_code: inviteCode }) + }); + await ensureRooms(true); + loadRooms(); + } catch(error){ + let resultBox = document.getElementById("search-room-result"); + if(resultBox){ resultBox.textContent = error.message; } + } +} + + +async function showRoomMembers(roomId){ + + let container = document.getElementById(`room-members-${roomId}`); + if(!container){ return; } + + container.innerHTML = `loading members...`; + + try{ + let data = await apiFetch(`/rooms/${roomId}/members`); + let members = data.members || []; + + if(members.length === 0){ + container.innerHTML = `no members`; + return; + } + + container.innerHTML = + members.map(m => ` + ${escapeHtml(m.username || ("user " + m.id))} + `).join(""); + + } catch(error){ + container.innerHTML = `${escapeHtml(error.message)}`; + } +} + + +async function handleLeaveRoom(roomId){ + + let messageBox = document.getElementById(`room-message-${roomId}`); + + try{ + await apiFetch(`/rooms/${roomId}/members/me`, { method: "DELETE" }); + await ensureRooms(true); + loadRooms(); + } catch(error){ + if(messageBox){ messageBox.textContent = error.message; } + } +} + + +async function handleDeleteRoom(roomId){ + + let messageBox = document.getElementById(`room-message-${roomId}`); + + try{ + await apiFetch(`/room/${roomId}`, { method: "DELETE" }); + await ensureRooms(true); + loadRooms(); + } catch(error){ + if(messageBox){ messageBox.textContent = error.message; } + } } async function loadRoomEvents(roomId){ - let content = - document.getElementById("content"); - + let content = document.getElementById("content"); content.innerHTML = `

Room events

Loading...
`; - try{ - - let data = - await apiFetch(`/rooms/${roomId}/events`); - - let events = - data.events || []; - + await ensureMe(); + let data = await apiFetch(`/rooms/${roomId}/events`); + let events = data.events || []; content.innerHTML = `

Room events

+
+ +
${renderEventCards(events)} `; } catch(error){ - content.innerHTML = `

Room events

-
Could not load events: ${error.message}
+
Could not load events: ${escapeHtml(error.message)}
`; - } - } - // ---------- events page ---------- +// kind -> backend fields mapping +const EVENT_KINDS = { + confirmed: { type: "event", visibility: "public", status: "confirmed", label: "Confirmed event", desc: "A regular event everyone can see." }, + proposed: { type: "event", visibility: "public", status: "proposed", label: "Propose event", desc: "Suggest a time and collect responses." }, + busy: { type: "busy", visibility: "public", status: "confirmed", label: "Busy", desc: "Block time without sharing details." }, + private: { type: "private", visibility: "private", status: "confirmed", label: "Private", desc: "Only you can see the details." } +}; + + async function loadEvents(){ - let content = - document.getElementById("content"); - + let content = document.getElementById("content"); content.innerHTML = `

Events

Loading...
`; - try{ + await ensureMe(); + let rooms = await ensureRooms(); - let data = - await apiFetch("/events"); - - let events = - data.events || []; - + let data = await apiFetch("/events"); + let events = data.events || []; content.innerHTML = `

Events

+ ${renderCreateEventCard(rooms)} +

Upcoming events

${renderEventCards(events)} `; } catch(error){ - content.innerHTML = `

Events

-
Could not load events: ${error.message}
+
Could not load events: ${escapeHtml(error.message)}
`; + } +} + +function renderCreateEventCard(rooms){ + + let roomOptions = + rooms.length + ? rooms.map(r => ``).join("") + : ``; + + let kindOptions = + Object.keys(EVENT_KINDS).map((key, index) => { + let k = EVENT_KINDS[key]; + return ` + + `; + }).join(""); + + return ` +
+

Create an event

+
+ + +
${kindOptions}
+ + + + + + + + + + +
+
+ + +
+
+ + +
+
+ + + + +
+

+
+ `; +} + + +function onEventKindChange(){ + + let selected = + document.querySelector('input[name="event-kind"]:checked'); + + let wrap = document.getElementById("event-minpeople-wrap"); + + if(!selected || !wrap){ return; } + + // min people is only meaningful when proposing + wrap.style.display = (selected.value === "proposed") ? "block" : "none"; +} + + +async function handleCreateEvent(event){ + + event.preventDefault(); + + let messageBox = document.getElementById("create-event-message"); + messageBox.classList.remove("success", "error"); + + let kindKey = + (document.querySelector('input[name="event-kind"]:checked') || {}).value || "confirmed"; + + let kind = EVENT_KINDS[kindKey]; + + let roomId = document.getElementById("event-room").value; + let title = document.getElementById("event-title").value; + let description = document.getElementById("event-description").value; + let start = toBackendTime(document.getElementById("event-start").value); + let end = toBackendTime(document.getElementById("event-end").value); + + let minPeople = 0; + if(kindKey === "proposed"){ + minPeople = parseInt(document.getElementById("event-minpeople").value, 10) || 0; } + if(!roomId){ + messageBox.classList.add("error"); + messageBox.textContent = "please create or join a room first"; + return; + } + + let payload = { + room_id: parseInt(roomId, 10), + type: kind.type, + title: title, + description: description, + start_time: start, + end_time: end, + visibility: kind.visibility, + status: kind.status, + min_people: minPeople + }; + + try{ + await apiFetch("/events", { + method: "POST", + body: JSON.stringify(payload) + }); + + messageBox.classList.add("success"); + messageBox.textContent = `${kind.label} created`; + + // refresh dashboard cache and reload the events list + loadEvents(); + + } catch(error){ + messageBox.classList.add("error"); + messageBox.textContent = error.message; + } +} + + +function eventBadge(event){ + + let status = (event.status || "").toLowerCase(); + let type = (event.type || "").toLowerCase(); + + if(event.visibility === "private"){ + return `private`; + } + if(status === "proposed"){ + return `proposed`; + } + if(type === "busy"){ + return `busy`; + } + return `confirmed`; } function renderEventCards(events){ if(events.length === 0){ - return `
No events scheduled.
`; - } + return events.map(event => { - return events.map(event => ` + let isOwner = event.creator_id === currentUserId; + let isProposed = (event.status || "").toLowerCase() === "proposed"; -
- -

${event.title}

- -

${event.description || ""}

- -

- ${formatEventTime(event.start_time, event.end_time)} -

- -

- status: ${event.status} -

- -
- - - - - - + return ` +
+
+

${escapeHtml(event.title)}

+ ${eventBadge(event)}
-

+

${escapeHtml(event.description || "")}

+ +

${escapeHtml(formatEventTime(event.start_time, event.end_time))}

+ + ${isProposed && event.min_people + ? `

needs at least ${event.min_people} people

` + : ""} + +
+ + + + + ${isOwner ? `` : ""} + ${isOwner ? `` : ""} +
+ +

+
+
- - `).join(""); - + `; + }).join(""); } function formatEventTime(start, end){ if(!start){ - return "time unknown"; - } + let startDate = new Date(start); + let endDate = end ? new Date(end) : null; - let startDate = - new Date(start); - - let endDate = - end - ? new Date(end) - : null; - - - let startText = - startDate.toLocaleString(); + let startText = startDate.toLocaleString(); if(!endDate){ - return startText; - } - return startText + " - " + endDate.toLocaleString(); - } async function handleRespond(eventId, status){ - let messageBox = - document.getElementById(`event-response-${eventId}`); - + let messageBox = document.getElementById(`event-response-${eventId}`); try{ - let result = - await apiFetch( - `/events/${eventId}/respond?status=${status}`, - { method: "POST" } - ); + await apiFetch(`/events/${eventId}/respond?status=${status}`, { method: "POST" }); - - messageBox.textContent = - `you responded: ${status} (${result.going} going)`; + let going = (result && result.going != null) ? result.going : "?"; + messageBox.textContent = `you responded: ${status} (${going} going)`; } catch(error){ - messageBox.textContent = error.message; - } - } +async function viewResponses(eventId){ + + let container = document.getElementById(`event-responses-${eventId}`); + if(!container){ return; } + + container.innerHTML = `

loading responses...

`; + + try{ + let data = await apiFetch(`/events/${eventId}/responses`); + let responses = data.responses || []; + + if(responses.length === 0){ + container.innerHTML = `

no responses yet

`; + return; + } + + container.innerHTML = ` +
+ ${responses.map(r => ` + + ${escapeHtml(r.username || ("user " + r.user_id))}: ${escapeHtml(r.status)} + + `).join("")} +
+ `; + + } catch(error){ + container.innerHTML = `

${escapeHtml(error.message)}

`; + } +} + + +async function toggleEditEvent(eventId){ + + let container = document.getElementById(`event-edit-${eventId}`); + if(!container){ return; } + + if(container.innerHTML.trim() !== ""){ + container.innerHTML = ""; + return; + } + + try{ + let event = await apiFetch(`/events/${eventId}`); + let ev = event.event || event; + + let startVal = (ev.start_time || "").replace(" ", "T").slice(0, 16); + let endVal = (ev.end_time || "").replace(" ", "T").slice(0, 16); + + container.innerHTML = ` +
+ + + + + + +
+
+ + +
+
+ + +
+
+ + + + + +
+

+ `; + + } catch(error){ + container.innerHTML = `

${escapeHtml(error.message)}

`; + } +} + + +async function handleUpdateEvent(event, eventId){ + + event.preventDefault(); + + let messageBox = document.getElementById(`edit-message-${eventId}`); + + let payload = { + title: document.getElementById(`edit-title-${eventId}`).value, + description: document.getElementById(`edit-desc-${eventId}`).value, + start_time: toBackendTime(document.getElementById(`edit-start-${eventId}`).value), + end_time: toBackendTime(document.getElementById(`edit-end-${eventId}`).value), + min_people: parseInt(document.getElementById(`edit-minpeople-${eventId}`).value, 10) || 0 + }; + + try{ + await apiFetch(`/events/${eventId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }); + + messageBox.classList.add("success"); + messageBox.textContent = "event updated"; + loadEvents(); + + } catch(error){ + messageBox.textContent = error.message; + } +} + + +async function handleDeleteEvent(eventId){ + + try{ + await apiFetch(`/events/${eventId}`, { method: "DELETE" }); + + let card = document.getElementById(`event-card-${eventId}`); + if(card){ card.remove(); } + + } catch(error){ + let messageBox = document.getElementById(`event-response-${eventId}`); + if(messageBox){ messageBox.textContent = error.message; } + } +} + + +// ---------- settings page ---------- + + +const A11Y_DEFAULTS = { + contrast: false, + underline: false, + reduceMotion: false, + dyslexic: false, + fontScale: "1" +}; + + +function getA11ySettings(){ + try{ + let raw = localStorage.getItem("a11y"); + if(!raw){ return { ...A11Y_DEFAULTS }; } + return { ...A11Y_DEFAULTS, ...JSON.parse(raw) }; + } catch(e){ + return { ...A11Y_DEFAULTS }; + } +} + + +function saveA11ySettings(settings){ + localStorage.setItem("a11y", JSON.stringify(settings)); +} + + +function applyA11ySettings(){ + + let s = getA11ySettings(); + let body = document.body; + + body.classList.toggle("a11y-contrast", !!s.contrast); + body.classList.toggle("a11y-underline", !!s.underline); + body.classList.toggle("a11y-reduce-motion", !!s.reduceMotion); + body.classList.toggle("a11y-dyslexic", !!s.dyslexic); + + document.documentElement.style.setProperty("--font-scale", s.fontScale || "1"); +} + + +function updateA11ySetting(key, value){ + let s = getA11ySettings(); + s[key] = value; + saveA11ySettings(s); + applyA11ySettings(); +} + + +async function loadSettings(){ + + let content = document.getElementById("content"); + let s = getA11ySettings(); + + let isDark = document.body.classList.contains("dark"); + + content.innerHTML = ` +

Settings

+ +
+

Appearance

+ +
+
+
Dark mode
+
Switch between the light paper theme and the dark theme.
+
+
+ +
+
+ +
+
+
Text size
+
Scale all text for easier reading.
+
+
+ + +
+
+
+ +
+

Accessibility

+ +
+
+
High contrast
+
Increase contrast and thicken borders for better visibility.
+
+
+ +
+
+ +
+
+
Underline links
+
Always underline links so they stand out from regular text.
+
+
+ +
+
+ +
+
+
Reduce motion
+
Turn off transitions and animations across the app.
+
+
+ +
+
+ +
+
+
Dyslexia-friendly reading
+
Use a more legible font with extra letter and line spacing.
+
+
+ +
+
+ +
+ +
+
+ +
+

Account

+

Loading account...

+ +
+ `; + + // load account info via /me + try{ + await ensureMe(); + let box = document.getElementById("settings-account"); + if(box){ + box.textContent = `Signed in as ${currentUsername} (id ${currentUserId})`; + } + } catch(e){ /* ignore */ } +} + + +function resetA11ySettings(){ + saveA11ySettings({ ...A11Y_DEFAULTS }); + applyA11ySettings(); + loadSettings(); +} + // ---------- theme ---------- @@ -1135,44 +1380,27 @@ function toggleTheme(){ document.body.classList.toggle("dark"); - let theme = - document.body.classList.contains("dark") - ? "dark" - : "light"; - - - localStorage.setItem( - "theme", - theme - ); + document.body.classList.contains("dark") ? "dark" : "light"; + localStorage.setItem("theme", theme); } - // ---------- init ---------- -let savedTheme = - localStorage.getItem("theme"); - +let savedTheme = localStorage.getItem("theme"); if(savedTheme === "dark"){ - document.body.classList.add("dark"); - } +applyA11ySettings(); if(getToken()){ - showApp(); - showPage("dashboard"); - } else { - showLogin(); - } diff --git a/index.html b/index.html index b4f3f5b..6825d6b 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,16 @@ - + + + Calendar Dashboard + + -