new commit
This commit is contained in:
Vendored
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "msedge",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Open index.html",
|
||||||
|
"file": "/home/ben/Desktop/calender/frontend/index.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+17
-1
@@ -351,4 +351,20 @@ print()
|
|||||||
print("YOU CAN NOT START THE SERVER WITH PYTHON")
|
print("YOU CAN NOT START THE SERVER WITH PYTHON")
|
||||||
print("start with:")
|
print("start with:")
|
||||||
print("$ bash start_server.sh")
|
print("$ bash start_server.sh")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Calendar API",
|
||||||
|
description="Room based agenda system",
|
||||||
|
version="1.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # later vervangen door je domein
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
Binary file not shown.
@@ -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 & 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>
|
||||||
Reference in New Issue
Block a user