From 57757e90aa110bfbfb1b718c08a5ea73d0ff6ff6 Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 7 Aug 2026 10:44:31 +0200 Subject: [PATCH] updates frontend --- frontend/www/app.js | 1595 +++++++++++++++++++++------------------- frontend/www/style.css | 681 +++++++++-------- 2 files changed, 1234 insertions(+), 1042 deletions(-) diff --git a/frontend/www/app.js b/frontend/www/app.js index d3617f0..f88244c 100644 --- a/frontend/www/app.js +++ b/frontend/www/app.js @@ -1,345 +1,203 @@ -const API_BASE = - "/api"; - - +const API_BASE = "/api"; function getToken(){ - return localStorage.getItem("token"); - } - function setToken(token){ - localStorage.setItem("token", token); - } - function clearToken(){ - localStorage.removeItem("token"); - } - - async function apiFetch(path, options = {}){ - - let headers = - options.headers - ? { ...options.headers } - : {}; - - + let headers = options.headers ? { ...options.headers } : {}; headers["Content-Type"] = "application/json"; - - let token = - getToken(); - - + let token = getToken(); if(token){ - headers["Authorization"] = "Bearer " + token; - } - - let response = - await fetch( - API_BASE + path, - { - ...options, - headers - } - ); - + 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); - + let data = await response.json().catch(() => null); if(!response.ok){ - - let message = - (data && data.detail) - ? data.detail - : "request failed"; - + 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"); - + 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 }) - } - ); - + 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"); - + let username = document.getElementById("login-username").value; + let password = document.getElementById("login-password").value; + let errorBox = document.getElementById("login-error"); errorBox.textContent = ""; - try{ - - await apiFetch( - "/create-user", - { - method: "POST", - body: JSON.stringify({ username, password }) - } - ); - + await apiFetch("/create-user", { + method: "POST", + body: JSON.stringify({ username, password }) + }); errorBox.style.color = "green"; errorBox.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"); - let content = - document.getElementById("content"); - - - document.querySelectorAll("nav a") - .forEach(link => link.classList.remove("active")); - - let activeLink = - document.querySelector(`nav a[data-page="${page}"]`); + 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 === "dashboard") loadDashboard(); + if(page === "rooms") loadRooms(); + if(page === "events") loadEvents(); if(page === "settings"){ - - content.innerHTML = ` -

Settings

- -
- Account preferences. -

- -
- `; - + renderSettingsPage(content); } - } - - // ---------- dashboard ---------- - let calendarWeekOffset = 0; - let cachedDashboardEvents = []; - +let cachedRoomsForForm = []; let currentUserId = null; - async function loadDashboard(){ - - let content = - document.getElementById("content"); - + let content = document.getElementById("content"); content.innerHTML = `

Dashboard

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

You have no rooms yet.

`; - content.innerHTML = `

Dashboard

Welcome back, ${me.username}

-

- Overview of your calendar. -

+

Overview of your calendar.

+
+ +
+
@@ -353,826 +211,1063 @@ async function loadDashboard(){

Create a room

-
- - - - - + + -
-

Join a room

-
- - - + -
-

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

Dashboard

Could not load dashboard: ${error.message}
`; - } - } - async function handleCreateRoom(event){ - event.preventDefault(); - - let roomName = - document.getElementById("create-room-name").value; - - let inviteCode = - document.getElementById("create-room-code").value; - - let messageBox = - document.getElementById("create-room-message"); - + let roomName = document.getElementById("create-room-name").value; + let inviteCode = document.getElementById("create-room-code").value; + let messageBox = document.getElementById("create-room-message"); try{ - - await apiFetch( - "/create-room", - { - method: "POST", - body: JSON.stringify({ - room_name: roomName, - invite_code: inviteCode - }) - } - ); - + await apiFetch("/create-room", { + method: "POST", + body: JSON.stringify({ room_name: roomName, invite_code: inviteCode }) + }); messageBox.textContent = "room created"; - 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"); - + 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 }) + }); - 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); +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" - ); - + let status = (event.status || "").toLowerCase(); + return status === "cancelled" || status === "canceled" || status === "declined"; } - function getEventColorClass(event, currentUserId){ + let isOwnEvent = event.creator_id === currentUserId; + let isPrivateOther = event.visibility === "private" && !isOwnEvent; - let isOwnEvent = - event.creator_id === currentUserId; + if(isPrivateOther) return "event-private"; - let isPrivateOther = - event.visibility === "private" && !isOwnEvent; - - - if(isPrivateOther){ - - return "event-private"; - - } - - - if((event.status || "").toLowerCase() === "confirmed"){ - - return "event-confirmed"; - - } + 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); - + 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"); - + let calendarCard = document.getElementById("calendar-card"); if(calendarCard){ - - calendarCard.innerHTML = - buildWeekCalendarHtml(cachedDashboardEvents, calendarWeekOffset); - + calendarCard.innerHTML = buildWeekCalendarHtml(cachedDashboardEvents, calendarWeekOffset); } - } - function buildWeekCalendarHtml(events, offset){ - - let weekStart = - getWeekStart(offset); - - let today = - new Date(); - - let dayNames = - ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; - + let weekStart = getWeekStart(offset); + let today = new Date(); + let dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; let days = []; - for(let i = 0; i < 7; i++){ - - let d = - new Date(weekStart); - + let d = new Date(weekStart); d.setDate(weekStart.getDate() + i); - days.push(d); - } - let rangeLabel = days[0].toLocaleDateString("default", { day: "numeric", month: "short" }) + " - " + days[6].toLocaleDateString("default", { day: "numeric", month: "short", year: "numeric" }); - - let totalMinutes = - (CALENDAR_END_HOUR - CALENDAR_START_HOUR) * 60; - - let trackHeight = - ((CALENDAR_END_HOUR - CALENDAR_START_HOUR) / CALENDAR_SLOT_HOURS) - * CALENDAR_SLOT_HEIGHT; - - // time gutter + 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 - ){ - + for(let h = CALENDAR_START_HOUR; h < CALENDAR_END_HOUR; h += CALENDAR_SLOT_HOURS){ hourRows += ` -
+
${String(h).padStart(2, "0")}:00
`; - } - // day columns + let dayColumns = days.map(day => { - let dayColumns = - days.map(day => { + let dayEvents = events.filter(event => { + if(!event.start_time || isEventCancelled(event)) return false; - let dayEvents = - events.filter(event => { + 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)); - if(!event.start_time || isEventCancelled(event)){ + let layerEnds = []; - return false; + 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 eventStart = - new Date(event.start_time); + let startMinutes = start.getHours() * 60 + start.getMinutes(); + let endMinutes = Math.max(startMinutes + 15, end.getHours() * 60 + end.getMinutes()); - return ( - eventStart.getFullYear() === day.getFullYear() - && eventStart.getMonth() === day.getMonth() - && eventStart.getDate() === day.getDate() - ); + let layer = 0; + while(layerEnds[layer] !== undefined && layerEnds[layer] > startMinutes){ + layer++; + } + layerEnds[layer] = endMinutes; - }) - .sort((a, b) => new Date(a.start_time) - new Date(b.start_time)); + return { event, startMinutes, endMinutes, layer }; + }); + let maxLayer = placed.reduce((m, p) => Math.max(m, p.layer), 0); - // assign overlap layers: an event gets the lowest layer - // whose most recent block has already ended - - let layerEnds = []; - - let placed = - dayEvents.map(event => { - function parseLocalDate(dateString){ - let parts = dateString.split(/[- :]/); - - 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 heightPct = - ((p.endMinutes - p.startMinutes) / totalMinutes) * 100; - - 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 - }); - - console.log({ - title: p.event.title, - startMinutes: p.startMinutes, - endMinutes: p.endMinutes, - topPx, - heightPx - }); - return ` -
- ${timeLabel} - ${p.event.title} -
- `; - - }).join(""); - - - let isToday = - ( - day.getFullYear() === today.getFullYear() - && day.getMonth() === today.getMonth() - && day.getDate() === today.getDate() - ); + 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 ` -
- -
-
${dayNames[days.indexOf(day)]}
-
${day.getDate()}
-
- -
- ${blocksHtml} -
- +
+ ${timeLabel} + ${p.event.title}
`; - }).join(""); + let isToday = + day.getFullYear() === today.getFullYear() + && day.getMonth() === today.getMonth() + && day.getDate() === today.getDate(); + + return ` +
+
+
${dayNames[days.indexOf(day)]}
+
${day.getDate()}
+
+
+ ${blocksHtml} +
+
+ `; + }).join(""); return `
- - - +

${rangeLabel}

- - - +
- - - confirmed - - - - busy - - - - private (others) - - + confirmed + busy / pending + private (others)
-
-
- ${hourRows} -
+
${hourRows}
+
+
${dayColumns}
+
+ `; +} + +// ---------- 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 => ` + + `).join(""); + + openModal(` +

New event

+
+ + + + + + + + + + + + +
+
-
- ${dayColumns} -
+

+ +
+ `); + + 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 = ` + `; + 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"); - + let content = document.getElementById("content"); content.innerHTML = `

Rooms

Loading...
`; - try{ + let roomsData = await apiFetch("/rooms"); + let rooms = roomsData.rooms || []; + cachedRoomsForForm = rooms; - let roomsData = - await apiFetch("/rooms"); - - let rooms = - roomsData.rooms || []; + let actionsHtml = ` +
+ +
+
+

Find a room

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

Rooms

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

${room.room_name}

-

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

- + let roomsHtml = rooms.map(room => ` +
+

${room.name}

+

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

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

Rooms

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

Rooms

Could not load rooms: ${error.message}
`; - } - } +async function handleSearchRoom(event){ + event.preventDefault(); + + let code = document.getElementById("search-room-code").value; + let resultBox = document.getElementById("search-room-result"); + + resultBox.innerHTML = `

Searching...

`; + + try{ + let result = await apiFetch("/search-room", { + method: "POST", + body: JSON.stringify({ invite_code: code }) + }); + + if(result.status === "failed"){ + resultBox.innerHTML = `

No room found for that code.

`; + return; + } + + resultBox.innerHTML = ` +
+ ${result.room_name} +
+ +
+
+ `; + + } catch(error){ + resultBox.innerHTML = `

${error.message}

`; + } +} + +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"); - + let content = document.getElementById("content"); content.innerHTML = `

Room events

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

Room events

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

Room events

Could not load events: ${error.message}
`; - } - } +// ---------- room detail (manage) ---------- +async function loadRoomDetail(roomId){ + let content = document.getElementById("content"); + + content.innerHTML = `

Room

Loading...
`; + + 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 = ` +

${room.name}

+ +
+

Owner: ${ownerUsername}

+

Your role: ${room.role}

+
+ + +
+
+ +
+
+

Members

+ +
+
${renderMembersList(members)}
+
+ +
+

Leave or delete

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

Room

Could not load room: ${error.message}
`; + } +} + +function renderMembersList(members){ + if(!members.length){ + return `

No members found.

`; + } + + return members.map(member => ` +
+ ${member.username}${member.role ? ` (${member.role})` : ""} +
+ `).join(""); +} + +async function refreshRoomMembers(roomId){ + let listBox = document.getElementById("room-members-list"); + if(!listBox) return; + + listBox.innerHTML = `

Refreshing...

`; + + try{ + let data = await apiFetch(`/rooms/${roomId}/members`); + listBox.innerHTML = renderMembersList(data.members || []); + } catch(error){ + listBox.innerHTML = `

Could not refresh members: ${error.message}

`; + } +} + +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"); - + let content = document.getElementById("content"); content.innerHTML = `

Events

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

Events

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

Events

Could not load events: ${error.message}
`; - } - } - function renderEventCards(events){ - if(events.length === 0){ - return `
No events scheduled.
`; - } - return events.map(event => ` -
-

${event.title}

-

${event.description || ""}

- -

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

- -

- status: ${event.status} -

+

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

+

status: ${event.status}

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

- +

- `).join(""); - } - function formatEventTime(start, end){ + if(!start) return "time unknown"; - 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; - - } + 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}`); - + 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)`; - + 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(`

Loading...

`); + + try{ + await ensureCurrentUser(); + + let eventDetail = await apiFetch(`/events/${eventId}`); + let isOwn = eventDetail.creator_id === currentUserId; + + openModal(` +

${eventDetail.title}

+

${formatEventTime(eventDetail.start_time, eventDetail.end_time)}

+

${eventDetail.description || ""}

+

status: ${eventDetail.status}

+ +

Responses

+

Loading responses...

+ + + `); + + 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 => `
${r.username} — ${r.status}
`).join("") + : `

No responses yet.

`; + } + } catch(error){ + let listBox = document.getElementById("event-responses-list"); + if(listBox) listBox.innerHTML = `

Could not load responses: ${error.message}

`; + } + + } catch(error){ + openModal(`

Could not load event: ${error.message}

`); + } +} + +async function openEditEventModal(eventId){ + try{ + let eventDetail = await apiFetch(`/events/${eventId}`); + + openModal(` +

Edit event

+
+ + + + + + + + + +

+ + +
+ `); + + } 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); + } +} + +// ---------- settings / accessibility ---------- + +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 = ` +

Settings

+ +
+

Account

+ +
+ +
+

Accessibility

+ + + + + + + + + + +
+ +
+

Appearance

+ +
+ +
+

Diagnostics

+ +

+ + +
+ `; +} + +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; + } +} // ---------- theme ---------- - function toggleTheme(){ - document.body.classList.toggle("dark"); - - let theme = - document.body.classList.contains("dark") - ? "dark" - : "light"; - - - localStorage.setItem( - "theme", - theme - ); - + let theme = document.body.classList.contains("dark") ? "dark" : "light"; + localStorage.setItem("theme", theme); } - - // ---------- init ---------- - -let savedTheme = - localStorage.getItem("theme"); - - +let savedTheme = localStorage.getItem("theme"); if(savedTheme === "dark"){ - document.body.classList.add("dark"); - } +applyA11ySettings(getA11ySettings()); if(getToken()){ - showApp(); - showPage("dashboard"); - } else { - showLogin(); - } \ No newline at end of file diff --git a/frontend/www/style.css b/frontend/www/style.css index ae9b186..d98d6d4 100644 --- a/frontend/www/style.css +++ b/frontend/www/style.css @@ -1,408 +1,378 @@ +/* ============================================================ + Design tokens + ------------------------------------------------------------ + Palette: warm paper background, ink-dark sidebar "spine", + deep pine accent (not the generic AI-blue). + Type: Source Serif 4 for headings (editorial, planner-like), + Inter for body copy, IBM Plex Mono for time/data. + ============================================================ */ + +@import url('https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,500;8..60,600&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap'); + :root { + /* Surfaces */ + --bg: #f7f6f3; + --panel: #ffffff; + --sidebar: #15171c; + --sidebar-hover: #1f222a; - --bg: #f4f5f7; - --panel: white; - --sidebar: #ffffff; - --text: #111; - --muted: #666; - --border: #ddd; - --accent: #2563eb; + /* Text */ + --text: #16181d; + --text-inverse: #eceae4; + --muted: #74716a; + --muted-inverse: #8b8e97; + /* Structure */ + --border: #e5e2da; + --border-strong: #d3cfc3; + + /* Accent */ + --accent: #1f5c52; + --accent-hover: #164941; + --accent-tint: #e8efec; + + /* Status (used as tint + left-bar, not solid fills) */ + --status-confirmed: #1f5c52; + --status-confirmed-tint: #e8efec; + --status-busy: #a15c14; + --status-busy-tint: #f6ede1; + --status-private: #4b4d57; + --status-private-tint: #eceae7; + + --danger: #ad2b38; + + /* Scale */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --space-6: 32px; + --space-7: 40px; + + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + + --shadow-sm: 0 1px 2px rgba(21, 23, 28, 0.06); + --shadow-md: 0 4px 16px rgba(21, 23, 28, 0.08); + + --ease: cubic-bezier(0.4, 0, 0.2, 1); + + --font-display: "Source Serif 4", Georgia, serif; + --font-body: Inter, system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, monospace; } - - .dark { + --bg: #0e0f12; + --panel: #17191d; + --sidebar: #0a0b0d; + --sidebar-hover: #161821; - --bg: #080808; - --panel: #151515; - --sidebar: #101010; - --text: #eee; - --muted: #aaa; - --border: #333; - --accent: #38bdf8; + --text: #eceae4; + --text-inverse: #16181d; + --muted: #91908a; + --muted-inverse: #6b6d75; + --border: #2a2c31; + --border-strong: #3a3d43; + + --accent: #4fa393; + --accent-hover: #63b6a5; + --accent-tint: #16221f; + + --status-confirmed: #4fa393; + --status-confirmed-tint: #16221f; + --status-busy: #d1934f; + --status-busy-tint: #26200f; + --status-private: #9294a0; + --status-private-tint: #1c1d21; + + --danger: #e2707c; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4); } - - * { box-sizing: border-box; } - - body { - margin: 0; - background: var(--bg); - color: var(--text); - - font-family: - Inter, - system-ui, - sans-serif; - - transition: 0.2s; - + font-family: var(--font-body); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + transition: background 0.2s var(--ease), color 0.2s var(--ease); } - +/* ============================================================ + Layout + ============================================================ */ .layout { - display: grid; - - grid-template-columns: 260px 1fr; - + grid-template-columns: 248px 1fr; height: 100vh; - - display: none; - } - .sidebar { - background: var(--sidebar); - - border-right: 1px solid var(--border); - - padding: 25px; - + color: var(--text-inverse); + border-right: 1px solid rgba(255, 255, 255, 0.06); + padding: var(--space-5) var(--space-4); display: flex; - flex-direction: column; - } - - .logo { - display: flex; - align-items: center; - - gap: 10px; - - margin-bottom: 40px; - + gap: var(--space-2); + margin-bottom: var(--space-6); + padding: 0 var(--space-2); } - - .logo h2 { - margin: 0; - + font-family: var(--font-display); + font-weight: 600; + font-size: 19px; + letter-spacing: -0.01em; } - - nav { - display: flex; - flex-direction: column; - - gap: 8px; - + gap: 2px; } - - nav a { - cursor: pointer; - - padding: 12px 15px; - - border-radius: 10px; - - color: var(--muted); - - transition: 0.2s; - + padding: 10px 14px; + border-radius: var(--radius-sm); + color: var(--muted-inverse); + font-size: 13.5px; + font-weight: 500; + transition: background 0.15s var(--ease), color 0.15s var(--ease); } +nav a:hover { + background: var(--sidebar-hover); + color: var(--text-inverse); +} - -nav a:hover, nav .active { - background: var(--accent); - - color: white; - + color: #ffffff; } - - .sidebar-bottom { - margin-top: auto; - + padding-top: var(--space-4); + border-top: 1px solid rgba(255, 255, 255, 0.08); } - - button { - width: 100%; - - padding: 12px; - - border-radius: 10px; - + padding: 10px 14px; + border-radius: var(--radius-sm); border: 1px solid var(--border); - background: var(--panel); - color: var(--text); - + font-family: var(--font-body); + font-size: 13.5px; + font-weight: 500; cursor: pointer; - - margin-top: 8px; - + margin-top: var(--space-2); + transition: border-color 0.15s var(--ease), background 0.15s var(--ease); } +button:hover { + border-color: var(--border-strong); + background: var(--bg); +} +button:focus-visible, +a:focus-visible, +input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.sidebar button { + background: transparent; + border-color: rgba(255, 255, 255, 0.12); + color: var(--text-inverse); +} + +.sidebar button:hover { + background: var(--sidebar-hover); + border-color: rgba(255, 255, 255, 0.2); +} /* Content */ .content { - - padding: 40px; - + padding: var(--space-6) var(--space-7); overflow-y: auto; - } - - h1 { - margin-top: 0; - + font-family: var(--font-display); + font-weight: 600; + font-size: 26px; + letter-spacing: -0.01em; } - - .card { - background: var(--panel); - border: 1px solid var(--border); - - border-radius: 16px; - - padding: 25px; - - margin-top: 20px; - - box-shadow: - 0 10px 30px rgba(0,0,0,0.05); - + border-radius: var(--radius-lg); + padding: var(--space-5); + margin-top: var(--space-4); + box-shadow: var(--shadow-sm); } - - /* forms */ input { - width: 100%; - - padding: 12px; - - border-radius: 10px; - + padding: 10px 12px; + border-radius: var(--radius-sm); border: 1px solid var(--border); - background: var(--bg); - color: var(--text); - - margin-top: 8px; - + font-family: var(--font-body); + font-size: 13.5px; + margin-top: var(--space-2); + transition: border-color 0.15s var(--ease); } - +input:focus { + border-color: var(--accent); +} form { - display: flex; - flex-direction: column; - } - - .muted { - color: var(--muted); - } - - .error { - - color: #e11d48; - + color: var(--danger); } - - /* list items */ .list-item { - - padding: 10px 15px; - + padding: 10px 14px; border: 1px solid var(--border); - - border-radius: 10px; - - margin-top: 10px; - + border-radius: var(--radius-md); + margin-top: var(--space-2); + transition: border-color 0.15s var(--ease); } - +.list-item:hover { + border-color: var(--border-strong); +} /* event actions */ .event-actions { - display: flex; - - gap: 10px; - - margin-top: 15px; - + gap: var(--space-2); + margin-top: var(--space-4); } - .event-actions button { - margin-top: 0; - } - - /* calendar */ .calendar-header { - display: flex; - align-items: center; - justify-content: space-between; - } - .calendar-header h3 { - margin: 0; - + font-family: var(--font-display); + font-weight: 600; + font-size: 17px; text-transform: capitalize; - } - .calendar-nav-btn { - width: auto; - - padding: 8px 14px; - + padding: 6px 12px; margin-top: 0; - } - .calendar-legend { - display: flex; - - gap: 18px; - - margin-top: 14px; - + gap: var(--space-5); + margin-top: var(--space-4); font-size: 12px; - color: var(--muted); - } - .legend-item { - display: flex; - align-items: center; - gap: 6px; - } - .legend-dot { - - width: 10px; - - height: 10px; - - border-radius: 50%; - + width: 8px; + height: 8px; + border-radius: 2px; display: inline-block; - } - /* week grid */ .calendar-week { display: flex; - margin-top: 16px; + margin-top: var(--space-4); overflow-x: auto; } - .calendar-gutter { - width: 55px; + width: 52px; flex-shrink: 0; padding-top: 0px; } - .calendar-hour-label { - height: 48px; /* 2 uur = 48px */ - font-size: 11px; + height: 48px; + font-family: var(--font-mono); + font-size: 10.5px; color: var(--muted); text-align: right; - padding-right: 8px; + padding-right: var(--space-2); display: flex; justify-content: flex-end; align-items: flex-start; transform: translateY(-6px); } - .calendar-days { display: flex; flex: 1; min-width: 700px; } - .calendar-day-column { flex: 1; display: flex; @@ -410,138 +380,265 @@ form { border-left: 1px solid var(--border); } - .calendar-day-header { height: 32px; text-align: center; padding: 6px 0; - font-size: 12px; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.02em; + text-transform: uppercase; color: var(--muted); } - .calendar-day-track { position: relative; - - height: 576px; /* 12 uur zichtbaar × 48px */ - - background-image: - repeating-linear-gradient( - to bottom, - var(--border) 0, - var(--border) 1px, - transparent 1px, - transparent 48px - ); + height: 576px; /* 12 uur zichtbaar x 48px */ + background-image: repeating-linear-gradient( + to bottom, + var(--border) 0, + var(--border) 1px, + transparent 1px, + transparent 48px + ); } - .calendar-event { position: absolute; - left: 4px; right: 4px; - - border-radius: 6px; - - padding: 3px 6px; - + border-radius: var(--radius-sm); + padding: 4px 8px 4px 9px; font-size: 11px; - - color: white; - + color: var(--text); cursor: pointer; - overflow: hidden; - - box-shadow: - 0 2px 6px rgba(0,0,0,0.25); - - opacity: 0.92; - - border: 1px solid rgba(255,255,255,0.4); + border-left: 3px solid transparent; + background: var(--panel); + box-shadow: var(--shadow-sm); + transition: box-shadow 0.15s var(--ease); } +.calendar-event:hover { + box-shadow: var(--shadow-md); +} .calendar-event-time { display: block; - font-size: 10px; - opacity: 0.85; + font-family: var(--font-mono); + font-size: 9.5px; + color: var(--muted); } - .calendar-event-title { display: block; font-weight: 600; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .event-confirmed { - - background: #16a34a; - + background: var(--status-confirmed-tint); + border-left-color: var(--status-confirmed); } - .event-busy { - - background: #f97316; - + background: var(--status-busy-tint); + border-left-color: var(--status-busy); } - .event-private { - - background: #6b7280; - + background: var(--status-private-tint); + border-left-color: var(--status-private); } - +.legend-dot.event-confirmed, +.legend-dot.event-busy, +.legend-dot.event-private { + border-radius: 2px; + border-left: none; +} /* login screen */ .login-screen { - height: 100vh; - width: 100%; - display: flex; - align-items: center; - justify-content: center; - + background: var(--bg); } - - .login-box { - background: var(--panel); - border: 1px solid var(--border); - - border-radius: 16px; - - padding: 40px; - + border-radius: var(--radius-lg); + padding: var(--space-6); width: 320px; - - box-shadow: - 0 10px 30px rgba(0,0,0,0.05); - + box-shadow: var(--shadow-md); } - - .login-box h2 { - margin-top: 0; - + margin-bottom: var(--space-5); + font-family: var(--font-display); + font-weight: 600; text-align: center; +} +/* ============================================================ + Modals + ============================================================ */ + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(10, 11, 13, 0.5); + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-4); + z-index: 100; +} + +.modal-box { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-5); + width: 100%; + max-width: 440px; + max-height: 85vh; + overflow-y: auto; + box-shadow: var(--shadow-md); +} + +.modal-box h2 { + margin-top: 0; + font-family: var(--font-display); + font-weight: 600; + font-size: 19px; +} + +.modal-box label { + display: block; + font-size: 12.5px; + color: var(--muted); + margin-top: var(--space-3); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + margin-top: var(--space-5); +} + +.modal-actions button { + width: auto; + margin-top: 0; + padding: 8px 16px; +} + +.danger-button { + border-color: var(--danger) !important; + color: var(--danger) !important; +} + +/* action rows above list pages */ + +.page-actions { + display: flex; + gap: var(--space-2); + margin-bottom: var(--space-2); +} + +.page-actions button { + width: auto; + margin-top: 0; +} + +/* settings */ + +.settings-row { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 13.5px; + color: var(--text); + margin-top: var(--space-3); +} + +.settings-row input[type="checkbox"] { + width: auto; + margin: 0; +} + +.settings-row select { + margin-left: auto; + width: auto; + padding: 6px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); +} + +.ascii-art-box { + font-family: var(--font-mono); + font-size: 11px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: var(--space-3); + overflow-x: auto; + margin-top: var(--space-2); +} + +/* ============================================================ + Accessibility preferences + ============================================================ */ + +body.reduced-motion, +body.reduced-motion * { + transition: none !important; + animation: none !important; +} + +body.high-contrast { + --border: #000000; + --border-strong: #000000; + --muted: #2a2a2a; +} + +body.high-contrast.dark { + --border: #ffffff; + --border-strong: #ffffff; + --muted: #e6e6e6; +} + +body.high-contrast .calendar-event { + border-left-width: 4px; +} + +body.underline-links a { + text-decoration: underline; +} + +body.always-focus a:focus, +body.always-focus button:focus, +body.always-focus input:focus, +body.always-focus select:focus { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +body.font-scale-large { + font-size: 16px; +} + +body.font-scale-xlarge { + font-size: 18px; } \ No newline at end of file