You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

939 lines
28 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>pages.elijah.run</title>
<style>
:root {
--bg: #f6f7f9;
--bg-card: #ffffff;
--text: #16181d;
--text-muted: #5b6270;
--border: #dfe3e8;
--accent: #2563eb;
--accent-text: #ffffff;
--danger: #dc2626;
--danger-bg: #fdecec;
--danger-border: #f5b5b1;
--success: #15803d;
--success-bg: #eafaf0;
--success-border: #a7e3bd;
--warn: #92400e;
--warn-bg: #fff7e6;
--warn-border: #f3d38a;
--info-bg: #eef4ff;
--info-border: #b9cdfb;
--code-bg: #eef0f3;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14161a;
--bg-card: #1d2025;
--text: #e7e9ec;
--text-muted: #9aa1ac;
--border: #33373f;
--accent: #5b8bf0;
--accent-text: #0a0d12;
--danger: #f87171;
--danger-bg: #2a1414;
--danger-border: #5c2323;
--success: #4ade80;
--success-bg: #12261a;
--success-border: #234a30;
--warn: #fbbf24;
--warn-bg: #2a2110;
--warn-border: #533f14;
--info-bg: #16233d;
--info-border: #2a3f68;
--code-bg: #262a31;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
}
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
a { color: var(--accent); }
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
flex-wrap: wrap;
}
header h1 {
font-size: 1.15rem;
margin: 0;
font-weight: 600;
}
#auth-area {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.9rem;
}
#auth-area img {
width: 28px;
height: 28px;
border-radius: 50%;
display: block;
}
main {
max-width: 900px;
margin: 0 auto;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.1rem 1.25rem;
box-shadow: var(--shadow);
}
.card h2 {
margin-top: 0;
font-size: 1.05rem;
}
button, .btn {
font: inherit;
cursor: pointer;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text);
border-radius: 6px;
padding: 0.45rem 0.9rem;
}
button:hover, .btn:hover { border-color: var(--accent); }
button.primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
button.primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
button.danger {
color: var(--danger);
border-color: var(--danger-border);
}
button.danger:hover { background: var(--danger-bg); }
input[type="text"] {
font: inherit;
padding: 0.45rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
width: 100%;
max-width: 320px;
}
input[type="text"].invalid { border-color: var(--danger); }
label {
display: block;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 0.3rem;
}
.field { margin-bottom: 0.8rem; }
.hint {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0.3rem;
}
.hint.error { color: var(--danger); }
#dropzone {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 1.5rem;
text-align: center;
color: var(--text-muted);
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
#dropzone.dragover {
border-color: var(--accent);
background: var(--info-bg);
}
#dropzone .filename {
color: var(--text);
font-weight: 600;
margin-top: 0.4rem;
word-break: break-all;
}
.banner {
border-radius: 8px;
border: 1px solid var(--border);
padding: 0.7rem 0.9rem;
margin-bottom: 0.9rem;
font-size: 0.9rem;
}
.banner.success {
background: var(--success-bg);
border-color: var(--success-border);
color: var(--success);
}
.banner.error {
background: var(--danger-bg);
border-color: var(--danger-border);
color: var(--danger);
}
.banner.warning {
background: var(--warn-bg);
border-color: var(--warn-border);
color: var(--warn);
}
.banner.info {
background: var(--info-bg);
border-color: var(--info-border);
color: var(--text);
}
.banner a { font-weight: 600; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th, td {
text-align: left;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
th { color: var(--text-muted); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; }
.table-scroll { overflow-x: auto; }
tr.user-row img {
width: 20px;
height: 20px;
border-radius: 50%;
vertical-align: middle;
margin-right: 0.4rem;
}
.badge {
display: inline-block;
font-size: 0.72rem;
font-weight: 600;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--info-bg);
color: var(--accent);
border: 1px solid var(--info-border);
}
input[type="checkbox"] + .badge {
margin-left: 0.4rem;
}
.muted { color: var(--text-muted); font-size: 0.85rem; }
code {
background: var(--code-bg);
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-size: 0.85em;
}
details > summary {
cursor: pointer;
font-weight: 600;
font-size: 1.05rem;
}
footer {
max-width: 900px;
margin: 0 auto;
padding: 1.25rem;
color: var(--text-muted);
font-size: 0.8rem;
text-align: center;
}
.empty {
color: var(--text-muted);
font-size: 0.9rem;
font-style: italic;
}
.row-actions { text-align: right; white-space: nowrap; }
</style>
</head>
<body>
<header>
<h1>pages.elijah.run</h1>
<div id="auth-area"></div>
</header>
<main>
<section id="upload-card" class="card" hidden>
<h2>Upload a page</h2>
<div id="upload-banner"></div>
<div id="dropzone">
<div id="dropzone-label">Drag &amp; drop an <code>.html</code> file or a <code>.zip</code> of static assets here, or click to choose one.</div>
<div class="filename" id="dropzone-filename"></div>
<input type="file" id="file-input" accept=".html,.htm,.zip" hidden>
</div>
<div class="field" style="margin-top: 0.9rem;">
<label for="name-input">Page name (slug)</label>
<input type="text" id="name-input" maxlength="63" placeholder="my-page" autocomplete="off">
<div class="hint" id="name-hint">Lowercase letters, digits, hyphens; must start with a letter or digit.</div>
</div>
<button type="button" class="primary" id="upload-submit" disabled>Upload</button>
</section>
<section id="upload-notice" class="card" hidden>
<p class="muted" style="margin: 0;">Your account isn't approved to upload yet. Ask an admin to enable it for you.</p>
</section>
<section class="card">
<h2>Pages</h2>
<div id="pages-banner"></div>
<div class="table-scroll">
<table id="pages-table">
<thead>
<tr>
<th>Slug</th>
<th>Owner</th>
<th>Size</th>
<th>Created</th>
<th>Trusted</th>
<th></th>
</tr>
</thead>
<tbody id="pages-body"></tbody>
</table>
</div>
<div class="empty" id="pages-empty" hidden>No pages published yet.</div>
</section>
<section id="admin-card" class="card" hidden>
<details id="admin-details">
<summary>Users</summary>
<div id="users-banner" style="margin-top: 0.8rem;"></div>
<div class="table-scroll">
<table id="users-table">
<thead>
<tr>
<th>Login</th>
<th>Can upload</th>
<th>Trusted</th>
<th>Role</th>
<th>Joined</th>
</tr>
</thead>
<tbody id="users-body"></tbody>
</table>
</div>
</details>
</section>
</main>
<footer>
Hosted pages are sandboxed (no cookies/storage) unless marked trusted by an admin.
</footer>
<script>
(function () {
"use strict";
// ── State ────────────────────────────────────────────────────────────
var me = { authenticated: false };
var currentFile = null;
var usersLoaded = false;
// ── DOM refs ─────────────────────────────────────────────────────────
var authArea = document.getElementById("auth-area");
var uploadCard = document.getElementById("upload-card");
var uploadNotice = document.getElementById("upload-notice");
var uploadBanner = document.getElementById("upload-banner");
var dropzone = document.getElementById("dropzone");
var dropzoneFilename = document.getElementById("dropzone-filename");
var fileInput = document.getElementById("file-input");
var nameInput = document.getElementById("name-input");
var nameHint = document.getElementById("name-hint");
var uploadSubmit = document.getElementById("upload-submit");
var pagesBanner = document.getElementById("pages-banner");
var pagesBody = document.getElementById("pages-body");
var pagesEmpty = document.getElementById("pages-empty");
var adminCard = document.getElementById("admin-card");
var adminDetails = document.getElementById("admin-details");
var usersBanner = document.getElementById("users-banner");
var usersBody = document.getElementById("users-body");
var DEFAULT_NAME_HINT = "Lowercase letters, digits, hyphens; must start with a letter or digit.";
// ── Reserved names + slug grammar (mirrors src/core/slug.rs) ────────────
var RESERVED = [
"api", "auth", "admin", "assets", "static", "login", "logout", "oauth",
"favicon.ico", "robots.txt", "sitemap.xml", "index.html", "www",
"health", "status", "well-known", "cdn-cgi"
];
var SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
/**
* Mirrors `core::slug::default_slug_from_filename`: strip the last
* extension (unless the '.' is the first character), lowercase, map
* anything outside [a-z0-9] to '-', collapse consecutive hyphens, trim
* leading/trailing hyphens, cap at 63 chars, and fall back to "my-page"
* if the result is empty or reserved.
*/
function defaultSlugFromFilename(filename) {
var idx = filename.lastIndexOf(".");
var stem = idx > 0 ? filename.slice(0, idx) : filename;
var lowered = stem.toLowerCase();
var mapped = "";
var lastWasHyphen = false;
for (var i = 0; i < lowered.length; i++) {
var c = lowered.charAt(i);
var isLowerOrDigit = (c >= "a" && c <= "z") || (c >= "0" && c <= "9");
var out = isLowerOrDigit ? c : "-";
if (out === "-") {
if (lastWasHyphen) continue;
lastWasHyphen = true;
} else {
lastWasHyphen = false;
}
mapped += out;
}
var trimmed = mapped.replace(/^-+/, "").replace(/-+$/, "");
var truncated = trimmed.slice(0, 63);
while (truncated.charAt(truncated.length - 1) === "-") {
truncated = truncated.slice(0, -1);
}
if (truncated === "" || RESERVED.indexOf(truncated) !== -1) {
return "my-page";
}
return truncated;
}
/** Client-side mirror of `validate_slug`'s grammar + reserved check, for
* a live hint only — the server is the source of truth and re-validates
* everything. */
function slugHint(name) {
if (name === "") return "Page name must not be empty.";
if (!SLUG_RE.test(name)) {
return "Only lowercase letters, digits, and hyphens are allowed; must start with a letter or digit.";
}
if (RESERVED.indexOf(name) !== -1) {
return "'" + name + "' is a reserved name and cannot be used.";
}
return null;
}
function isValidSlug(name) {
return slugHint(name) === null;
}
// ── Small helpers ────────────────────────────────────────────────────
function clearChildren(el) {
while (el.firstChild) el.removeChild(el.firstChild);
}
function humanSize(bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KiB";
return (bytes / (1024 * 1024)).toFixed(1) + " MiB";
}
function formatDate(s) {
if (!s) return "";
var iso = s.indexOf("T") === -1 ? s.replace(" ", "T") + "Z" : s;
var d = new Date(iso);
if (isNaN(d.getTime())) return s;
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
function el(tag, opts) {
var e = document.createElement(tag);
opts = opts || {};
if (opts.text !== undefined) e.textContent = opts.text;
if (opts.className) e.className = opts.className;
return e;
}
function showBanner(container, kind, message, linkUrl, linkText) {
clearChildren(container);
var b = el("div", { className: "banner " + kind });
b.appendChild(document.createTextNode(message));
if (linkUrl) {
b.appendChild(document.createTextNode(" "));
var a = el("a", { text: linkText || linkUrl });
a.href = linkUrl;
a.rel = "noopener";
b.appendChild(a);
}
container.appendChild(b);
}
function clearBanner(container) {
clearChildren(container);
}
/** Wraps fetch + JSON parsing; never throws — resolves to
* { ok, status, body } where `body` is the parsed JSON (or null if the
* response had no/invalid JSON body). */
function apiFetch(url, options) {
options = options || {};
options.credentials = "same-origin";
return fetch(url, options).then(function (resp) {
return resp.text().then(function (text) {
var body = null;
if (text) {
try { body = JSON.parse(text); } catch (e) { body = null; }
}
return { ok: resp.ok, status: resp.status, body: body };
});
}).catch(function (err) {
return { ok: false, status: 0, body: null, networkError: true };
});
}
// ── Auth area ────────────────────────────────────────────────────────
function renderAuthArea() {
clearChildren(authArea);
if (me.authenticated && me.user) {
if (me.user.avatar_url) {
var img = el("img");
img.src = me.user.avatar_url;
img.alt = "";
authArea.appendChild(img);
}
authArea.appendChild(el("span", { text: me.user.login }));
if (me.user.is_admin) {
authArea.appendChild(el("span", { className: "badge", text: "admin" }));
}
var logoutBtn = el("button", { text: "Logout" });
logoutBtn.type = "button";
logoutBtn.addEventListener("click", function () {
logoutBtn.disabled = true;
apiFetch("/auth/logout", { method: "POST" }).then(function () {
window.location.reload();
});
});
authArea.appendChild(logoutBtn);
} else {
var link = el("a", { className: "btn", text: "Sign in with GitHub" });
link.href = "/auth/login";
authArea.appendChild(link);
}
}
// ── Upload card ──────────────────────────────────────────────────────
function renderUploadArea() {
var canUpload = !!(me.authenticated && me.user && me.user.can_upload);
var authedNoUpload = !!(me.authenticated && me.user && !me.user.can_upload);
uploadCard.hidden = !canUpload;
uploadNotice.hidden = !authedNoUpload;
}
function setDropzoneFile(file) {
currentFile = file;
if (file) {
dropzoneFilename.textContent = file.name;
nameInput.value = defaultSlugFromFilename(file.name);
} else {
dropzoneFilename.textContent = "";
}
validateNameField();
}
function extensionAllowed(filename) {
var lower = filename.toLowerCase();
return lower.slice(-5) === ".html" || lower.slice(-4) === ".htm" || lower.slice(-4) === ".zip";
}
function contentTypeFor(filename) {
return filename.toLowerCase().slice(-4) === ".zip" ? "application/zip" : "text/html";
}
function validateNameField() {
var name = nameInput.value;
var hint = slugHint(name);
var fileOk = !!currentFile && extensionAllowed(currentFile.name);
if (!currentFile) {
nameInput.classList.remove("invalid");
nameHint.textContent = DEFAULT_NAME_HINT;
nameHint.classList.remove("error");
uploadSubmit.disabled = true;
return;
}
if (!fileOk) {
nameHint.textContent = "Only .html, .htm, or .zip files are supported.";
nameHint.classList.add("error");
uploadSubmit.disabled = true;
return;
}
if (hint) {
nameInput.classList.add("invalid");
nameHint.textContent = hint;
nameHint.classList.add("error");
uploadSubmit.disabled = true;
} else {
nameInput.classList.remove("invalid");
nameHint.textContent = DEFAULT_NAME_HINT;
nameHint.classList.remove("error");
uploadSubmit.disabled = false;
}
}
dropzone.addEventListener("click", function () {
fileInput.click();
});
dropzone.addEventListener("dragover", function (e) {
e.preventDefault();
dropzone.classList.add("dragover");
});
dropzone.addEventListener("dragleave", function () {
dropzone.classList.remove("dragover");
});
dropzone.addEventListener("drop", function (e) {
e.preventDefault();
dropzone.classList.remove("dragover");
var files = e.dataTransfer && e.dataTransfer.files;
if (files && files.length > 0) {
setDropzoneFile(files[0]);
}
});
fileInput.addEventListener("change", function () {
if (fileInput.files && fileInput.files.length > 0) {
setDropzoneFile(fileInput.files[0]);
}
});
nameInput.addEventListener("input", validateNameField);
// Mirrors the server's 10 MiB compressed-upload cap (design §2.2) so an
// oversize file gets a friendly message instead of a raw HTTP 413.
var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
uploadSubmit.addEventListener("click", function () {
if (!currentFile || !isValidSlug(nameInput.value)) return;
var file = currentFile;
var name = nameInput.value;
if (file.size > MAX_UPLOAD_BYTES) {
showBanner(uploadBanner, "error", "File is too large — the limit is 10 MiB.");
return;
}
uploadSubmit.disabled = true;
clearBanner(uploadBanner);
var url = "/api/pages?name=" + encodeURIComponent(name);
apiFetch(url, {
method: "POST",
headers: { "Content-Type": contentTypeFor(file.name) },
body: file
}).then(function (res) {
if (res.networkError) {
showBanner(uploadBanner, "error", "Network error — please try again.");
uploadSubmit.disabled = false;
return;
}
if (res.status === 201 && res.body) {
showBanner(uploadBanner, "success", "Uploaded! Live at ", res.body.url, res.body.url);
currentFile = null;
fileInput.value = "";
nameInput.value = "";
dropzoneFilename.textContent = "";
nameHint.textContent = DEFAULT_NAME_HINT;
nameHint.classList.remove("error");
nameInput.classList.remove("invalid");
loadPages();
return;
}
if (res.status === 409) {
showBanner(uploadBanner, "error", "That name is already taken — pick another.");
uploadSubmit.disabled = false;
return;
}
if (res.status === 403) {
var msg = (res.body && res.body.error) || "You're not authorized to upload right now.";
showBanner(uploadBanner, "warning", msg);
uploadSubmit.disabled = false;
return;
}
if (res.status === 401) {
showBanner(uploadBanner, "warning", "Please sign in to upload.");
uploadSubmit.disabled = false;
return;
}
var errMsg = (res.body && res.body.error) || ("Upload failed (HTTP " + res.status + ").");
showBanner(uploadBanner, "error", errMsg);
uploadSubmit.disabled = false;
});
});
// ── Pages list ───────────────────────────────────────────────────────
function renderPages(pages) {
clearChildren(pagesBody);
pagesEmpty.hidden = pages.length > 0;
pages.forEach(function (row) {
var tr = el("tr");
var slugTd = el("td");
var link = el("a", { text: row.slug });
link.href = "/" + row.slug + "/";
slugTd.appendChild(link);
tr.appendChild(slugTd);
tr.appendChild(el("td", { text: row.owner_login }));
tr.appendChild(el("td", { text: humanSize(row.total_bytes) }));
tr.appendChild(el("td", { text: formatDate(row.created_at) }));
var trustedTd = el("td");
var effectiveTrust = !!(row.effective_trust !== undefined ? row.effective_trust : (row.trusted || row.owner_trusted));
var isAdmin = !!(me.authenticated && me.user && me.user.is_admin);
if (isAdmin) {
var trustCheckbox = el("input");
trustCheckbox.type = "checkbox";
trustCheckbox.checked = !!row.trusted;
trustCheckbox.title = row.owner_trusted
? "Owner is trusted, so this page is served trusted regardless of this checkbox."
: "Mark this page as trusted (served without the CSP sandbox).";
trustCheckbox.addEventListener("change", function () {
var checked = trustCheckbox.checked;
trustCheckbox.disabled = true;
apiFetch("/api/pages/" + encodeURIComponent(row.slug), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trusted: checked })
}).then(function (res) {
if (res.ok) {
clearBanner(pagesBanner);
loadPages();
} else {
trustCheckbox.checked = !checked;
trustCheckbox.disabled = false;
var msg = (res.body && res.body.error) || "Update failed.";
showBanner(pagesBanner, "error", msg);
}
});
});
trustedTd.appendChild(trustCheckbox);
if (effectiveTrust) {
trustedTd.appendChild(el("span", { className: "badge", text: "trusted" }));
}
} else if (effectiveTrust) {
trustedTd.appendChild(el("span", { className: "badge", text: "trusted" }));
}
tr.appendChild(trustedTd);
var actionsTd = el("td", { className: "row-actions" });
var canDelete = !!(me.authenticated && me.user &&
(me.user.is_admin || me.user.login === row.owner_login));
if (canDelete) {
var delBtn = el("button", { className: "danger", text: "Delete" });
delBtn.type = "button";
delBtn.addEventListener("click", function () {
if (!window.confirm("Delete page '" + row.slug + "'? This cannot be undone.")) return;
delBtn.disabled = true;
apiFetch("/api/pages/" + encodeURIComponent(row.slug), { method: "DELETE" })
.then(function (res) {
if (res.ok) {
clearBanner(pagesBanner);
loadPages();
} else {
var msg = (res.body && res.body.error) || "Delete failed.";
showBanner(pagesBanner, "error", msg);
delBtn.disabled = false;
}
});
});
actionsTd.appendChild(delBtn);
}
tr.appendChild(actionsTd);
pagesBody.appendChild(tr);
});
}
function loadPages() {
return apiFetch("/api/pages").then(function (res) {
if (res.ok && res.body && Array.isArray(res.body.pages)) {
renderPages(res.body.pages);
} else {
showBanner(pagesBanner, "error", "Failed to load pages.");
}
});
}
// ── Admin: users panel ───────────────────────────────────────────────
function renderUsers(users) {
clearChildren(usersBody);
users.forEach(function (u) {
var tr = el("tr", { className: "user-row" });
var loginTd = el("td");
if (u.avatar_url) {
var img = el("img");
img.src = u.avatar_url;
img.alt = "";
loginTd.appendChild(img);
}
loginTd.appendChild(document.createTextNode(u.login));
tr.appendChild(loginTd);
var uploadTd = el("td");
var checkbox = el("input");
checkbox.type = "checkbox";
checkbox.checked = !!u.can_upload;
checkbox.addEventListener("change", function () {
var checked = checkbox.checked;
checkbox.disabled = true;
apiFetch("/api/users/" + encodeURIComponent(u.id), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ can_upload: checked })
}).then(function (res) {
checkbox.disabled = false;
if (!res.ok) {
checkbox.checked = !checked;
var msg = (res.body && res.body.error) || "Update failed.";
showBanner(usersBanner, "error", msg);
} else {
clearBanner(usersBanner);
}
});
});
uploadTd.appendChild(checkbox);
tr.appendChild(uploadTd);
var trustedTd = el("td");
var trustedCheckbox = el("input");
trustedCheckbox.type = "checkbox";
trustedCheckbox.checked = !!u.trusted;
trustedCheckbox.title = "Every page this user owns is served trusted (no CSP sandbox) while this is checked.";
trustedCheckbox.addEventListener("change", function () {
var checked = trustedCheckbox.checked;
trustedCheckbox.disabled = true;
apiFetch("/api/users/" + encodeURIComponent(u.id), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trusted: checked })
}).then(function (res) {
trustedCheckbox.disabled = false;
if (!res.ok) {
trustedCheckbox.checked = !checked;
var msg = (res.body && res.body.error) || "Update failed.";
showBanner(usersBanner, "error", msg);
} else {
clearBanner(usersBanner);
}
});
});
trustedTd.appendChild(trustedCheckbox);
tr.appendChild(trustedTd);
var roleTd = el("td");
if (u.is_admin) roleTd.appendChild(el("span", { className: "badge", text: "admin" }));
tr.appendChild(roleTd);
tr.appendChild(el("td", { text: formatDate(u.created_at) }));
usersBody.appendChild(tr);
});
}
function loadUsers() {
return apiFetch("/api/users").then(function (res) {
if (res.ok && res.body && Array.isArray(res.body.users)) {
renderUsers(res.body.users);
} else {
var msg = (res.body && res.body.error) || "Failed to load users.";
showBanner(usersBanner, "error", msg);
}
});
}
adminDetails.addEventListener("toggle", function () {
if (adminDetails.open && !usersLoaded) {
usersLoaded = true;
loadUsers();
}
});
function renderAdminArea() {
adminCard.hidden = !(me.authenticated && me.user && me.user.is_admin);
}
// ── Init ─────────────────────────────────────────────────────────────
function loadMe() {
return apiFetch("/api/me").then(function (res) {
if (res.ok && res.body) {
me = res.body;
} else {
me = { authenticated: false };
}
renderAuthArea();
renderUploadArea();
renderAdminArea();
});
}
loadMe().then(function () {
loadPages();
});
}());
</script>
</body>
</html>