877 lines
39 KiB
HTML
877 lines
39 KiB
HTML
<!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 & 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 & RSVP'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,"&").replace(/</g,"<").replace(/>/g,">")
|
||
.replace(/"/g,""").replace(/'/g,"'");
|
||
}
|
||
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,""") + ')">' +
|
||
'<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,""") + ',' + JSON.stringify(r.name).replace(/"/g,""") + ',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,""");
|
||
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 + ',"going",this)">Ga ik</button>' +
|
||
'<button class="btn btn-sm" onclick="App.respond(' + idArg + ',"maybe",this)">Misschien</button>' +
|
||
'<button class="btn btn-sm btn-danger" onclick="App.respond(' + idArg + ',"declined",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>
|