new commit

This commit is contained in:
ben
2026-08-03 19:35:35 +02:00
parent 0cea076e61
commit 8d115a87f6
4 changed files with 908 additions and 1 deletions
+876
View File
@@ -0,0 +1,876 @@
<!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>