588 lines
20 KiB
HTML
588 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="nl">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Calendar</title>
|
|
<style>
|
|
:root {
|
|
--bg: #ffffff;
|
|
--fg: #18181b;
|
|
--muted: #71717a;
|
|
--line: #e4e4e7;
|
|
--soft: #f4f4f5;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
color: var(--fg);
|
|
background: var(--bg);
|
|
}
|
|
h1, h2, h3 { font-weight: 600; margin: 0; }
|
|
h1 { font-size: 18px; }
|
|
h2 { font-size: 15px; }
|
|
input, select, textarea, button { font: inherit; color: inherit; }
|
|
input, select, textarea {
|
|
width: 100%;
|
|
padding: 8px 10px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
background: var(--bg);
|
|
}
|
|
textarea { resize: vertical; }
|
|
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
|
.field { margin-bottom: 12px; }
|
|
button {
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
background: var(--bg);
|
|
cursor: pointer;
|
|
}
|
|
button:hover { background: var(--soft); }
|
|
button.primary { background: var(--fg); color: #fff; border-color: var(--fg); }
|
|
button.primary:hover { opacity: .9; }
|
|
button.link { border: none; background: none; padding: 0; color: var(--muted); text-decoration: underline; }
|
|
button:disabled { opacity: .5; cursor: default; }
|
|
.row { display: flex; gap: 8px; align-items: center; }
|
|
.muted { color: var(--muted); }
|
|
.small { font-size: 12px; }
|
|
|
|
/* auth */
|
|
#auth { max-width: 340px; margin: 12vh auto; padding: 0 20px; }
|
|
#auth .tabs { display: flex; gap: 16px; margin: 20px 0 16px; }
|
|
#auth .tabs button { border: none; background: none; padding: 0 0 6px; border-bottom: 2px solid transparent; }
|
|
#auth .tabs button[aria-selected="true"] { border-bottom-color: var(--fg); }
|
|
|
|
/* app */
|
|
#app { display: none; }
|
|
header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
gap: 12px; padding: 12px 20px; border-bottom: 1px solid var(--line);
|
|
}
|
|
main { display: flex; align-items: flex-start; }
|
|
aside { width: 220px; flex: none; padding: 20px; border-right: 1px solid var(--line); min-height: calc(100vh - 53px); }
|
|
section.content { flex: 1; padding: 20px; min-width: 0; }
|
|
aside ul { list-style: none; margin: 8px 0 12px; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
aside li button { width: 100%; text-align: left; border: none; background: none; border-radius: 6px; padding: 6px 8px; }
|
|
aside li button[aria-current="true"] { background: var(--soft); font-weight: 600; }
|
|
|
|
.day { margin-bottom: 20px; }
|
|
.day > h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 8px; }
|
|
.event { border: 1px solid var(--line); border-radius: 8px; padding: 12px; margin-bottom: 8px; }
|
|
.event .top { display: flex; justify-content: space-between; gap: 12px; }
|
|
.event p { margin: 4px 0 0; color: var(--muted); }
|
|
.event .actions { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
|
|
.event .actions button { padding: 4px 10px; font-size: 12px; }
|
|
.event .actions button[aria-pressed="true"] { background: var(--fg); color: #fff; border-color: var(--fg); }
|
|
|
|
dialog { border: 1px solid var(--line); border-radius: 10px; padding: 20px; width: min(420px, calc(100vw - 32px)); }
|
|
dialog::backdrop { background: rgb(0 0 0 / .35); }
|
|
dialog h2 { margin-bottom: 16px; }
|
|
dialog .cols { display: flex; gap: 8px; }
|
|
dialog .cols > * { flex: 1; min-width: 0; }
|
|
dialog footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
|
|
|
#toast {
|
|
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
|
|
background: var(--fg); color: #fff; padding: 8px 14px; border-radius: 6px;
|
|
font-size: 13px; display: none; max-width: 90vw;
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
main { flex-direction: column; }
|
|
aside { width: 100%; min-height: 0; border-right: none; border-bottom: 1px solid var(--line); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- ---------- auth ---------- -->
|
|
<div id="auth">
|
|
<h1>Calendar</h1>
|
|
<p class="muted small">Room based agenda</p>
|
|
|
|
<div class="tabs">
|
|
<button id="tab-login" aria-selected="true">Inloggen</button>
|
|
<button id="tab-register" aria-selected="false">Registreren</button>
|
|
</div>
|
|
|
|
<form id="auth-form">
|
|
<div class="field">
|
|
<label for="au">Gebruikersnaam</label>
|
|
<input id="au" autocomplete="username" required />
|
|
</div>
|
|
<div class="field">
|
|
<label for="ap">Wachtwoord</label>
|
|
<input id="ap" type="password" autocomplete="current-password" required />
|
|
</div>
|
|
<div class="field">
|
|
<label for="api">API</label>
|
|
<input id="api" />
|
|
</div>
|
|
<button class="primary" type="submit" id="auth-submit" style="width:100%">Inloggen</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- ---------- app ---------- -->
|
|
<div id="app">
|
|
<header>
|
|
<div class="row">
|
|
<h1>Calendar</h1>
|
|
<span class="muted small" id="me"></span>
|
|
</div>
|
|
<div class="row">
|
|
<button id="new-event" class="primary">Nieuw event</button>
|
|
<button id="logout" class="link">Uitloggen</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main>
|
|
<aside>
|
|
<h2>Rooms</h2>
|
|
<ul id="rooms"></ul>
|
|
<div class="row">
|
|
<button id="add-room">Nieuw</button>
|
|
<button id="join-room">Join</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<section class="content">
|
|
<div class="row" style="justify-content:space-between;margin-bottom:16px">
|
|
<h2 id="list-title">Events</h2>
|
|
<div class="row">
|
|
<button id="delete-room" class="link" style="display:none">Room verwijderen</button>
|
|
<button id="refresh" class="link">Verversen</button>
|
|
</div>
|
|
</div>
|
|
<div id="events"></div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- ---------- dialogs ---------- -->
|
|
<dialog id="dlg-room">
|
|
<form id="form-room">
|
|
<h2>Nieuwe room</h2>
|
|
<div class="field">
|
|
<label for="r-name">Naam</label>
|
|
<input id="r-name" required />
|
|
</div>
|
|
<div class="field">
|
|
<label for="r-code">Invite code</label>
|
|
<input id="r-code" required />
|
|
</div>
|
|
<footer>
|
|
<button type="button" data-close>Annuleren</button>
|
|
<button class="primary" type="submit">Aanmaken</button>
|
|
</footer>
|
|
</form>
|
|
</dialog>
|
|
|
|
<dialog id="dlg-join">
|
|
<form id="form-join">
|
|
<h2>Room joinen</h2>
|
|
<div class="field">
|
|
<label for="j-code">Invite code</label>
|
|
<input id="j-code" required />
|
|
</div>
|
|
<p class="small muted" id="j-found"></p>
|
|
<footer>
|
|
<button type="button" id="j-search">Zoeken</button>
|
|
<button type="button" data-close>Annuleren</button>
|
|
<button class="primary" type="submit">Joinen</button>
|
|
</footer>
|
|
</form>
|
|
</dialog>
|
|
|
|
<dialog id="dlg-event">
|
|
<form id="form-event">
|
|
<h2>Nieuw event</h2>
|
|
<div class="field">
|
|
<label for="e-title">Titel</label>
|
|
<input id="e-title" required />
|
|
</div>
|
|
<div class="field">
|
|
<label for="e-desc">Beschrijving</label>
|
|
<textarea id="e-desc" rows="2"></textarea>
|
|
</div>
|
|
<div class="field cols">
|
|
<div>
|
|
<label for="e-date">Datum</label>
|
|
<input id="e-date" type="date" required />
|
|
</div>
|
|
<div>
|
|
<label for="e-start">Van</label>
|
|
<input id="e-start" type="time" value="19:00" required />
|
|
</div>
|
|
<div>
|
|
<label for="e-end">Tot</label>
|
|
<input id="e-end" type="time" value="21:00" required />
|
|
</div>
|
|
</div>
|
|
<div class="field cols">
|
|
<div>
|
|
<label for="e-type">Type</label>
|
|
<select id="e-type">
|
|
<option value="proposal">proposal</option>
|
|
<option value="event">event</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="e-status">Status</label>
|
|
<select id="e-status">
|
|
<option value="proposed">proposed</option>
|
|
<option value="confirmed">confirmed</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="e-min">Min.</label>
|
|
<input id="e-min" type="number" min="1" value="1" />
|
|
</div>
|
|
</div>
|
|
<footer>
|
|
<button type="button" data-close>Annuleren</button>
|
|
<button class="primary" type="submit">Aanmaken</button>
|
|
</footer>
|
|
</form>
|
|
</dialog>
|
|
|
|
<div id="toast" role="status" aria-live="polite"></div>
|
|
|
|
<script>
|
|
(() => {
|
|
"use strict";
|
|
|
|
const DEFAULT_API = "http://192.168.254.244:8000";
|
|
const LS = { token: "cal_token", api: "cal_api", rooms: "cal_rooms" };
|
|
|
|
let api = localStorage.getItem(LS.api) || DEFAULT_API;
|
|
let token = localStorage.getItem(LS.token) || "";
|
|
let userId = null;
|
|
let mode = "login";
|
|
let rooms = []; // [{ room_id, room_name }]
|
|
let activeRoom = null;
|
|
let events = [];
|
|
const answered = {}; // event_id -> status
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
|
|
let toastTimer;
|
|
function toast(msg) {
|
|
const t = $("toast");
|
|
t.textContent = msg;
|
|
t.style.display = "block";
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => (t.style.display = "none"), 2600);
|
|
}
|
|
|
|
// ---------- api ----------
|
|
async function call(path, { method = "GET", body } = {}) {
|
|
const res = await fetch(api.replace(/\/+$/, "") + path, {
|
|
method,
|
|
headers: {
|
|
...(body ? { "Content-Type": "application/json" } : {}),
|
|
...(token ? { Authorization: "Bearer " + token } : {}),
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
|
|
let data = null;
|
|
try { data = await res.json(); } catch {}
|
|
|
|
if (res.status === 401 || res.status === 403) { logout(); throw new Error("Sessie verlopen"); }
|
|
if (!res.ok) {
|
|
const d = data && data.detail;
|
|
throw new Error(Array.isArray(d) ? (d[0] && d[0].msg) || "Ongeldige invoer" : d || "Er ging iets mis");
|
|
}
|
|
// the API reports business errors with HTTP 200
|
|
if (data && ["failed", "denied", "not_found", "error"].includes(data.status)) {
|
|
throw new Error(data.reason || data.status);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
// ---------- rooms (no "my rooms" endpoint, so cache locally) ----------
|
|
function loadRooms() {
|
|
try { rooms = JSON.parse(localStorage.getItem(LS.rooms + userId) || "[]"); } catch { rooms = []; }
|
|
}
|
|
function addRoom(room_id, room_name) {
|
|
if (!rooms.some((r) => r.room_id === room_id)) rooms.push({ room_id, room_name });
|
|
localStorage.setItem(LS.rooms + userId, JSON.stringify(rooms));
|
|
}
|
|
|
|
// ---------- dates ("2026-08-10 19:00", sometimes malformed) ----------
|
|
function parse(str) {
|
|
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{2})/.exec(String(str || ""));
|
|
if (!m) return null;
|
|
const [, y, mo, d, h, mi] = m.slice(0, 6).map(Number);
|
|
if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
|
|
return new Date(y, mo - 1, d, h, mi);
|
|
}
|
|
const dayKey = (dt) => (dt ? dt.getFullYear() + "-" + String(dt.getMonth() + 1).padStart(2, "0") + "-" + String(dt.getDate()).padStart(2, "0") : "zzzz");
|
|
const dayLabel = (dt) => (dt ? dt.toLocaleDateString("nl-NL", { weekday: "long", day: "numeric", month: "long" }) : "Onbekende datum");
|
|
const timeLabel = (dt) => (dt ? dt.toLocaleTimeString("nl-NL", { hour: "2-digit", minute: "2-digit" }) : "--:--");
|
|
|
|
// ---------- auth ----------
|
|
function setMode(next) {
|
|
mode = next;
|
|
$("tab-login").setAttribute("aria-selected", String(next === "login"));
|
|
$("tab-register").setAttribute("aria-selected", String(next === "register"));
|
|
$("auth-submit").textContent = next === "login" ? "Inloggen" : "Account aanmaken";
|
|
$("ap").autocomplete = next === "login" ? "current-password" : "new-password";
|
|
}
|
|
$("tab-login").onclick = () => setMode("login");
|
|
$("tab-register").onclick = () => setMode("register");
|
|
|
|
$("auth-form").onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
api = $("api").value.trim() || DEFAULT_API;
|
|
localStorage.setItem(LS.api, api);
|
|
|
|
const body = { username: $("au").value.trim(), password: $("ap").value };
|
|
const btn = $("auth-submit");
|
|
btn.disabled = true;
|
|
try {
|
|
if (mode === "register") {
|
|
await call("/create-user", { method: "POST", body });
|
|
toast("Account aangemaakt, log nu in");
|
|
setMode("login");
|
|
} else {
|
|
const data = await call("/login", { method: "POST", body });
|
|
token = data.access_token;
|
|
localStorage.setItem(LS.token, token);
|
|
await start(body.username);
|
|
}
|
|
} catch (err) {
|
|
toast(err.message);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
};
|
|
|
|
function logout() {
|
|
token = "";
|
|
userId = null;
|
|
localStorage.removeItem(LS.token);
|
|
$("app").style.display = "none";
|
|
$("auth").style.display = "block";
|
|
}
|
|
$("logout").onclick = logout;
|
|
|
|
async function start(username) {
|
|
const me = await call("/me"); // -> { user_id }
|
|
userId = me.user_id;
|
|
loadRooms();
|
|
$("me").textContent = username ? username + " · #" + userId : "#" + userId;
|
|
$("auth").style.display = "none";
|
|
$("app").style.display = "block";
|
|
await refresh();
|
|
}
|
|
|
|
// ---------- render ----------
|
|
function renderRooms() {
|
|
$("rooms").innerHTML = rooms.length
|
|
? rooms.map((r) => `<li><button data-room="${r.room_id}" aria-current="${r.room_id === activeRoom}">${esc(r.room_name)}</button></li>`).join("")
|
|
: '<li class="muted small">Nog geen rooms</li>';
|
|
|
|
const room = rooms.find((r) => r.room_id === activeRoom);
|
|
$("list-title").textContent = room ? room.room_name : "Alle events";
|
|
$("delete-room").style.display = room ? "inline" : "none";
|
|
}
|
|
|
|
function renderEvents() {
|
|
const list = activeRoom ? events.filter((e) => e.room_id === activeRoom) : events;
|
|
if (!list.length) {
|
|
$("events").innerHTML = '<p class="muted">Geen events.</p>';
|
|
return;
|
|
}
|
|
|
|
const groups = new Map();
|
|
for (const ev of list) {
|
|
const dt = parse(ev.start_time);
|
|
const key = dayKey(dt);
|
|
if (!groups.has(key)) groups.set(key, { dt, items: [] });
|
|
groups.get(key).items.push(ev);
|
|
}
|
|
|
|
$("events").innerHTML = [...groups.entries()]
|
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
.map(([, g]) => {
|
|
const cards = g.items.map((ev) => {
|
|
const btn = (v, label) => `<button data-respond="${ev.id}" data-status="${v}" aria-pressed="${answered[ev.id] === v}">${label}</button>`;
|
|
return `
|
|
<article class="event">
|
|
<div class="top">
|
|
<strong>${esc(ev.title)}</strong>
|
|
<span class="muted small">${timeLabel(parse(ev.start_time))}–${timeLabel(parse(ev.end_time))}</span>
|
|
</div>
|
|
${ev.description ? `<p class="small">${esc(ev.description)}</p>` : ""}
|
|
<p class="small">${esc(ev.type)} · ${esc(ev.status)} · min. ${ev.min_people}${ev.going != null ? " · " + ev.going + " gaan" : ""}</p>
|
|
<div class="actions">${btn("going", "Ga mee")}${btn("maybe", "Misschien")}${btn("declined", "Kan niet")}</div>
|
|
</article>`;
|
|
}).join("");
|
|
return `<div class="day"><h3>${esc(dayLabel(g.dt))}</h3>${cards}</div>`;
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
async function refresh() {
|
|
try {
|
|
const data = await call("/events"); // -> { events: [...] }
|
|
events = data.events || [];
|
|
for (const ev of events) addRoom(ev.room_id, "Room " + ev.room_id);
|
|
if (activeRoom && !rooms.some((r) => r.room_id === activeRoom)) activeRoom = null;
|
|
renderRooms();
|
|
renderEvents();
|
|
} catch (err) {
|
|
toast(err.message);
|
|
}
|
|
}
|
|
$("refresh").onclick = refresh;
|
|
|
|
// ---------- interactions ----------
|
|
$("rooms").onclick = (e) => {
|
|
const b = e.target.closest("[data-room]");
|
|
if (!b) return;
|
|
const id = Number(b.dataset.room);
|
|
activeRoom = activeRoom === id ? null : id;
|
|
renderRooms();
|
|
renderEvents();
|
|
};
|
|
|
|
$("events").onclick = async (e) => {
|
|
const b = e.target.closest("[data-respond]");
|
|
if (!b) return;
|
|
const id = Number(b.dataset.respond);
|
|
b.disabled = true;
|
|
try {
|
|
const data = await call(`/events/${id}/respond?status=${encodeURIComponent(b.dataset.status)}`, { method: "POST" });
|
|
answered[id] = b.dataset.status;
|
|
const ev = events.find((x) => x.id === id);
|
|
if (ev && data.going != null) ev.going = data.going;
|
|
renderEvents();
|
|
} catch (err) {
|
|
toast(err.message);
|
|
b.disabled = false;
|
|
}
|
|
};
|
|
|
|
document.querySelectorAll("[data-close]").forEach((b) => (b.onclick = () => b.closest("dialog").close()));
|
|
|
|
// create room -> { status:"created", room_id:{ room_id, member_id } }
|
|
$("add-room").onclick = () => {
|
|
$("r-name").value = "";
|
|
$("r-code").value = Math.random().toString(36).slice(2, 8).toUpperCase();
|
|
$("dlg-room").showModal();
|
|
};
|
|
$("form-room").onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
const name = $("r-name").value.trim();
|
|
try {
|
|
const data = await call("/create-room", { method: "POST", body: { room_name: name, invite_code: $("r-code").value.trim() } });
|
|
const id = data.room_id && data.room_id.room_id;
|
|
addRoom(id, name);
|
|
activeRoom = id;
|
|
$("dlg-room").close();
|
|
toast("Room aangemaakt");
|
|
renderRooms();
|
|
renderEvents();
|
|
} catch (err) {
|
|
toast(err.message);
|
|
}
|
|
};
|
|
|
|
// search + join room (both take { invite_code })
|
|
$("join-room").onclick = () => {
|
|
$("j-code").value = "";
|
|
$("j-found").textContent = "";
|
|
$("dlg-join").showModal();
|
|
};
|
|
$("j-search").onclick = async () => {
|
|
try {
|
|
const data = await call("/search-room", { method: "POST", body: { invite_code: $("j-code").value.trim() } });
|
|
$("j-found").textContent = "Gevonden: " + data.room_name;
|
|
} catch {
|
|
$("j-found").textContent = "Niet gevonden";
|
|
}
|
|
};
|
|
$("form-join").onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
const code = $("j-code").value.trim();
|
|
try {
|
|
const data = await call("/join-room", { method: "POST", body: { invite_code: code } });
|
|
let name = "Room " + data.room_id;
|
|
try { name = (await call("/search-room", { method: "POST", body: { invite_code: code } })).room_name || name; } catch {}
|
|
addRoom(data.room_id, name);
|
|
activeRoom = data.room_id;
|
|
$("dlg-join").close();
|
|
toast("Room gejoind");
|
|
await refresh();
|
|
} catch (err) {
|
|
toast(err.message);
|
|
}
|
|
};
|
|
|
|
// delete room
|
|
$("delete-room").onclick = async () => {
|
|
if (!activeRoom || !confirm("Deze room verwijderen?")) return;
|
|
try {
|
|
await call("/room/" + activeRoom, { method: "DELETE" });
|
|
rooms = rooms.filter((r) => r.room_id !== activeRoom);
|
|
localStorage.setItem(LS.rooms + userId, JSON.stringify(rooms));
|
|
activeRoom = null;
|
|
toast("Room verwijderd");
|
|
await refresh();
|
|
} catch (err) {
|
|
toast(err.message);
|
|
}
|
|
};
|
|
|
|
// create event (EventCreate model, naive ISO times)
|
|
$("new-event").onclick = () => {
|
|
if (!activeRoom) return toast("Kies eerst een room");
|
|
$("e-title").value = "";
|
|
$("e-desc").value = "";
|
|
const n = new Date();
|
|
$("e-date").value = n.getFullYear() + "-" + String(n.getMonth() + 1).padStart(2, "0") + "-" + String(n.getDate()).padStart(2, "0");
|
|
$("dlg-event").showModal();
|
|
};
|
|
$("form-event").onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
const date = $("e-date").value;
|
|
try {
|
|
await call("/events", {
|
|
method: "POST",
|
|
body: {
|
|
room_id: activeRoom,
|
|
type: $("e-type").value,
|
|
title: $("e-title").value.trim(),
|
|
description: $("e-desc").value.trim(),
|
|
start_time: `${date}T${$("e-start").value}:00`,
|
|
end_time: `${date}T${$("e-end").value}:00`,
|
|
visibility: "room",
|
|
status: $("e-status").value,
|
|
min_people: Number($("e-min").value) || 1,
|
|
},
|
|
});
|
|
$("dlg-event").close();
|
|
toast("Event aangemaakt");
|
|
await refresh();
|
|
} catch (err) {
|
|
toast(err.message);
|
|
}
|
|
};
|
|
|
|
// ---------- boot ----------
|
|
$("api").value = api;
|
|
setMode("login");
|
|
if (token) start(null).catch(() => logout());
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|