Files
calendar/frontend/www/app.js
T
2026-08-07 18:07:56 +02:00

1267 lines
40 KiB
JavaScript

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 = options.headers ? { ...options.headers } : {};
headers["Content-Type"] = "application/json";
let token = getToken();
if(token){
headers["Authorization"] = "Bearer " + token;
}
let response = await fetch(API_BASE + path, { ...options, headers });
if(response.status === 401){
clearToken();
showLogin();
throw new Error("not authenticated");
}
let data = await response.json().catch(() => null);
if(!response.ok){
let message = (data && data.detail) ? data.detail : "request failed";
throw new Error(message);
}
return data;
}
// Small helper: several endpoints (join-room, search-room, create event,
// delete room) return HTTP 200 with a { status: "failed" | "denied" | ... }
// body instead of a real error status. apiFetch only throws on non-2xx,
// so callers that care about these soft failures check result.status
// themselves — see handleJoinRoom, handleSearchRoom, handleCreateEvent,
// handleDeleteRoom below.
// ---------- date helpers ----------
// datetime-local inputs give "YYYY-MM-DDTHH:MM"; the backend stores
// "YYYY-MM-DD HH:MM" (see the create_event() example in events.py).
function toBackendDateTime(localValue){
return localValue ? localValue.replace("T", " ") : localValue;
}
function toDatetimeLocal(value){
if(!value) return "";
let d = new Date(value);
if(isNaN(d.getTime())) return "";
let pad = n => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// ---------- 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";
}
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");
errorBox.textContent = "";
try{
let result = 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;
}
}
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");
errorBox.textContent = "";
try{
await apiFetch("/create-user", {
method: "POST",
body: JSON.stringify({ username, password })
});
errorBox.style.color = "green";
errorBox.textContent = "account created, you can log in now";
} catch(error){
errorBox.style.color = "";
errorBox.textContent = error.message;
}
}
function logout(){
clearToken();
showLogin();
}
// ---------- page router ----------
function showPage(page){
let content = document.getElementById("content");
document.querySelectorAll("nav a").forEach(link => link.classList.remove("active"));
let activeLink = 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"){
renderSettingsPage(content);
}
}
// ---------- dashboard ----------
let calendarWeekOffset = 0;
let cachedDashboardEvents = [];
let cachedRoomsForForm = [];
let currentUserId = null;
async function loadDashboard(){
let content = document.getElementById("content");
content.innerHTML = `
<h1>Dashboard</h1>
<div class="card">Loading...</div>
`;
calendarWeekOffset = 0;
try{
let me = await apiFetch("/me");
currentUserId = me.id;
let roomsData = await apiFetch("/rooms");
let rooms = roomsData.rooms || [];
cachedRoomsForForm = rooms;
let eventsData = await apiFetch("/events");
cachedDashboardEvents = eventsData.events || [];
let roomsHtml = rooms.length
? rooms.map(room => `<div class="list-item">${room.name}</div>`).join("")
: `<p class="muted">You have no rooms yet.</p>`;
content.innerHTML = `
<h1>Dashboard</h1>
<div class="card">
<h3>Welcome back, ${me.username}</h3>
<p>Overview of your calendar.</p>
</div>
<div class="page-actions">
<button onclick="openCreateEventModal()">+ New event</button>
</div>
<div class="card" id="calendar-card">
${buildWeekCalendarHtml(cachedDashboardEvents, calendarWeekOffset)}
</div>
<div class="card">
<h3>Your rooms</h3>
${roomsHtml}
</div>
<div class="card">
<h3>Create a room</h3>
<form onsubmit="handleCreateRoom(event)">
<input type="text" id="create-room-name" placeholder="Room name" required>
<input type="text" id="create-room-code" placeholder="Invite code" required>
<button type="submit">Create room</button>
</form>
<p id="create-room-message" class="muted"></p>
</div>
<div class="card">
<h3>Join a room</h3>
<form onsubmit="handleJoinRoom(event)">
<input type="text" id="join-room-code" placeholder="Invite code" required>
<button type="submit">Join room</button>
</form>
<p id="join-room-message" class="muted"></p>
</div>
`;
} catch(error){
content.innerHTML = `
<h1>Dashboard</h1>
<div class="card">Could not load dashboard: ${error.message}</div>
`;
}
}
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");
try{
await apiFetch("/create-room", {
method: "POST",
body: JSON.stringify({ room_name: roomName, invite_code: inviteCode })
});
messageBox.textContent = "room created";
loadDashboard();
} catch(error){
messageBox.textContent = error.message;
}
}
async function handleJoinRoom(event){
event.preventDefault();
let inviteCode = document.getElementById("join-room-code").value;
let messageBox = document.getElementById("join-room-message");
try{
let result = await apiFetch("/join-room", {
method: "POST",
body: JSON.stringify({ invite_code: inviteCode })
});
// /join-room returns 200 with status:"failed" for both an
// unknown code AND an already-joined room - it can't be told apart
// client-side, so the message stays deliberately generic.
if(result.status === "failed"){
messageBox.textContent = "invalid invite code, or you're already in that room";
return;
}
messageBox.textContent = "joined room";
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();
return status === "cancelled" || status === "canceled" || status === "declined";
}
function getEventColorClass(event, currentUserId){
let isOwnEvent = event.creator_id === currentUserId;
let isPrivateOther = event.visibility === "private" && !isOwnEvent;
if(isPrivateOther) return "event-private";
let statusLower = (event.status || "").toLowerCase();
if(statusLower === "confirmed") return "event-confirmed";
if(statusLower === "busy") return "event-busy";
// pending proposals fall back to the same tentative color as busy
return "event-busy";
}
function getWeekStart(offset){
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);
base.setDate(base.getDate() + mondayDiff);
return base;
}
function changeCalendarWeek(delta){
calendarWeekOffset += delta;
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 days = [];
for(let i = 0; i < 7; i++){
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 trackHeight = ((CALENDAR_END_HOUR - CALENDAR_START_HOUR) / CALENDAR_SLOT_HOURS) * CALENDAR_SLOT_HEIGHT;
let hourRows = "";
for(let h = CALENDAR_START_HOUR; h < CALENDAR_END_HOUR; h += CALENDAR_SLOT_HOURS){
hourRows += `
<div class="calendar-hour-label" style="height:${CALENDAR_SLOT_HEIGHT}px">
${String(h).padStart(2, "0")}:00
</div>
`;
}
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);
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));
let layerEnds = [];
let placed = dayEvents.map(event => {
function parseLocalDate(dateString){
let parts = dateString.split(/[- :]/);
return new Date(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]);
}
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 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 blocksHtml = placed.map(p => {
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 colorClass = getEventColorClass(p.event, currentUserId);
let timeLabel = new Date(p.event.start_time).toLocaleTimeString("nl-NL", {
hour: "2-digit",
minute: "2-digit",
hour12: false
});
return `
<div
class="calendar-event ${colorClass}"
style="
top:${topPx}px;
height:${heightPx}px;
left:${leftPct}%;
width:${widthPct}%;
z-index:${p.layer + 1};
"
onclick="openEventDetailModal(${p.event.id})"
title="${p.event.title}"
>
<span class="calendar-event-time">${timeLabel}</span>
<span class="calendar-event-title">${p.event.title}</span>
</div>
`;
}).join("");
let isToday =
day.getFullYear() === today.getFullYear()
&& day.getMonth() === today.getMonth()
&& day.getDate() === today.getDate();
return `
<div class="calendar-day-column">
<div class="calendar-day-header ${isToday ? "today" : ""}">
<div class="calendar-day-name">${dayNames[days.indexOf(day)]}</div>
<div class="calendar-day-number">${day.getDate()}</div>
</div>
<div class="calendar-day-track" style="height:${trackHeight}px">
${blocksHtml}
</div>
</div>
`;
}).join("");
return `
<div class="calendar-header">
<button class="calendar-nav-btn" onclick="changeCalendarWeek(-1)">&lt;</button>
<h3>${rangeLabel}</h3>
<button class="calendar-nav-btn" onclick="changeCalendarWeek(1)">&gt;</button>
</div>
<div class="calendar-legend">
<span class="legend-item"><span class="legend-dot event-confirmed"></span> confirmed</span>
<span class="legend-item"><span class="legend-dot event-busy"></span> busy / pending</span>
<span class="legend-item"><span class="legend-dot event-private"></span> private (others)</span>
</div>
<div class="calendar-week">
<div class="calendar-gutter">
<div class="calendar-day-header"></div>
<div class="calendar-hour-labels">${hourRows}</div>
</div>
<div class="calendar-days">${dayColumns}</div>
</div>
`;
}
// ---------- create / propose event ----------
function openCreateEventModal(prefill = {}){
if(cachedRoomsForForm.length === 0){
alert("You need to be in a room before you can create an event.");
return;
}
let kindValue = prefill.kind || "proposal";
let roomOptions = cachedRoomsForForm.map(room => `
<option value="${room.id}" ${prefill.roomId === room.id ? "selected" : ""}>${room.name}</option>
`).join("");
openModal(`
<h2>New event</h2>
<form onsubmit="handleCreateEvent(event)">
<label>Type
<select id="event-type" onchange="onEventTypeChange()">
<option value="proposal" ${kindValue === "proposal" ? "selected" : ""}>Propose event (room votes)</option>
<option value="busy" ${kindValue === "busy" ? "selected" : ""}>Busy block</option>
<option value="private" ${kindValue === "private" ? "selected" : ""}>Private appointment</option>
</select>
</label>
<label>Room
<select id="event-room" required>${roomOptions}</select>
</label>
<input type="text" id="event-title" placeholder="Title" required>
<input type="text" id="event-description" placeholder="Description (optional)">
<label>Start
<input type="datetime-local" id="event-start" required>
</label>
<label>End
<input type="datetime-local" id="event-end" required>
</label>
<div id="event-min-people-wrap">
<label>Minimum attendees to confirm
<input type="number" id="event-min-people" min="1" value="2">
</label>
</div>
<p id="create-event-message" class="muted"></p>
<div class="modal-actions">
<button type="button" onclick="closeModal()">Cancel</button>
<button type="submit">Create</button>
</div>
</form>
`);
onEventTypeChange();
}
function onEventTypeChange(){
let kind = document.getElementById("event-type").value;
let wrap = document.getElementById("event-min-people-wrap");
wrap.style.display = (kind === "proposal") ? "block" : "none";
}
async function handleCreateEvent(event){
event.preventDefault();
let kind = document.getElementById("event-type").value;
let roomId = parseInt(document.getElementById("event-room").value, 10);
let title = document.getElementById("event-title").value;
let description = document.getElementById("event-description").value;
let startLocal = document.getElementById("event-start").value;
let endLocal = document.getElementById("event-end").value;
let messageBox = document.getElementById("create-event-message");
let payload = {
room_id: roomId,
title,
description,
start_time: toBackendDateTime(startLocal),
end_time: toBackendDateTime(endLocal)
};
// Field values follow the convention from the create_event() example
// in events.py: type="proposal", visibility="room", status="proposed".
// "busy" / "private" extend that same pattern for the two other kinds.
if(kind === "proposal"){
payload.type = "proposal";
payload.status = "proposed";
payload.visibility = "room";
payload.min_people = parseInt(document.getElementById("event-min-people").value, 10) || 1;
} else if(kind === "busy"){
payload.type = "busy";
payload.status = "busy";
payload.visibility = "room";
payload.min_people = 0;
} else {
payload.type = "private";
payload.status = "confirmed";
payload.visibility = "private";
payload.min_people = 0;
}
try{
let result = await apiFetch("/events", {
method: "POST",
body: JSON.stringify(payload)
});
// POST /events returns 200 with status:"failed" (not an exception)
// when the room membership check fails.
if(result.status === "failed"){
messageBox.textContent =
result.reason === "not_room_member"
? "you're not a member of that room"
: (result.reason || "could not create event");
return;
}
closeModal();
let activeLink = document.querySelector("nav a.active");
showPage(activeLink ? activeLink.dataset.page : "dashboard");
} catch(error){
messageBox.textContent = error.message;
}
}
// ---------- modal infrastructure ----------
function ensureModalRoot(){
let modalRoot = document.getElementById("modal-root");
if(!modalRoot){
modalRoot = document.createElement("div");
modalRoot.id = "modal-root";
document.body.appendChild(modalRoot);
}
return modalRoot;
}
function openModal(innerHtml){
let modalRoot = ensureModalRoot();
modalRoot.innerHTML = `
<div class="modal-backdrop" onclick="if(event.target === this){ closeModal(); }">
<div class="modal-box" role="dialog" aria-modal="true">
${innerHtml}
</div>
</div>
`;
document.addEventListener("keydown", handleModalEscape);
}
function closeModal(){
let modalRoot = document.getElementById("modal-root");
if(modalRoot) modalRoot.innerHTML = "";
document.removeEventListener("keydown", handleModalEscape);
}
function handleModalEscape(event){
if(event.key === "Escape") closeModal();
}
// ---------- rooms page ----------
async function loadRooms(){
let content = document.getElementById("content");
content.innerHTML = `
<h1>Rooms</h1>
<div class="card">Loading...</div>
`;
try{
let roomsData = await apiFetch("/rooms");
let rooms = roomsData.rooms || [];
cachedRoomsForForm = rooms;
let actionsHtml = `
<div class="page-actions">
<button onclick="openCreateEventModal()">+ Propose event</button>
</div>
<div class="card">
<h3>Find a room</h3>
<form onsubmit="handleSearchRoom(event)">
<input type="text" id="search-room-code" placeholder="Invite code" required>
<button type="submit">Search</button>
</form>
<div id="search-room-result"></div>
</div>
`;
if(rooms.length === 0){
content.innerHTML = `
<h1>Rooms</h1>
${actionsHtml}
<div class="card">Your shared rooms appear here.</div>
`;
return;
}
let roomsHtml = rooms.map(room => `
<div class="card">
<h3>${room.name}</h3>
<p class="muted">invite code: ${room.invite_code || "-"}</p>
<div class="event-actions">
<button onclick="loadRoomEvents(${room.id})">View events</button>
<button onclick="loadRoomDetail(${room.id})">Manage</button>
<button onclick="openCreateEventModal({kind: 'proposal', roomId: ${room.id}})">Propose event</button>
</div>
</div>
`).join("");
content.innerHTML = `
<h1>Rooms</h1>
${actionsHtml}
${roomsHtml}
`;
} catch(error){
content.innerHTML = `
<h1>Rooms</h1>
<div class="card">Could not load rooms: ${error.message}</div>
`;
}
}
async function handleSearchRoom(event){
event.preventDefault();
let code = document.getElementById("search-room-code").value;
let resultBox = document.getElementById("search-room-result");
resultBox.innerHTML = `<p class="muted">Searching...</p>`;
try{
let result = await apiFetch("/search-room", {
method: "POST",
body: JSON.stringify({ invite_code: code })
});
if(result.status === "failed"){
resultBox.innerHTML = `<p class="error">No room found for that code.</p>`;
return;
}
resultBox.innerHTML = `
<div class="list-item">
<strong>${result.room_name}</strong>
<div class="event-actions">
<button onclick="handleJoinRoomByCode('${code}')">Join</button>
</div>
</div>
`;
} catch(error){
resultBox.innerHTML = `<p class="error">${error.message}</p>`;
}
}
async function handleJoinRoomByCode(code){
try{
let result = await apiFetch("/join-room", {
method: "POST",
body: JSON.stringify({ invite_code: code })
});
if(result.status === "failed"){
alert("Could not join: invalid code, or you're already a member.");
return;
}
showPage("rooms");
} catch(error){
alert(error.message);
}
}
async function loadRoomEvents(roomId){
let content = document.getElementById("content");
content.innerHTML = `
<h1>Room events</h1>
<div class="card">Loading...</div>
`;
try{
let data = await apiFetch(`/rooms/${roomId}/events`);
let events = data.events || [];
content.innerHTML = `
<h1>Room events</h1>
<div class="page-actions">
<button onclick="openCreateEventModal({kind: 'proposal', roomId: ${roomId}})">+ Propose event</button>
</div>
${renderEventCards(events)}
`;
} catch(error){
content.innerHTML = `
<h1>Room events</h1>
<div class="card">Could not load events: ${error.message}</div>
`;
}
}
// ---------- room detail (manage) ----------
async function loadRoomDetail(roomId){
let content = document.getElementById("content");
content.innerHTML = `<h1>Room</h1><div class="card">Loading...</div>`;
try{
let data = await apiFetch(`/rooms/${roomId}`);
let room = data.room;
let members = data.members || [];
let events = data.events || [];
let ownerUsername = `user #${room.owner_id}`;
try{
let owner = await apiFetch(`/users/${room.owner_id}`);
ownerUsername = owner.username;
} catch(error){
// keep the fallback label
}
content.innerHTML = `
<h1>${room.name}</h1>
<div class="card">
<p class="muted">Owner: ${ownerUsername}</p>
<p class="muted">Your role: ${room.role}</p>
<div class="event-actions">
<button onclick="loadRoomEvents(${roomId})">View events (${events.length})</button>
<button onclick="openCreateEventModal({kind: 'proposal', roomId: ${roomId}})">Propose event</button>
</div>
</div>
<div class="card">
<div class="calendar-header">
<h3>Members</h3>
<button class="calendar-nav-btn" onclick="refreshRoomMembers(${roomId})">Refresh</button>
</div>
<div id="room-members-list">${renderMembersList(members)}</div>
</div>
<div class="card">
<h3>Leave or delete</h3>
<div class="event-actions">
<button onclick="handleLeaveRoom(${roomId})">Leave room</button>
<button onclick="handleDeleteRoom(${roomId})" class="danger-button">Delete room</button>
</div>
</div>
`;
} catch(error){
content.innerHTML = `<h1>Room</h1><div class="card">Could not load room: ${error.message}</div>`;
}
}
function renderMembersList(members){
if(!members.length){
return `<p class="muted">No members found.</p>`;
}
return members.map(member => `
<div class="list-item">
${member.username}${member.role ? ` <span class="muted">(${member.role})</span>` : ""}
</div>
`).join("");
}
async function refreshRoomMembers(roomId){
let listBox = document.getElementById("room-members-list");
if(!listBox) return;
listBox.innerHTML = `<p class="muted">Refreshing...</p>`;
try{
let data = await apiFetch(`/rooms/${roomId}/members`);
listBox.innerHTML = renderMembersList(data.members || []);
} catch(error){
listBox.innerHTML = `<p class="error">Could not refresh members: ${error.message}</p>`;
}
}
async function handleLeaveRoom(roomId){
if(!confirm("Leave this room?")) return;
try{
await apiFetch(`/rooms/${roomId}/members/me`, { method: "DELETE" });
showPage("rooms");
} catch(error){
alert("Could not leave room: " + error.message);
}
}
async function handleDeleteRoom(roomId){
if(!confirm("Delete this room? This cannot be undone.")) return;
try{
let result = await apiFetch(`/room/${roomId}`, { method: "DELETE" });
if(result.status === "denied"){
alert("You don't have permission to delete this room.");
return;
}
if(result.status !== "deleted"){
// the endpoint currently always returns this until it's implemented
alert("Room was not deleted: " + (result.reason || "unknown reason"));
return;
}
showPage("rooms");
} catch(error){
alert("Could not delete room: " + error.message);
}
}
// ---------- events page ----------
async function loadEvents(){
let content = document.getElementById("content");
content.innerHTML = `
<h1>Events</h1>
<div class="card">Loading...</div>
`;
try{
let data = await apiFetch("/events");
let events = data.events || [];
content.innerHTML = `
<h1>Events</h1>
<div class="page-actions">
<button onclick="openCreateEventModal()">+ New event</button>
</div>
${renderEventCards(events)}
`;
} catch(error){
content.innerHTML = `
<h1>Events</h1>
<div class="card">Could not load events: ${error.message}</div>
`;
}
}
function renderEventCards(events){
if(events.length === 0){
return `<div class="card">No events scheduled.</div>`;
}
return events.map(event => `
<div class="card">
<h3>${event.title}</h3>
<p>${event.description || ""}</p>
<p class="muted">${formatEventTime(event.start_time, event.end_time)}</p>
<p class="muted">status: ${event.status}</p>
<div class="event-actions">
<button onclick="handleRespond(${event.id}, 'going')">Going</button>
<button onclick="handleRespond(${event.id}, 'maybe')">Maybe</button>
<button onclick="handleRespond(${event.id}, 'declined')">Declined</button>
<button onclick="openEventDetailModal(${event.id})">Details</button>
</div>
<p id="event-response-${event.id}" class="muted"></p>
</div>
`).join("");
}
function formatEventTime(start, end){
if(!start) return "time unknown";
let startDate = new Date(start);
let endDate = end ? new Date(end) : null;
let startText = startDate.toLocaleString();
if(!endDate) return startText;
return startText + " - " + endDate.toLocaleString();
}
async function handleRespond(eventId, status){
let messageBox = document.getElementById(`event-response-${eventId}`);
try{
let result = await apiFetch(`/events/${eventId}/respond?status=${status}`, { method: "POST" });
messageBox.textContent = `you responded: ${status} (${result.going} going)`;
} catch(error){
messageBox.textContent = error.message;
}
}
// ---------- event detail / edit / delete ----------
async function ensureCurrentUser(){
if(currentUserId !== null) return currentUserId;
try{
let me = await apiFetch("/me");
currentUserId = me.id;
} catch(error){
// isOwn checks just stay false
}
return currentUserId;
}
async function openEventDetailModal(eventId){
openModal(`<p class="muted">Loading...</p>`);
try{
await ensureCurrentUser();
let eventDetail = await apiFetch(`/events/${eventId}`);
let isOwn = eventDetail.creator_id === currentUserId;
openModal(`
<h2>${eventDetail.title}</h2>
<p class="muted">${formatEventTime(eventDetail.start_time, eventDetail.end_time)}</p>
<p>${eventDetail.description || ""}</p>
<p class="muted">status: ${eventDetail.status}</p>
<h3>Responses</h3>
<div id="event-responses-list"><p class="muted">Loading responses...</p></div>
<div class="modal-actions">
${isOwn ? `
<button onclick="openEditEventModal(${eventId})">Edit</button>
<button onclick="handleDeleteEvent(${eventId})" class="danger-button">Delete</button>
` : ""}
<button type="button" onclick="closeModal()">Close</button>
</div>
`);
try{
let responsesData = await apiFetch(`/events/${eventId}/responses`);
let responses = responsesData.responses || [];
let listBox = document.getElementById("event-responses-list");
if(listBox){
listBox.innerHTML = responses.length
? responses.map(r => `<div class="list-item">${r.username}${r.status}</div>`).join("")
: `<p class="muted">No responses yet.</p>`;
}
} catch(error){
let listBox = document.getElementById("event-responses-list");
if(listBox) listBox.innerHTML = `<p class="error">Could not load responses: ${error.message}</p>`;
}
} catch(error){
openModal(`<p class="error">Could not load event: ${error.message}</p>`);
}
}
async function openEditEventModal(eventId){
try{
let eventDetail = await apiFetch(`/events/${eventId}`);
openModal(`
<h2>Edit event</h2>
<form onsubmit="handleUpdateEvent(event, ${eventId})">
<input type="text" id="edit-event-title" value="${eventDetail.title}" required>
<input type="text" id="edit-event-description" value="${eventDetail.description || ""}">
<label>Start
<input type="datetime-local" id="edit-event-start" value="${toDatetimeLocal(eventDetail.start_time)}" required>
</label>
<label>End
<input type="datetime-local" id="edit-event-end" value="${toDatetimeLocal(eventDetail.end_time)}" required>
</label>
<label>Minimum attendees
<input type="number" id="edit-event-min-people" min="1" value="${eventDetail.min_people || 1}">
</label>
<p id="edit-event-message" class="muted"></p>
<div class="modal-actions">
<button type="button" onclick="closeModal()">Cancel</button>
<button type="submit">Save</button>
</div>
</form>
`);
} catch(error){
alert("Could not load event: " + error.message);
}
}
async function handleUpdateEvent(event, eventId){
event.preventDefault();
let messageBox = document.getElementById("edit-event-message");
let payload = {
title: document.getElementById("edit-event-title").value,
description: document.getElementById("edit-event-description").value,
start_time: toBackendDateTime(document.getElementById("edit-event-start").value),
end_time: toBackendDateTime(document.getElementById("edit-event-end").value),
min_people: parseInt(document.getElementById("edit-event-min-people").value, 10) || 1
};
try{
await apiFetch(`/events/${eventId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
closeModal();
showPage("events");
} catch(error){
messageBox.textContent = error.message;
}
}
async function handleDeleteEvent(eventId){
if(!confirm("Delete this event?")) return;
try{
await apiFetch(`/events/${eventId}`, { method: "DELETE" });
closeModal();
showPage("events");
} catch(error){
alert("Could not delete event: " + error.message);
}
}
const A11Y_DEFAULTS = {
reducedMotion: false,
highContrast: false,
fontScale: "normal",
underlineLinks: false,
alwaysShowFocus: false
};
function getA11ySettings(){
try{
let stored = JSON.parse(localStorage.getItem("a11y-settings"));
return { ...A11Y_DEFAULTS, ...(stored || {}) };
} catch(error){
return { ...A11Y_DEFAULTS };
}
}
function applyA11ySettings(settings){
document.body.classList.toggle("reduced-motion", settings.reducedMotion);
document.body.classList.toggle("high-contrast", settings.highContrast);
document.body.classList.toggle("underline-links", settings.underlineLinks);
document.body.classList.toggle("always-focus", settings.alwaysShowFocus);
document.body.classList.remove("font-scale-large", "font-scale-xlarge");
if(settings.fontScale === "large") document.body.classList.add("font-scale-large");
if(settings.fontScale === "xlarge") document.body.classList.add("font-scale-xlarge");
}
function updateA11ySetting(key, value){
let settings = getA11ySettings();
settings[key] = value;
localStorage.setItem("a11y-settings", JSON.stringify(settings));
applyA11ySettings(settings);
}
function renderSettingsPage(content){
let settings = getA11ySettings();
content.innerHTML = `
<h1>Settings</h1>
<div class="card">
<h3>Account</h3>
<button onclick="logout()">Log out</button>
</div>
<div class="card">
<h3>Accessibility</h3>
<label class="settings-row">
<input type="checkbox" ${settings.reducedMotion ? "checked" : ""} onchange="updateA11ySetting('reducedMotion', this.checked)">
Reduce motion
</label>
<label class="settings-row">
<input type="checkbox" ${settings.highContrast ? "checked" : ""} onchange="updateA11ySetting('highContrast', this.checked)">
High contrast
</label>
<label class="settings-row">
<input type="checkbox" ${settings.underlineLinks ? "checked" : ""} onchange="updateA11ySetting('underlineLinks', this.checked)">
Underline links
</label>
<label class="settings-row">
<input type="checkbox" ${settings.alwaysShowFocus ? "checked" : ""} onchange="updateA11ySetting('alwaysShowFocus', this.checked)">
Always show focus outline
</label>
<label class="settings-row">
Text size
<select onchange="updateA11ySetting('fontScale', this.value)">
<option value="normal" ${settings.fontScale === "normal" ? "selected" : ""}>Normal</option>
<option value="large" ${settings.fontScale === "large" ? "selected" : ""}>Large</option>
<option value="xlarge" ${settings.fontScale === "xlarge" ? "selected" : ""}>Extra large</option>
</select>
</label>
</div>
<div class="card">
<h3>Appearance</h3>
<button onclick="toggleTheme()">Toggle dark mode</button>
</div>
<div class="card">
<h3>Diagnostics</h3>
<button onclick="runDiagnostics()">Check API status</button>
<p id="diagnostics-result" class="muted"></p>
<button onclick="loadAsciiArt()">Show ascii art</button>
<pre id="ascii-art-box" class="ascii-art-box" hidden></pre>
</div>
`;
}
async function runDiagnostics(){
let resultBox = document.getElementById("diagnostics-result");
resultBox.textContent = "Checking...";
try{
let home = await apiFetch("/");
let db = await apiFetch("/database");
resultBox.textContent = `${home.service}: ${home.status}. Tables: ${(db.tables || []).join(", ")}`;
} catch(error){
resultBox.textContent = "Diagnostics failed: " + error.message;
}
}
async function loadAsciiArt(){
let box = document.getElementById("ascii-art-box");
try{
let headers = {};
let token = getToken();
if(token) headers["Authorization"] = "Bearer " + token;
let response = await fetch(API_BASE + "/ascii-art", { headers });
let text = await response.text();
box.textContent = text;
box.hidden = false;
} catch(error){
box.textContent = "Could not load ascii art.";
box.hidden = false;
}
}
function toggleTheme(){
document.body.classList.toggle("dark");
let theme = document.body.classList.contains("dark") ? "dark" : "light";
localStorage.setItem("theme", theme);
}
let savedTheme = localStorage.getItem("theme");
if(savedTheme === "dark"){
document.body.classList.add("dark");
}
applyA11ySettings(getA11ySettings());
if(getToken()){
showApp();
showPage("dashboard");
} else {
showLogin();
}