1407 lines
42 KiB
JavaScript
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, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function toBackendTime(localValue){
|
|
// datetime-local gives "YYYY-MM-DDTHH:MM" -> "YYYY-MM-DD HH:MM:00"
|
|
if(!localValue){
|
|
return null;
|
|
}
|
|
let value = localValue.replace("T", " ");
|
|
if(value.length === 16){
|
|
value += ":00";
|
|
}
|
|
return value;
|
|
}
|
|
|
|
|
|
// ---------- auth ----------
|
|
|
|
|
|
function showLogin(){
|
|
document.getElementById("app").style.display = "none";
|
|
document.getElementById("login-screen").style.display = "flex";
|
|
}
|
|
|
|
function showApp(){
|
|
document.getElementById("login-screen").style.display = "none";
|
|
document.getElementById("app").style.display = "grid";
|
|
}
|
|
|
|
|
|
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"><</button>
|
|
<h3>${rangeLabel}</h3>
|
|
<button class="calendar-nav-btn" onclick="changeCalendarWeek(1)" aria-label="Next week">></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();
|
|
}
|