Files
ben.de-roo.org/app.js
T
2026-08-06 18:23:39 +02:00

1407 lines
42 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)
? (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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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";
}
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.classList.add("success");
errorBox.classList.remove("error");
errorBox.textContent = "account created, you can log in now";
} catch(error){
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");
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"){ loadSettings(); }
}
// ---------- dashboard ----------
async function loadDashboard(){
let content = document.getElementById("content");
content.innerHTML = `
<h1>Dashboard</h1>
<div class="card">Loading...</div>
`;
calendarWeekOffset = 0;
try{
await ensureMe();
let rooms = await ensureRooms(true);
let eventsData = await apiFetch("/events");
cachedDashboardEvents = eventsData.events || [];
let roomsHtml =
rooms.length
? rooms.map(room => `
<div class="list-item">${escapeHtml(room.room_name)}</div>
`).join("")
: `<p class="muted">You have no rooms yet.</p>`;
content.innerHTML = `
<h1>Dashboard</h1>
<div class="card">
<h3>Welcome back, ${escapeHtml(currentUsername)}</h3>
<p>Overview of your calendar.</p>
</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)">
<label class="field-label" for="create-room-name">Room name</label>
<input type="text" id="create-room-name" placeholder="Room name" required>
<label class="field-label" for="create-room-code">Invite code</label>
<input type="text" id="create-room-code" placeholder="Invite code" required>
<button type="submit" class="btn-primary">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)">
<label class="field-label" for="join-room-code">Invite code</label>
<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: ${escapeHtml(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";
await ensureRooms(true);
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{
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();
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 status = (event.status || "").toLowerCase();
let type = (event.type || "").toLowerCase();
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 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(/[- :T]/);
return new Date(
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 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="showPage('events')"
title="${escapeHtml(p.event.title)}"
>
<span class="calendar-event-time">${timeLabel}</span>
<span class="calendar-event-title">${escapeHtml(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)" aria-label="Previous week">&lt;</button>
<h3>${rangeLabel}</h3>
<button class="calendar-nav-btn" onclick="changeCalendarWeek(1)" aria-label="Next week">&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-proposed"></span> proposed</span>
<span class="legend-item"><span class="legend-dot event-busy"></span> busy</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>
`;
}
// ---------- rooms page ----------
async function loadRooms(){
let content = document.getElementById("content");
content.innerHTML = `
<h1>Rooms</h1>
<div class="card">Loading...</div>
`;
try{
await ensureMe();
let rooms = await ensureRooms(true);
let searchCard = `
<div class="card">
<h3>Find a room by invite code</h3>
<form onsubmit="handleSearchRoom(event)">
<label class="field-label" for="search-room-code">Invite code</label>
<input type="text" id="search-room-code" placeholder="Invite code" required>
<button type="submit">Search</button>
</form>
<div id="search-room-result" class="muted"></div>
</div>
`;
if(rooms.length === 0){
content.innerHTML = `
<h1>Rooms</h1>
${searchCard}
<div class="card">Your shared rooms appear here.</div>
`;
return;
}
let roomsHtml =
rooms.map(room => {
let isOwner = room.owner_id === currentUserId;
return `
<div class="card">
<div class="event-head">
<h3>${escapeHtml(room.room_name)}</h3>
${isOwner ? `<span class="badge badge-confirmed">owner</span>` : ""}
</div>
<p class="muted">invite code: ${escapeHtml(room.invite_code || "-")}</p>
<div id="room-members-${room.id}" class="member-list"></div>
<div class="event-actions">
<button class="btn-sm" onclick="loadRoomEvents(${room.id})">View events</button>
<button class="btn-sm" onclick="showRoomMembers(${room.id})">View members</button>
${isOwner
? `<button class="btn-sm btn-danger" onclick="handleDeleteRoom(${room.id})">Delete room</button>`
: `<button class="btn-sm btn-danger" onclick="handleLeaveRoom(${room.id})">Leave room</button>`}
</div>
<p id="room-message-${room.id}" class="muted"></p>
</div>
`;
}).join("");
content.innerHTML = `
<h1>Rooms</h1>
${searchCard}
${roomsHtml}
`;
} catch(error){
content.innerHTML = `
<h1>Rooms</h1>
<div class="card">Could not load rooms: ${escapeHtml(error.message)}</div>
`;
}
}
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 = `
<div class="list-item">
<strong>${escapeHtml(room.room_name || "room")}</strong>
<div class="event-actions">
<button class="btn-sm btn-primary" onclick="handleJoinRoomByCode('${escapeHtml(inviteCode)}')">Join this room</button>
</div>
</div>
`;
} 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 = `<span class="muted">loading members...</span>`;
try{
let data = await apiFetch(`/rooms/${roomId}/members`);
let members = data.members || [];
if(members.length === 0){
container.innerHTML = `<span class="muted">no members</span>`;
return;
}
container.innerHTML =
members.map(m => `
<span class="member-chip">${escapeHtml(m.username || ("user " + m.id))}</span>
`).join("");
} catch(error){
container.innerHTML = `<span class="error">${escapeHtml(error.message)}</span>`;
}
}
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");
content.innerHTML = `
<h1>Room events</h1>
<div class="card">Loading...</div>
`;
try{
await ensureMe();
let data = await apiFetch(`/rooms/${roomId}/events`);
let events = data.events || [];
content.innerHTML = `
<h1>Room events</h1>
<div class="event-actions">
<button class="btn-sm" onclick="loadRooms()">Back to rooms</button>
</div>
${renderEventCards(events)}
`;
} catch(error){
content.innerHTML = `
<h1>Room events</h1>
<div class="card">Could not load events: ${escapeHtml(error.message)}</div>
`;
}
}
// ---------- 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");
content.innerHTML = `
<h1>Events</h1>
<div class="card">Loading...</div>
`;
try{
await ensureMe();
let rooms = await ensureRooms();
let data = await apiFetch("/events");
let events = data.events || [];
content.innerHTML = `
<h1>Events</h1>
${renderCreateEventCard(rooms)}
<h3 style="font-family:var(--font-display);margin-top:var(--space-6)">Upcoming events</h3>
${renderEventCards(events)}
`;
} catch(error){
content.innerHTML = `
<h1>Events</h1>
<div class="card">Could not load events: ${escapeHtml(error.message)}</div>
`;
}
}
function renderCreateEventCard(rooms){
let roomOptions =
rooms.length
? rooms.map(r => `<option value="${r.id}">${escapeHtml(r.room_name)}</option>`).join("")
: `<option value="">You need a room first</option>`;
let kindOptions =
Object.keys(EVENT_KINDS).map((key, index) => {
let k = EVENT_KINDS[key];
return `
<label class="kind-option">
<input type="radio" name="event-kind" value="${key}" ${index === 0 ? "checked" : ""} onchange="onEventKindChange()">
<span class="kind-option-title">${k.label}</span>
<span class="kind-option-desc">${k.desc}</span>
</label>
`;
}).join("");
return `
<div class="card">
<h3>Create an event</h3>
<form onsubmit="handleCreateEvent(event)">
<label class="field-label">What kind of entry?</label>
<div class="kind-grid">${kindOptions}</div>
<label class="field-label" for="event-room">Room</label>
<select id="event-room" required>${roomOptions}</select>
<label class="field-label" for="event-title">Title</label>
<input type="text" id="event-title" placeholder="Title" required>
<label class="field-label" for="event-description">Description</label>
<textarea id="event-description" placeholder="Optional details"></textarea>
<div class="field-row">
<div>
<label class="field-label" for="event-start">Starts</label>
<input type="datetime-local" id="event-start" required>
</div>
<div>
<label class="field-label" for="event-end">Ends</label>
<input type="datetime-local" id="event-end">
</div>
</div>
<div id="event-minpeople-wrap" style="display:none">
<label class="field-label" for="event-minpeople">Minimum people needed</label>
<input type="number" id="event-minpeople" min="0" value="0">
</div>
<button type="submit" class="btn-primary" style="margin-top:var(--space-4)">Create</button>
</form>
<p id="create-event-message" class="muted"></p>
</div>
`;
}
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 `<span class="badge badge-private">private</span>`;
}
if(status === "proposed"){
return `<span class="badge badge-proposed">proposed</span>`;
}
if(type === "busy"){
return `<span class="badge badge-busy">busy</span>`;
}
return `<span class="badge badge-confirmed">confirmed</span>`;
}
function renderEventCards(events){
if(events.length === 0){
return `<div class="card">No events scheduled.</div>`;
}
return events.map(event => {
let isOwner = event.creator_id === currentUserId;
let isProposed = (event.status || "").toLowerCase() === "proposed";
return `
<div class="card" id="event-card-${event.id}">
<div class="event-head">
<h3>${escapeHtml(event.title)}</h3>
${eventBadge(event)}
</div>
<p>${escapeHtml(event.description || "")}</p>
<p class="muted">${escapeHtml(formatEventTime(event.start_time, event.end_time))}</p>
${isProposed && event.min_people
? `<p class="muted">needs at least ${event.min_people} people</p>`
: ""}
<div class="event-actions">
<button class="btn-sm" onclick="handleRespond(${event.id}, 'going')">Going</button>
<button class="btn-sm" onclick="handleRespond(${event.id}, 'maybe')">Maybe</button>
<button class="btn-sm" onclick="handleRespond(${event.id}, 'declined')">Declined</button>
<button class="btn-sm" onclick="viewResponses(${event.id})">Responses</button>
${isOwner ? `<button class="btn-sm" onclick="toggleEditEvent(${event.id})">Edit</button>` : ""}
${isOwner ? `<button class="btn-sm btn-danger" onclick="handleDeleteEvent(${event.id})">Delete</button>` : ""}
</div>
<p id="event-response-${event.id}" class="muted"></p>
<div id="event-responses-${event.id}"></div>
<div id="event-edit-${event.id}"></div>
</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" });
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 = `<p class="muted">loading responses...</p>`;
try{
let data = await apiFetch(`/events/${eventId}/responses`);
let responses = data.responses || [];
if(responses.length === 0){
container.innerHTML = `<p class="muted">no responses yet</p>`;
return;
}
container.innerHTML = `
<div class="member-list">
${responses.map(r => `
<span class="member-chip">
${escapeHtml(r.username || ("user " + r.user_id))}: ${escapeHtml(r.status)}
</span>
`).join("")}
</div>
`;
} catch(error){
container.innerHTML = `<p class="error">${escapeHtml(error.message)}</p>`;
}
}
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 = `
<form onsubmit="handleUpdateEvent(event, ${eventId})" style="margin-top:var(--space-4);border-top:1px solid var(--border);padding-top:var(--space-4)">
<label class="field-label" for="edit-title-${eventId}">Title</label>
<input type="text" id="edit-title-${eventId}" value="${escapeHtml(ev.title || "")}" required>
<label class="field-label" for="edit-desc-${eventId}">Description</label>
<textarea id="edit-desc-${eventId}">${escapeHtml(ev.description || "")}</textarea>
<div class="field-row">
<div>
<label class="field-label" for="edit-start-${eventId}">Starts</label>
<input type="datetime-local" id="edit-start-${eventId}" value="${startVal}">
</div>
<div>
<label class="field-label" for="edit-end-${eventId}">Ends</label>
<input type="datetime-local" id="edit-end-${eventId}" value="${endVal}">
</div>
</div>
<label class="field-label" for="edit-minpeople-${eventId}">Minimum people</label>
<input type="number" id="edit-minpeople-${eventId}" min="0" value="${ev.min_people || 0}">
<button type="submit" class="btn-primary" style="margin-top:var(--space-3)">Save changes</button>
</form>
<p id="edit-message-${eventId}" class="muted"></p>
`;
} catch(error){
container.innerHTML = `<p class="error">${escapeHtml(error.message)}</p>`;
}
}
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 = `
<h1>Settings</h1>
<div class="card">
<h3>Appearance</h3>
<div class="setting-row">
<div class="setting-info">
<div class="setting-title">Dark mode</div>
<div class="setting-desc">Switch between the light paper theme and the dark theme.</div>
</div>
<div class="setting-control">
<label class="switch">
<input type="checkbox" ${isDark ? "checked" : ""} onchange="toggleTheme()" aria-label="Dark mode">
<span class="slider"></span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-title">Text size</div>
<div class="setting-desc">Scale all text for easier reading.</div>
</div>
<div class="setting-control">
<label class="sr-only" for="a11y-fontscale">Text size</label>
<select id="a11y-fontscale" onchange="updateA11ySetting('fontScale', this.value)">
<option value="1" ${s.fontScale === "1" ? "selected" : ""}>Normal</option>
<option value="1.15" ${s.fontScale === "1.15" ? "selected" : ""}>Large</option>
<option value="1.3" ${s.fontScale === "1.3" ? "selected" : ""}>Larger</option>
<option value="1.5" ${s.fontScale === "1.5" ? "selected" : ""}>Largest</option>
</select>
</div>
</div>
</div>
<div class="card">
<h3>Accessibility</h3>
<div class="setting-row">
<div class="setting-info">
<div class="setting-title">High contrast</div>
<div class="setting-desc">Increase contrast and thicken borders for better visibility.</div>
</div>
<div class="setting-control">
<label class="switch">
<input type="checkbox" ${s.contrast ? "checked" : ""} onchange="updateA11ySetting('contrast', this.checked)" aria-label="High contrast">
<span class="slider"></span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-title">Underline links</div>
<div class="setting-desc">Always underline links so they stand out from regular text.</div>
</div>
<div class="setting-control">
<label class="switch">
<input type="checkbox" ${s.underline ? "checked" : ""} onchange="updateA11ySetting('underline', this.checked)" aria-label="Underline links">
<span class="slider"></span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-title">Reduce motion</div>
<div class="setting-desc">Turn off transitions and animations across the app.</div>
</div>
<div class="setting-control">
<label class="switch">
<input type="checkbox" ${s.reduceMotion ? "checked" : ""} onchange="updateA11ySetting('reduceMotion', this.checked)" aria-label="Reduce motion">
<span class="slider"></span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-title">Dyslexia-friendly reading</div>
<div class="setting-desc">Use a more legible font with extra letter and line spacing.</div>
</div>
<div class="setting-control">
<label class="switch">
<input type="checkbox" ${s.dyslexic ? "checked" : ""} onchange="updateA11ySetting('dyslexic', this.checked)" aria-label="Dyslexia-friendly reading">
<span class="slider"></span>
</label>
</div>
</div>
<div class="event-actions">
<button class="btn-sm" onclick="resetA11ySettings()">Reset to defaults</button>
</div>
</div>
<div class="card">
<h3>Account</h3>
<p class="muted" id="settings-account">Loading account...</p>
<button class="btn-danger" onclick="logout()">Log out</button>
</div>
`;
// 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 ----------
function toggleTheme(){
document.body.classList.toggle("dark");
let theme =
document.body.classList.contains("dark") ? "dark" : "light";
localStorage.setItem("theme", theme);
}
// ---------- init ----------
let savedTheme = localStorage.getItem("theme");
if(savedTheme === "dark"){
document.body.classList.add("dark");
}
applyA11ySettings();
if(getToken()){
showApp();
showPage("dashboard");
} else {
showLogin();
}