Files
calendar/frontend/index.html
T
2026-08-03 19:35:35 +02:00

877 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0b0d12" />
<title>Calendar — Rooms &amp; Events</title>
<style>
:root{
--bg:#0b0d12;
--bg-2:#11141b;
--panel:rgba(255,255,255,.045);
--panel-2:rgba(255,255,255,.07);
--border:rgba(255,255,255,.09);
--border-2:rgba(255,255,255,.16);
--fg:#e8eaf0;
--muted:#98a0b3;
--brand:#4f7cff;
--brand-2:#6b93ff;
--ok:#3ecf8e;
--warn:#f0b23c;
--bad:#f0555c;
--radius:14px;
}
*{box-sizing:border-box}
html,body{height:100%}
html{background:var(--bg)}
body{
margin:0;
background:
radial-gradient(900px 500px at 12% -8%, rgba(79,124,255,.18), transparent 60%),
radial-gradient(700px 400px at 100% 0%, rgba(62,207,142,.10), transparent 60%),
var(--bg);
color:var(--fg);
font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
line-height:1.55;
-webkit-font-smoothing:antialiased;
}
button,input,select,textarea{font:inherit;color:inherit}
.hidden{display:none !important}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
/* ---------- generic ---------- */
.glass{
background:var(--panel);
border:1px solid var(--border);
border-radius:var(--radius);
backdrop-filter:blur(14px);
-webkit-backdrop-filter:blur(14px);
}
.btn{
display:inline-flex;align-items:center;justify-content:center;gap:8px;
padding:10px 16px;border-radius:11px;border:1px solid var(--border-2);
background:var(--panel-2);cursor:pointer;font-weight:600;font-size:14px;
transition:transform .12s ease, background .18s ease, border-color .18s ease, box-shadow .18s ease;
white-space:nowrap;
}
.btn:hover{background:rgba(255,255,255,.12);transform:translateY(-1px)}
.btn:active{transform:translateY(0) scale(.985)}
.btn:focus-visible{outline:2px solid var(--brand-2);outline-offset:2px}
.btn[disabled]{opacity:.55;cursor:not-allowed;transform:none}
.btn-primary{
background:linear-gradient(180deg,var(--brand-2),var(--brand));
border-color:transparent;color:#fff;
box-shadow:0 8px 22px -10px rgba(79,124,255,.9);
}
.btn-primary:hover{background:linear-gradient(180deg,#7ea2ff,#5b86ff)}
.btn-ghost{background:transparent;border-color:var(--border)}
.btn-sm{padding:7px 12px;font-size:13px;border-radius:9px}
.btn-block{width:100%}
.btn-danger{border-color:rgba(240,85,92,.35);color:#ffb4b7;background:rgba(240,85,92,.10)}
.btn-danger:hover{background:rgba(240,85,92,.18)}
.field{display:flex;flex-direction:column;gap:6px;margin-bottom:14px}
.field label{font-size:12.5px;color:var(--muted);font-weight:600;letter-spacing:.02em}
.input{
width:100%;padding:11px 13px;border-radius:11px;
background:rgba(0,0,0,.28);border:1px solid var(--border);
transition:border-color .18s ease, box-shadow .18s ease;
}
.input:focus{outline:none;border-color:var(--brand);box-shadow:0 0 0 3px rgba(79,124,255,.18)}
textarea.input{resize:vertical;min-height:82px}
.row{display:flex;gap:12px}
.row>*{flex:1;min-width:0}
.muted{color:var(--muted)}
.small{font-size:13px}
.tiny{font-size:12px}
.badge{
display:inline-flex;align-items:center;gap:6px;
padding:3px 9px;border-radius:999px;font-size:11.5px;font-weight:700;
border:1px solid var(--border);background:rgba(255,255,255,.06);color:var(--muted);
letter-spacing:.02em;
}
.badge.going{color:#9ff0c8;border-color:rgba(62,207,142,.35);background:rgba(62,207,142,.13)}
.badge.maybe{color:#ffd894;border-color:rgba(240,178,60,.35);background:rgba(240,178,60,.13)}
.badge.declined{color:#ffb4b7;border-color:rgba(240,85,92,.35);background:rgba(240,85,92,.13)}
/* ---------- auth ---------- */
#auth{min-height:100vh;display:grid;place-items:center;padding:24px}
.auth-card{width:100%;max-width:420px;padding:28px}
.brand{display:flex;align-items:center;gap:11px;margin-bottom:20px}
.brand-mark{
width:40px;height:40px;border-radius:12px;flex:none;
background:linear-gradient(160deg,var(--brand-2),#2f57d6);
display:grid;place-items:center;color:#fff;
box-shadow:0 10px 24px -12px rgba(79,124,255,1);
}
.brand h1{font-size:19px;margin:0;letter-spacing:-.01em}
.brand p{margin:0;font-size:12.5px;color:var(--muted)}
.tabs{display:flex;gap:4px;padding:4px;border-radius:12px;background:rgba(0,0,0,.3);border:1px solid var(--border);margin-bottom:20px}
.tab{flex:1;padding:9px;border-radius:9px;border:0;background:transparent;color:var(--muted);cursor:pointer;font-weight:600;font-size:13.5px;transition:all .18s ease}
.tab[aria-selected="true"]{background:var(--panel-2);color:var(--fg)}
/* ---------- app shell ---------- */
#app{display:flex;min-height:100vh}
.sidebar{
width:268px;flex:none;padding:18px 14px;
display:flex;flex-direction:column;gap:14px;
border-right:1px solid var(--border);
background:rgba(0,0,0,.25);
backdrop-filter:blur(14px);
position:sticky;top:0;height:100vh;
}
.side-scroll{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:8px;min-height:0}
.side-label{font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);font-weight:800;margin:6px 4px 2px}
.room{
display:flex;align-items:center;gap:10px;width:100%;text-align:left;
padding:9px 11px;border-radius:11px;border:1px solid transparent;
background:transparent;cursor:pointer;font-size:13.5px;font-weight:600;color:var(--muted);
transition:all .16s ease;
}
.room:hover{background:var(--panel);color:var(--fg)}
.room[aria-current="true"]{background:var(--panel-2);border-color:var(--border-2);color:var(--fg)}
.room-dot{width:26px;height:26px;border-radius:9px;flex:none;display:grid;place-items:center;font-size:11.5px;font-weight:800;background:linear-gradient(160deg,#3b4a75,#232a3d);color:#dbe3ff}
.me{display:flex;align-items:center;gap:10px;padding:10px;border-radius:12px;background:var(--panel);border:1px solid var(--border)}
.avatar{width:34px;height:34px;border-radius:50%;flex:none;display:grid;place-items:center;font-weight:800;font-size:13px;background:linear-gradient(160deg,var(--brand-2),#2f57d6);color:#fff}
main{flex:1;min-width:0;padding:22px;display:flex;flex-direction:column;gap:18px}
header.top{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
header.top h2{margin:0;font-size:22px;letter-spacing:-.02em}
.spacer{flex:1}
.icon-btn{width:38px;height:38px;padding:0;border-radius:11px}
.grid{display:grid;gap:14px;grid-template-columns:1fr}
@media(min-width:900px){.grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(min-width:1320px){.grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
.card{padding:16px;display:flex;flex-direction:column;gap:10px;transition:transform .16s ease,border-color .16s ease}
.card:hover{transform:translateY(-2px);border-color:var(--border-2)}
.card h3{margin:0;font-size:16px;letter-spacing:-.01em}
.card-when{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted)}
.card-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:2px}
/* ---------- calendar ---------- */
.cal-head{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.cal-head strong{font-size:15px}
.cal{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px}
.cal-dow{text-align:center;font-size:11px;font-weight:800;color:var(--muted);letter-spacing:.08em;padding-bottom:4px}
.cal-cell{
min-height:78px;padding:6px;border-radius:10px;
border:1px solid var(--border);background:rgba(0,0,0,.2);
display:flex;flex-direction:column;gap:4px;cursor:pointer;
transition:border-color .16s ease,background .16s ease;
}
.cal-cell:hover{border-color:var(--border-2);background:rgba(255,255,255,.05)}
.cal-cell.out{opacity:.32}
.cal-cell.today{border-color:var(--brand);box-shadow:inset 0 0 0 1px rgba(79,124,255,.35)}
.cal-cell.sel{background:rgba(79,124,255,.14);border-color:var(--brand)}
.cal-num{font-size:12px;font-weight:700;color:var(--muted)}
.pill{
font-size:10.5px;font-weight:700;padding:2px 6px;border-radius:6px;
background:rgba(79,124,255,.22);border:1px solid rgba(79,124,255,.4);color:#cfdcff;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
@media(max-width:640px){.cal-cell{min-height:56px}.pill{display:none}.cal-cell .count{display:block}}
.count{display:none;font-size:10.5px;color:#cfdcff;font-weight:700}
/* ---------- modal ---------- */
.overlay{
position:fixed;inset:0;z-index:50;display:grid;place-items:center;padding:20px;
background:rgba(4,6,10,.66);backdrop-filter:blur(6px);
animation:fade .18s ease;
}
.modal{width:100%;max-width:460px;padding:22px;max-height:88vh;overflow-y:auto;animation:pop .2s ease}
.modal h3{margin:0 0 4px;font-size:18px}
.modal-head{display:flex;align-items:flex-start;gap:10px;margin-bottom:16px}
@keyframes fade{from{opacity:0}to{opacity:1}}
@keyframes pop{from{opacity:0;transform:translateY(10px) scale(.98)}to{opacity:1;transform:none}}
/* ---------- toast ---------- */
#toasts{position:fixed;z-index:80;right:16px;bottom:16px;display:flex;flex-direction:column;gap:8px;max-width:340px}
.toast{
padding:11px 14px;border-radius:12px;font-size:13.5px;font-weight:600;
border:1px solid var(--border-2);background:rgba(17,20,27,.96);
box-shadow:0 18px 40px -18px rgba(0,0,0,.9);animation:pop .2s ease;
}
.toast.ok{border-color:rgba(62,207,142,.4);color:#a9f0cd}
.toast.err{border-color:rgba(240,85,92,.45);color:#ffb4b7}
.empty{padding:34px 18px;text-align:center;color:var(--muted);font-size:14px}
.spin{width:15px;height:15px;border-radius:50%;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;animation:sp .7s linear infinite}
@keyframes sp{to{transform:rotate(360deg)}}
/* mobile sidebar */
.backdrop{position:fixed;inset:0;background:rgba(4,6,10,.6);z-index:39}
@media(max-width:860px){
.sidebar{position:fixed;z-index:40;left:0;top:0;transform:translateX(-102%);transition:transform .22s ease;box-shadow:0 0 60px rgba(0,0,0,.6)}
.sidebar.open{transform:none}
main{padding:16px}
header.top h2{font-size:19px}
}
@media(min-width:861px){.only-mobile{display:none !important}}
</style>
</head>
<body>
<!-- ===================== AUTH ===================== -->
<section id="auth">
<div class="glass auth-card">
<div class="brand">
<div class="brand-mark" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="4" width="18" height="18" rx="3"/><path d="M8 2v4M16 2v4M3 10h18"/></svg>
</div>
<div>
<h1>Calendar</h1>
<p>Rooms, events &amp; RSVP&apos;s</p>
</div>
</div>
<div class="tabs" role="tablist" aria-label="Authenticatie">
<button class="tab" role="tab" id="tabLogin" aria-selected="true" onclick="App.setAuthTab('login')">Inloggen</button>
<button class="tab" role="tab" id="tabRegister" aria-selected="false" onclick="App.setAuthTab('register')">Registreren</button>
</div>
<form id="loginForm" onsubmit="App.login(event)">
<div class="field">
<label for="li-user">Gebruikersnaam</label>
<input class="input" id="li-user" autocomplete="username" required />
</div>
<div class="field">
<label for="li-pass">Wachtwoord</label>
<input class="input" id="li-pass" type="password" autocomplete="current-password" required />
</div>
<button class="btn btn-primary btn-block" type="submit">Inloggen</button>
</form>
<form id="registerForm" class="hidden" onsubmit="App.register(event)">
<div class="field">
<label for="rg-user">Gebruikersnaam</label>
<input class="input" id="rg-user" autocomplete="username" required />
</div>
<div class="field">
<label for="rg-email">E-mail <span class="muted tiny">(optioneel)</span></label>
<input class="input" id="rg-email" type="email" autocomplete="email" />
</div>
<div class="field">
<label for="rg-pass">Wachtwoord</label>
<input class="input" id="rg-pass" type="password" autocomplete="new-password" minlength="4" required />
</div>
<button class="btn btn-primary btn-block" type="submit">Account aanmaken</button>
</form>
<p class="tiny muted" style="margin:16px 0 0">API: <code id="apiLabel"></code></p>
</div>
</section>
<!-- ===================== APP ===================== -->
<div id="app" class="hidden">
<div id="sidebarBackdrop" class="backdrop hidden only-mobile" onclick="App.toggleSidebar(false)"></div>
<aside class="sidebar" id="sidebar">
<div class="brand" style="margin:0">
<div class="brand-mark" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="4" width="18" height="18" rx="3"/><path d="M8 2v4M16 2v4M3 10h18"/></svg>
</div>
<div>
<h1>Calendar</h1>
<p>Single page app</p>
</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-primary btn-sm" style="flex:1" onclick="App.openModal('room')">+ Room</button>
<button class="btn btn-sm" style="flex:1" onclick="App.openModal('join')">Joinen</button>
</div>
<div class="side-scroll">
<div class="side-label">Rooms</div>
<button class="room" aria-current="true" onclick="App.selectRoom(null)">
<span class="room-dot" aria-hidden="true"></span> Alle events
</button>
<div id="roomList"></div>
</div>
<div class="me">
<div class="avatar" id="meAvatar" aria-hidden="true">?</div>
<div style="min-width:0;flex:1">
<div class="small" style="font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" id="meName"></div>
<div class="tiny muted" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap" id="meSub">ingelogd</div>
</div>
<button class="btn btn-sm btn-danger" onclick="App.logout()" title="Uitloggen" aria-label="Uitloggen">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>
</button>
</div>
</aside>
<main>
<header class="top">
<button class="btn icon-btn only-mobile" onclick="App.toggleSidebar(true)" aria-label="Menu openen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
</button>
<h2 id="viewTitle">Alle events</h2>
<span class="badge" id="eventCount">0 events</span>
<div class="spacer"></div>
<button class="btn icon-btn" onclick="App.loadEvents()" title="Verversen" aria-label="Verversen">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-3-6.7M21 3v6h-6"/></svg>
</button>
<button class="btn btn-primary" onclick="App.openModal('event')">+ Event</button>
</header>
<section class="glass" style="padding:16px" aria-label="Kalender">
<div class="cal-head">
<button class="btn btn-sm" onclick="App.shiftMonth(-1)" aria-label="Vorige maand"></button>
<strong id="calTitle"></strong>
<button class="btn btn-sm" onclick="App.shiftMonth(1)" aria-label="Volgende maand"></button>
<div class="spacer"></div>
<button class="btn btn-sm btn-ghost" onclick="App.today()">Vandaag</button>
<button class="btn btn-sm btn-ghost" id="clearDay" onclick="App.selectDay(null)">Filter wissen</button>
</div>
<div class="cal" id="calGrid"></div>
</section>
<section aria-label="Events">
<div class="grid" id="eventGrid"></div>
<div class="glass empty hidden" id="eventEmpty">Nog geen events. Maak er een aan met <strong>+ Event</strong>.</div>
</section>
</main>
</div>
<div id="modalRoot"></div>
<div id="toasts" role="status" aria-live="polite"></div>
<script>
(function () {
"use strict";
var API = "http://localhost:8000";
var TOKEN_KEY = "calendar_jwt";
var state = {
token: localStorage.getItem(TOKEN_KEY) || null,
me: null,
rooms: [],
events: [],
roomId: null,
day: null,
cursor: new Date()
};
/* ---------- helpers ---------- */
function $(id){ return document.getElementById(id); }
function esc(s){
return String(s == null ? "" : s)
.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")
.replace(/"/g,"&quot;").replace(/'/g,"&#39;");
}
function toast(msg, kind){
var el = document.createElement("div");
el.className = "toast " + (kind || "");
el.textContent = msg;
$("toasts").appendChild(el);
setTimeout(function(){
el.style.transition = "opacity .25s ease, transform .25s ease";
el.style.opacity = "0"; el.style.transform = "translateY(6px)";
setTimeout(function(){ el.remove(); }, 260);
}, 3200);
}
function busy(btn, on){
if(!btn) return;
if(on){ btn.dataset.txt = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<span class="spin"></span> Bezig…'; }
else { btn.disabled = false; if(btn.dataset.txt) btn.innerHTML = btn.dataset.txt; }
}
/* ---------- fetch wrapper ---------- */
function api(path, options){
options = options || {};
var headers = { "Accept": "application/json" };
if (options.body !== undefined) headers["Content-Type"] = "application/json";
if (state.token) headers["Authorization"] = "Bearer " + state.token;
if (options.headers) for (var k in options.headers) headers[k] = options.headers[k];
return fetch(API + path, {
method: options.method || "GET",
headers: headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined
}).then(function (res) {
return res.text().then(function (text) {
var data = null;
if (text) { try { data = JSON.parse(text); } catch (e) { data = text; } }
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
if (state.token) { clearSession(); toast("Sessie verlopen — log opnieuw in", "err"); }
}
throw new Error(errText(data) || ("HTTP " + res.status));
}
return data;
});
}, function (netErr) {
throw new Error("Kan de API niet bereiken op " + API + " (" + netErr.message + ")");
});
}
function errText(d){
if (!d) return "";
if (typeof d === "string") return d;
if (typeof d.detail === "string") return d.detail;
if (Array.isArray(d.detail)) {
return d.detail.map(function(x){ return (x.loc ? x.loc.slice(-1)[0] + ": " : "") + (x.msg || ""); }).join(", ");
}
return d.message || d.error || JSON.stringify(d);
}
/* ---------- normalising (backend field names may vary) ---------- */
function pick(o, keys){
for (var i=0;i<keys.length;i++){ if (o && o[keys[i]] != null && o[keys[i]] !== "") return o[keys[i]]; }
return null;
}
function normRoom(r){
if (typeof r === "string") return { id: r, name: r };
return {
id: pick(r, ["id","room_id","_id","uuid","code"]),
name: pick(r, ["name","room_name","title","code"]) || "Room",
code: pick(r, ["code","invite_code","join_code"]),
raw: r
};
}
function normEvent(e){
return {
id: pick(e, ["id","event_id","_id","uuid"]),
title: pick(e, ["title","name","summary"]) || "Event",
description: pick(e, ["description","desc","details","body"]) || "",
start: pick(e, ["start_time","start","starts_at","date","datetime","start_at","when"]),
end: pick(e, ["end_time","end","ends_at","end_at"]),
location: pick(e, ["location","place","where"]) || "",
roomId: pick(e, ["room_id","roomId","room"]),
myStatus: pick(e, ["my_status","status","response","my_response","rsvp"]),
responses: e && (e.responses || e.attendees || e.participants) || null,
raw: e
};
}
function toArray(d){
if (!d) return [];
if (Array.isArray(d)) return d;
var keys = ["events","rooms","items","results","data"];
for (var i=0;i<keys.length;i++){ if (Array.isArray(d[keys[i]])) return d[keys[i]]; }
if (typeof d === "object") return [d];
return [];
}
/* ---------- dates ---------- */
var DOW = ["ma","di","wo","do","vr","za","zo"];
var MONTHS = ["januari","februari","maart","april","mei","juni","juli","augustus","september","oktober","november","december"];
function parseDate(v){
if (!v) return null;
var d = new Date(v);
if (!isNaN(d.getTime())) return d;
d = new Date(String(v).replace(" ", "T"));
return isNaN(d.getTime()) ? null : d;
}
function key(d){
return d.getFullYear() + "-" + String(d.getMonth()+1).padStart(2,"0") + "-" + String(d.getDate()).padStart(2,"0");
}
function fmt(d){
if (!d) return "Geen datum";
return DOW[(d.getDay()+6)%7] + " " + d.getDate() + " " + MONTHS[d.getMonth()] +
" · " + String(d.getHours()).padStart(2,"0") + ":" + String(d.getMinutes()).padStart(2,"0");
}
/* ---------- session ---------- */
function saveToken(t){ state.token = t; localStorage.setItem(TOKEN_KEY, t); }
function clearSession(){
state.token = null; state.me = null; state.rooms = []; state.events = [];
state.roomId = null; state.day = null;
localStorage.removeItem(TOKEN_KEY);
closeModal();
$("app").classList.add("hidden");
$("auth").classList.remove("hidden");
}
/* ---------- auth views ---------- */
function setAuthTab(which){
var login = which === "login";
$("tabLogin").setAttribute("aria-selected", login ? "true" : "false");
$("tabRegister").setAttribute("aria-selected", login ? "false" : "true");
$("loginForm").classList.toggle("hidden", !login);
$("registerForm").classList.toggle("hidden", login);
}
function login(ev){
ev.preventDefault();
var btn = ev.target.querySelector("button[type=submit]");
busy(btn, true);
api("/login", { method:"POST", body:{
username: $("li-user").value.trim(),
password: $("li-pass").value
}})
.then(function(d){
var t = pick(d || {}, ["access_token","token","jwt","accessToken"]);
if (!t) throw new Error("Geen token ontvangen van /login");
saveToken(t);
toast("Welkom terug!", "ok");
return enterApp();
})
.catch(function(e){ toast(e.message, "err"); })
.then(function(){ busy(btn, false); });
}
function register(ev){
ev.preventDefault();
var btn = ev.target.querySelector("button[type=submit]");
busy(btn, true);
var body = { username: $("rg-user").value.trim(), password: $("rg-pass").value };
var email = $("rg-email").value.trim();
if (email) body.email = email;
api("/create-user", { method:"POST", body: body })
.then(function(d){
var t = pick(d || {}, ["access_token","token","jwt","accessToken"]);
if (t) { saveToken(t); toast("Account aangemaakt!", "ok"); return enterApp(); }
toast("Account aangemaakt — je kunt nu inloggen", "ok");
$("li-user").value = body.username;
setAuthTab("login");
})
.catch(function(e){ toast(e.message, "err"); })
.then(function(){ busy(btn, false); });
}
function logout(){ clearSession(); toast("Uitgelogd", "ok"); }
/* ---------- app boot ---------- */
function enterApp(){
return api("/me").then(function(me){
state.me = me || {};
var name = pick(state.me, ["username","name","email"]) || "Gebruiker";
$("meName").textContent = name;
$("meSub").textContent = pick(state.me, ["email"]) || "ingelogd";
$("meAvatar").textContent = name.charAt(0).toUpperCase();
var rooms = toArray(state.me.rooms).map(normRoom).filter(function(r){ return r.id != null; });
state.rooms = rooms;
renderRooms();
$("auth").classList.remove("hidden");
$("auth").classList.add("hidden");
$("app").classList.remove("hidden");
return loadEvents();
});
}
/* ---------- rooms ---------- */
function renderRooms(){
var host = $("roomList");
if (!state.rooms.length){
host.innerHTML = '<p class="tiny muted" style="padding:6px 10px">Nog geen rooms. Maak of join er een.</p>';
return;
}
host.innerHTML = state.rooms.map(function(r){
return '<button class="room" aria-current="' + (String(state.roomId) === String(r.id)) + '" ' +
'onclick="App.selectRoom(' + JSON.stringify(String(r.id)).replace(/"/g,"&quot;") + ')">' +
'<span class="room-dot" aria-hidden="true">' + esc(r.name.charAt(0).toUpperCase()) + '</span>' +
'<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(r.name) + '</span></button>';
}).join("");
}
function selectRoom(id){
state.roomId = id;
state.day = null;
document.querySelectorAll(".room").forEach(function(b){ b.setAttribute("aria-current","false"); });
renderRooms();
var all = document.querySelector('.side-scroll > .room');
if (all) all.setAttribute("aria-current", id == null ? "true" : "false");
var r = state.rooms.filter(function(x){ return String(x.id) === String(id); })[0];
$("viewTitle").textContent = r ? r.name : "Alle events";
toggleSidebar(false);
render();
}
function createRoom(ev){
ev.preventDefault();
var btn = ev.target.querySelector("button[type=submit]");
var name = $("mr-name").value.trim();
busy(btn, true);
api("/create-room", { method:"POST", body:{ name: name } })
.then(function(d){
var r = normRoom(d && (d.room || d) || { name: name });
if (r.id != null && !state.rooms.some(function(x){ return String(x.id)===String(r.id); })) state.rooms.push(r);
renderRooms();
closeModal();
toast("Room “" + name + "” aangemaakt", "ok");
return refreshMeRooms();
})
.catch(function(e){ toast(e.message, "err"); })
.then(function(){ busy(btn, false); });
}
function searchRoom(ev){
ev.preventDefault();
var btn = ev.target.querySelector("button[type=submit]");
var q = $("mj-query").value.trim();
busy(btn, true);
api("/search-room", { method:"POST", body:{ name: q, query: q } })
.then(function(d){
var found = toArray(d && (d.rooms || d)).map(normRoom).filter(function(r){ return r.id != null; });
var host = $("mj-results");
if (!found.length){ host.innerHTML = '<p class="small muted">Geen rooms gevonden.</p>'; return; }
host.innerHTML = found.map(function(r){
return '<div class="glass" style="padding:10px 12px;display:flex;align-items:center;gap:10px;margin-bottom:8px">' +
'<span class="room-dot" aria-hidden="true">' + esc(r.name.charAt(0).toUpperCase()) + '</span>' +
'<span class="small" style="flex:1;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(r.name) + '</span>' +
'<button class="btn btn-sm btn-primary" onclick="App.joinRoom(' + JSON.stringify(String(r.id)).replace(/"/g,"&quot;") + ',' + JSON.stringify(r.name).replace(/"/g,"&quot;") + ',this)">Join</button>' +
'</div>';
}).join("");
})
.catch(function(e){ toast(e.message, "err"); })
.then(function(){ busy(btn, false); });
}
function joinRoom(id, name, btn){
busy(btn, true);
api("/join-room", { method:"POST", body:{ room_id: id, id: id, name: name } })
.then(function(){
if (!state.rooms.some(function(x){ return String(x.id)===String(id); })) state.rooms.push({ id:id, name:name });
renderRooms();
closeModal();
toast("Joined “" + name + "”", "ok");
return loadEvents().then(refreshMeRooms);
})
.catch(function(e){ toast(e.message, "err"); busy(btn, false); });
}
function refreshMeRooms(){
return api("/me").then(function(me){
var rooms = toArray(me && me.rooms).map(normRoom).filter(function(r){ return r.id != null; });
if (rooms.length){ state.rooms = rooms; renderRooms(); }
}).catch(function(){});
}
/* ---------- events ---------- */
function loadEvents(){
return api("/events").then(function(d){
state.events = toArray(d).map(normEvent);
// derive rooms from events if /me gave none
if (!state.rooms.length){
var seen = {};
state.events.forEach(function(e){
if (e.roomId != null && !seen[e.roomId]){
seen[e.roomId] = true;
var nm = (e.raw && (e.raw.room_name || (e.raw.room && e.raw.room.name))) || ("Room " + e.roomId);
state.rooms.push({ id: e.roomId, name: String(nm) });
}
});
renderRooms();
}
render();
}).catch(function(e){ toast(e.message, "err"); });
}
function createEvent(ev){
ev.preventDefault();
var btn = ev.target.querySelector("button[type=submit]");
var startLocal = $("me-start").value;
var endLocal = $("me-end").value;
var body = {
title: $("me-title").value.trim(),
description: $("me-desc").value.trim(),
location: $("me-loc").value.trim(),
start_time: startLocal ? new Date(startLocal).toISOString() : null,
end_time: endLocal ? new Date(endLocal).toISOString() : null
};
var rid = $("me-room").value;
if (rid) body.room_id = isNaN(Number(rid)) ? rid : Number(rid);
busy(btn, true);
api("/events", { method:"POST", body: body })
.then(function(){
closeModal();
toast("Event aangemaakt", "ok");
return loadEvents();
})
.catch(function(e){ toast(e.message, "err"); })
.then(function(){ busy(btn, false); });
}
function respond(eventId, status, btn){
busy(btn, true);
api("/events/" + encodeURIComponent(eventId) + "/respond?status=" + encodeURIComponent(status), { method:"POST" })
.then(function(){
var e = state.events.filter(function(x){ return String(x.id) === String(eventId); })[0];
if (e) e.myStatus = status;
toast("Reactie opgeslagen: " + status, "ok");
render();
return loadEvents();
})
.catch(function(err){ toast(err.message, "err"); busy(btn, false); });
}
/* ---------- rendering ---------- */
function visibleEvents(){
return state.events.filter(function(e){
if (state.roomId != null && String(e.roomId) !== String(state.roomId)) return false;
if (state.day){
var d = parseDate(e.start);
if (!d || key(d) !== state.day) return false;
}
return true;
}).sort(function(a,b){
var da = parseDate(a.start), db = parseDate(b.start);
if (!da) return 1; if (!db) return -1;
return da - db;
});
}
function render(){ renderCalendar(); renderCards(); }
function renderCalendar(){
var y = state.cursor.getFullYear(), m = state.cursor.getMonth();
$("calTitle").textContent = MONTHS[m].charAt(0).toUpperCase() + MONTHS[m].slice(1) + " " + y;
$("clearDay").classList.toggle("hidden", !state.day);
var byDay = {};
state.events.forEach(function(e){
if (state.roomId != null && String(e.roomId) !== String(state.roomId)) return;
var d = parseDate(e.start); if (!d) return;
(byDay[key(d)] = byDay[key(d)] || []).push(e);
});
var first = new Date(y, m, 1);
var offset = (first.getDay() + 6) % 7;
var start = new Date(y, m, 1 - offset);
var todayKey = key(new Date());
var html = DOW.map(function(d){ return '<div class="cal-dow">' + d + '</div>'; }).join("");
for (var i = 0; i < 42; i++){
var d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
var k = key(d);
var list = byDay[k] || [];
var cls = "cal-cell";
if (d.getMonth() !== m) cls += " out";
if (k === todayKey) cls += " today";
if (state.day === k) cls += " sel";
html += '<button class="' + cls + '" onclick="App.selectDay(' + JSON.stringify(k) + ')" aria-label="' +
d.getDate() + " " + MONTHS[d.getMonth()] + ", " + list.length + ' events">' +
'<span class="cal-num">' + d.getDate() + '</span>' +
list.slice(0,2).map(function(e){ return '<span class="pill">' + esc(e.title) + '</span>'; }).join("") +
(list.length > 2 ? '<span class="pill">+' + (list.length - 2) + '</span>' : "") +
(list.length ? '<span class="count">' + list.length + ' ●</span>' : "") +
'</button>';
}
$("calGrid").innerHTML = html;
}
function renderCards(){
var list = visibleEvents();
$("eventCount").textContent = list.length + (list.length === 1 ? " event" : " events");
$("eventEmpty").classList.toggle("hidden", list.length > 0);
$("eventGrid").innerHTML = list.map(function(e){
var d = parseDate(e.start);
var room = state.rooms.filter(function(r){ return String(r.id) === String(e.roomId); })[0];
var st = e.myStatus ? String(e.myStatus).toLowerCase() : "";
var idArg = JSON.stringify(String(e.id)).replace(/"/g,"&quot;");
return '<article class="glass card">' +
'<div style="display:flex;align-items:flex-start;gap:8px">' +
'<h3 style="flex:1">' + esc(e.title) + '</h3>' +
(st ? '<span class="badge ' + esc(st) + '">' + esc(st) + '</span>' : "") +
'</div>' +
'<div class="card-when">' +
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>' +
esc(fmt(d)) +
'</div>' +
(e.location ? '<div class="card-when"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>' + esc(e.location) + '</div>' : "") +
(e.description ? '<p class="small muted" style="margin:0">' + esc(e.description) + '</p>' : "") +
(room ? '<span class="badge">' + esc(room.name) + '</span>' : "") +
(e.id != null ?
'<div class="card-actions">' +
'<button class="btn btn-sm btn-primary" onclick="App.respond(' + idArg + ',&quot;going&quot;,this)">Ga ik</button>' +
'<button class="btn btn-sm" onclick="App.respond(' + idArg + ',&quot;maybe&quot;,this)">Misschien</button>' +
'<button class="btn btn-sm btn-danger" onclick="App.respond(' + idArg + ',&quot;declined&quot;,this)">Kan niet</button>' +
'</div>' : "") +
'</article>';
}).join("");
}
function selectDay(k){ state.day = (state.day === k) ? null : k; render(); }
function shiftMonth(n){ state.cursor = new Date(state.cursor.getFullYear(), state.cursor.getMonth() + n, 1); renderCalendar(); }
function today(){ state.cursor = new Date(); state.day = key(new Date()); render(); }
/* ---------- modals ---------- */
function closeModal(){ $("modalRoot").innerHTML = ""; document.removeEventListener("keydown", escClose); }
function escClose(e){ if (e.key === "Escape") closeModal(); }
function shell(title, sub, inner){
return '<div class="overlay" onclick="if(event.target===this)App.closeModal()">' +
'<div class="glass modal" role="dialog" aria-modal="true" aria-label="' + esc(title) + '">' +
'<div class="modal-head"><div style="flex:1"><h3>' + esc(title) + '</h3>' +
'<p class="tiny muted" style="margin:0">' + esc(sub) + '</p></div>' +
'<button class="btn btn-sm btn-ghost" onclick="App.closeModal()" aria-label="Sluiten">✕</button></div>' +
inner +
'</div></div>';
}
function openModal(which){
var html = "";
if (which === "room"){
html = shell("Nieuwe room", "Maak een ruimte voor jullie events",
'<form onsubmit="App.createRoom(event)">' +
'<div class="field"><label for="mr-name">Room naam</label><input class="input" id="mr-name" required autofocus /></div>' +
'<button class="btn btn-primary btn-block" type="submit">Room aanmaken</button>' +
'</form>');
} else if (which === "join"){
html = shell("Room joinen", "Zoek op naam en sluit je aan",
'<form onsubmit="App.searchRoom(event)">' +
'<div class="field"><label for="mj-query">Zoeken</label><input class="input" id="mj-query" placeholder="Room naam…" required autofocus /></div>' +
'<button class="btn btn-primary btn-block" type="submit">Zoeken</button>' +
'</form><div id="mj-results" style="margin-top:16px"></div>');
} else {
var now = new Date(); now.setMinutes(0,0,0); now.setHours(now.getHours()+1);
var later = new Date(now.getTime() + 3600000);
var localVal = function(d){
return d.getFullYear() + "-" + String(d.getMonth()+1).padStart(2,"0") + "-" + String(d.getDate()).padStart(2,"0") +
"T" + String(d.getHours()).padStart(2,"0") + ":" + String(d.getMinutes()).padStart(2,"0");
};
var opts = '<option value="">— geen room —</option>' + state.rooms.map(function(r){
return '<option value="' + esc(r.id) + '"' + (String(r.id)===String(state.roomId) ? " selected" : "") + '>' + esc(r.name) + '</option>';
}).join("");
html = shell("Nieuw event", "Plan iets met je room",
'<form onsubmit="App.createEvent(event)">' +
'<div class="field"><label for="me-title">Titel</label><input class="input" id="me-title" required autofocus /></div>' +
'<div class="row"><div class="field"><label for="me-start">Start</label><input class="input" id="me-start" type="datetime-local" value="' + localVal(now) + '" required /></div>' +
'<div class="field"><label for="me-end">Einde</label><input class="input" id="me-end" type="datetime-local" value="' + localVal(later) + '" /></div></div>' +
'<div class="field"><label for="me-loc">Locatie</label><input class="input" id="me-loc" placeholder="Optioneel" /></div>' +
'<div class="field"><label for="me-room">Room</label><select class="input" id="me-room">' + opts + '</select></div>' +
'<div class="field"><label for="me-desc">Beschrijving</label><textarea class="input" id="me-desc" placeholder="Optioneel"></textarea></div>' +
'<button class="btn btn-primary btn-block" type="submit">Event aanmaken</button>' +
'</form>');
}
$("modalRoot").innerHTML = html;
document.addEventListener("keydown", escClose);
var f = $("modalRoot").querySelector("input,select,textarea");
if (f) f.focus();
}
function toggleSidebar(open){
$("sidebar").classList.toggle("open", !!open);
$("sidebarBackdrop").classList.toggle("hidden", !open);
}
/* ---------- init ---------- */
window.App = {
setAuthTab: setAuthTab, login: login, register: register, logout: logout,
selectRoom: selectRoom, createRoom: createRoom, searchRoom: searchRoom, joinRoom: joinRoom,
loadEvents: loadEvents, createEvent: createEvent, respond: respond,
selectDay: selectDay, shiftMonth: shiftMonth, today: today,
openModal: openModal, closeModal: closeModal, toggleSidebar: toggleSidebar
};
$("apiLabel").textContent = API;
setAuthTab("login");
if (state.token){
enterApp().catch(function(){ clearSession(); });
}
})();
</script>
</body>
</html>