Files
calendar/frontend/www/app.js
T
2026-08-04 16:45:20 +02:00

1144 lines
21 KiB
JavaScript

const API_BASE =
"http://localhost:8000";
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;
}
// ---------- 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"){
content.innerHTML = `
<h1>Settings</h1>
<div class="card">
Account preferences.
<br><br>
<button onclick="logout()">Log out</button>
</div>
`;
}
}
// ---------- dashboard ----------
let calendarWeekOffset = 0;
let cachedDashboardEvents = [];
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 || [];
let eventsData =
await apiFetch("/events");
cachedDashboardEvents =
eventsData.events || [];
let roomsHtml =
rooms.length
? rooms.map(room => `
<div class="list-item">
${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, ${me.username}</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)">
<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{
await apiFetch(
"/join-room",
{
method: "POST",
body: JSON.stringify({ invite_code: inviteCode })
}
);
messageBox.textContent = "joined room";
loadDashboard();
} catch(error){
messageBox.textContent = error.message;
}
}
// ---------- calendar (week view) ----------
const CALENDAR_HOUR_HEIGHT = 48;
const CALENDAR_START_HOUR = 0;
const CALENDAR_END_HOUR = 24;
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";
}
if((event.status || "").toLowerCase() === "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_HOUR_HEIGHT;
// time gutter
let hourRows = "";
for(let h = CALENDAR_START_HOUR; h < CALENDAR_END_HOUR; h++){
hourRows += `
<div class="calendar-hour-label" style="height:${CALENDAR_HOUR_HEIGHT}px">
${String(h).padStart(2, "0")}:00
</div>
`;
}
// day columns
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));
// assign overlap layers: an event gets the lowest layer
// whose most recent block has already ended
let layerEnds = [];
let placed =
dayEvents.map(event => {
let start =
new Date(event.start_time);
let end =
event.end_time
? new Date(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 topPct =
(p.startMinutes / totalMinutes) * 100;
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("default", { hour: "2-digit", minute: "2-digit" });
return `
<div
class="calendar-event ${colorClass}"
style="
top:${topPct}%;
height:${heightPct}%;
left:${leftPct}%;
width:${widthPct}%;
z-index:${p.layer + 1};
"
onclick="showPage('events')"
title="${p.event.title}"
>
<span class="calendar-event-time">${timeLabel}</span>
<span class="calendar-event-title">${p.event.title}</span>
</div>
`;
}).join("");
let isToday =
(
day.getFullYear() === today.getFullYear()
&& day.getMonth() === today.getMonth()
&& day.getDate() === today.getDate()
);
return `
<div class="calendar-day-column">
<div class="calendar-day-header ${isToday ? "today" : ""}">
<div class="calendar-day-name">${dayNames[days.indexOf(day)]}</div>
<div class="calendar-day-number">${day.getDate()}</div>
</div>
<div class="calendar-day-track" style="height:${trackHeight}px">
${blocksHtml}
</div>
</div>
`;
}).join("");
return `
<div class="calendar-header">
<button class="calendar-nav-btn" onclick="changeCalendarWeek(-1)">
&lt;
</button>
<h3>${rangeLabel}</h3>
<button class="calendar-nav-btn" onclick="changeCalendarWeek(1)">
&gt;
</button>
</div>
<div class="calendar-legend">
<span class="legend-item">
<span class="legend-dot event-confirmed"></span> confirmed
</span>
<span class="legend-item">
<span class="legend-dot event-busy"></span> busy
</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{
let roomsData =
await apiFetch("/rooms");
let rooms =
roomsData.rooms || [];
if(rooms.length === 0){
content.innerHTML = `
<h1>Rooms</h1>
<div class="card">
Your shared rooms appear here.
</div>
`;
return;
}
let roomsHtml =
rooms.map(room => `
<div class="card">
<h3>${room.room_name}</h3>
<p class="muted">invite code: ${room.invite_code || "-"}</p>
<button onclick="loadRoomEvents(${room.id})">
View events
</button>
</div>
`).join("");
content.innerHTML = `
<h1>Rooms</h1>
${roomsHtml}
`;
} catch(error){
content.innerHTML = `
<h1>Rooms</h1>
<div class="card">Could not load rooms: ${error.message}</div>
`;
}
}
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>
${renderEventCards(events)}
`;
} catch(error){
content.innerHTML = `
<h1>Room events</h1>
<div class="card">Could not load events: ${error.message}</div>
`;
}
}
// ---------- 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>
${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>
</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;
}
}
// ---------- 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");
}
if(getToken()){
showApp();
showPage("dashboard");
} else {
showLogin();
}