1267 lines
40 KiB
JavaScript
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)"><</button>
|
|
<h3>${rangeLabel}</h3>
|
|
<button class="calendar-nav-btn" onclick="changeCalendarWeek(1)">></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();
|
|
} |