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.

1260 lines
40 KiB
HTML

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!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); }
input[type="text"]:read-only {
background: var(--code-bg);
color: var(--text-muted);
cursor: not-allowed;
}
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;
}
/* Alias chips under a page's slug (pages-9lsk). */
.aliases { margin-top: 0.3rem; display: flex; flex-wrap: wrap; gap: 0.3rem; }
.alias-chip {
display: inline-flex;
align-items: center;
gap: 0.2rem;
font-size: 0.72rem;
padding: 0.08rem 0.2rem 0.08rem 0.45rem;
border-radius: 999px;
background: var(--code-bg);
border: 1px solid var(--info-border);
}
.alias-chip a { color: var(--text-muted); }
.alias-remove {
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 0.95rem;
line-height: 1;
padding: 0 0.15rem;
}
.alias-remove:hover { color: var(--danger, #c0392b); }
.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; }
.row-actions button + button { margin-left: 0.4rem; }
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.8rem;
}
.card-header h2 { margin: 0; }
/* ── Upload modal ─────────────────────────────────────────────────── */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 10;
}
.modal-backdrop[hidden] { display: none; }
.modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
padding: 1.1rem 1.25rem;
width: 100%;
max-width: 520px;
max-height: calc(100vh - 2rem);
overflow-y: auto;
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.9rem;
}
.modal-header h2 { margin: 0; font-size: 1.05rem; }
.modal-close {
border: none;
background: none;
color: var(--text-muted);
font-size: 1.4rem;
line-height: 1;
padding: 0 0.25rem;
border-radius: 6px;
}
.modal-close:hover { color: var(--text); }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
</style>
</head>
<body>
<header>
<h1>pages.elijah.run</h1>
<div id="auth-area"></div>
</header>
<main>
<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">
<div class="card-header">
<h2>Pages</h2>
<button type="button" class="primary" id="new-page-btn" hidden>+ New Page</button>
</div>
<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>
<div class="modal-backdrop" id="upload-modal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="upload-modal-title">
<div class="modal-header">
<h2 id="upload-modal-title">Upload a page</h2>
<button type="button" class="modal-close" id="upload-modal-close" aria-label="Close">&times;</button>
</div>
<div id="upload-banner"></div>
<div id="dropzone" role="button" tabindex="0">
<div id="dropzone-label">Drag &amp; drop an <code>.html</code> file, a <code>.zip</code>, or a compressed tarball (<code>.tar.gz</code>, <code>.tar.zst</code>, <code>.tar.xz</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,.tar.gz,.tgz,.tar.zst,.tar.xz" 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>
<div class="modal-actions">
<button type="button" id="upload-cancel">Cancel</button>
<button type="button" class="primary" id="upload-submit" disabled>Upload</button>
</div>
</div>
</div>
<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;
// The upload modal serves both flows (pages-ytau): "create" posts to
// /api/pages?name=<slug>, "replace" puts to /api/pages/<replaceSlug> with
// the slug fixed. `replaceSlug` is null in create mode.
var uploadMode = "create";
var replaceSlug = null;
// Element focus is returned here when the modal closes (the button that
// opened it), so keyboard users don't get dumped back at the top of the page.
var modalOpener = null;
// ── DOM refs ─────────────────────────────────────────────────────────
var authArea = document.getElementById("auth-area");
var uploadNotice = document.getElementById("upload-notice");
var uploadModal = document.getElementById("upload-modal");
var uploadModalTitle = document.getElementById("upload-modal-title");
var uploadModalClose = document.getElementById("upload-modal-close");
var uploadCancel = document.getElementById("upload-cancel");
var newPageBtn = document.getElementById("new-page-btn");
var uploadBanner = document.getElementById("upload-banner");
var dropzone = document.getElementById("dropzone");
var dropzoneLabel = document.getElementById("dropzone-label");
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 modal ─────────────────────────────────────────────────────
function canUpload() {
return !!(me.authenticated && me.user && me.user.can_upload);
}
function renderUploadArea() {
var authedNoUpload = !!(me.authenticated && me.user && !me.user.can_upload);
newPageBtn.hidden = !canUpload();
uploadNotice.hidden = !authedNoUpload;
}
/**
* Opens the modal. Pass a slug to open in "replace" mode (content upload
* for an existing page: name pre-filled and read-only); pass nothing for
* "create" mode. `opener` is the button that triggered this, so focus can
* be restored to it on close.
*/
function openUploadModal(slug, opener) {
uploadMode = slug ? "replace" : "create";
replaceSlug = slug || null;
modalOpener = opener || null;
// Reset any state left over from a previous open.
currentFile = null;
fileInput.value = "";
dropzoneFilename.textContent = "";
clearBanner(uploadBanner);
nameInput.classList.remove("invalid");
if (uploadMode === "replace") {
uploadModalTitle.textContent = "Update page";
dropzoneLabel.textContent = "Drop the new .html, .zip, or compressed tarball (.tar.gz, .tar.zst, .tar.xz) here, or click to choose one. It replaces this page's current content.";
nameInput.value = slug;
nameInput.readOnly = true;
uploadSubmit.textContent = "Update";
} else {
uploadModalTitle.textContent = "Upload a page";
dropzoneLabel.textContent = "Drag & drop an .html file, a .zip, or a compressed tarball (.tar.gz, .tar.zst, .tar.xz) of static assets here, or click to choose one.";
nameInput.value = "";
nameInput.readOnly = false;
uploadSubmit.textContent = "Upload";
}
validateNameField();
uploadModal.hidden = false;
// In replace mode the name is fixed, so the file picker is the only thing
// left to do — put focus there rather than on a read-only input.
if (uploadMode === "replace") {
dropzone.focus();
} else {
nameInput.focus();
}
}
function closeUploadModal() {
uploadModal.hidden = true;
currentFile = null;
fileInput.value = "";
nameInput.readOnly = false;
uploadSubmit.disabled = true;
if (modalOpener && document.contains(modalOpener)) {
modalOpener.focus();
}
modalOpener = null;
}
newPageBtn.addEventListener("click", function () {
openUploadModal(null, newPageBtn);
});
uploadModalClose.addEventListener("click", closeUploadModal);
uploadCancel.addEventListener("click", closeUploadModal);
// Backdrop click closes; a click *inside* the dialog must not (the check
// that the event target is the backdrop itself, not a descendant).
uploadModal.addEventListener("click", function (e) {
if (e.target === uploadModal) closeUploadModal();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !uploadModal.hidden) closeUploadModal();
});
function setDropzoneFile(file) {
currentFile = file;
if (file) {
dropzoneFilename.textContent = file.name;
// Only derive a slug from the filename when creating: in replace mode
// the slug identifies the page being updated and must not move.
if (uploadMode === "create") {
nameInput.value = defaultSlugFromFilename(file.name);
}
} else {
dropzoneFilename.textContent = "";
}
validateNameField();
}
var ALLOWED_EXTENSIONS = [".html", ".htm", ".zip", ".tar.gz", ".tgz", ".tar.zst", ".tar.xz"];
function extensionAllowed(filename) {
var lower = filename.toLowerCase();
return ALLOWED_EXTENSIONS.some(function (ext) {
return lower.slice(-ext.length) === ext;
});
}
function isHtmlFile(filename) {
var lower = filename.toLowerCase();
return lower.slice(-5) === ".html" || lower.slice(-4) === ".htm";
}
// The backend sniffs the real format from the body's magic bytes, so the
// Content-Type only needs to distinguish "raw HTML" (which has no magic and
// relies on this) from "some archive" (zip or tarball). Sending
// application/octet-stream for every archive is correct and future-proof.
function contentTypeFor(filename) {
return isHtmlFile(filename) ? "text/html" : "application/octet-stream";
}
/** The idle (non-error) hint under the name field, which differs by mode:
* in replace mode the name is fixed, so the grammar rules are noise and
* what matters is that the upload overwrites what's there. */
function defaultNameHint() {
return uploadMode === "replace"
? "This page's current content will be replaced. The name can't be changed."
: DEFAULT_NAME_HINT;
}
function validateNameField() {
var name = nameInput.value;
var hint = slugHint(name);
var fileOk = !!currentFile && extensionAllowed(currentFile.name);
if (!currentFile) {
nameInput.classList.remove("invalid");
nameHint.textContent = defaultNameHint();
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 = defaultNameHint();
nameHint.classList.remove("error");
uploadSubmit.disabled = false;
}
}
dropzone.addEventListener("click", function () {
fileInput.click();
});
dropzone.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
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 24 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 = 24 * 1024 * 1024;
uploadSubmit.addEventListener("click", function () {
var replacing = uploadMode === "replace";
var name = replacing ? replaceSlug : nameInput.value;
if (!currentFile || !isValidSlug(name)) return;
var file = currentFile;
if (file.size > MAX_UPLOAD_BYTES) {
showBanner(uploadBanner, "error", "File is too large — the limit is 24 MiB.");
return;
}
uploadSubmit.disabled = true;
clearBanner(uploadBanner);
var request = replacing
? { url: "/api/pages/" + encodeURIComponent(name), method: "PUT", okStatus: 200 }
: { url: "/api/pages?name=" + encodeURIComponent(name), method: "POST", okStatus: 201 };
apiFetch(request.url, {
method: request.method,
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 === request.okStatus && res.body) {
// Success closes the modal; the confirmation lands on the pages card
// behind it, which is also where the refreshed row appears.
closeUploadModal();
showBanner(
pagesBanner,
"success",
replacing ? "Updated! Live at " : "Uploaded! Live at ",
res.body.url,
res.body.url
);
loadPages();
return;
}
if (res.status === 409) {
showBanner(uploadBanner, "error", "That name is already taken — pick another.");
uploadSubmit.disabled = false;
return;
}
if (res.status === 404) {
showBanner(uploadBanner, "error", "That page no longer exists — it may have been deleted.");
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 verb = replacing ? "Update" : "Upload";
var errMsg = (res.body && res.body.error) || (verb + " 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");
// Owner-or-admin is the server's rule for both DELETE and the alias
// routes (core::origin::can_delete / can_replace).
var canManage = !!(me.authenticated && me.user &&
(me.user.is_admin || me.user.login === row.owner_login));
var slugTd = el("td");
var link = el("a", { text: row.slug });
link.href = "/" + row.slug + "/";
slugTd.appendChild(link);
// Alias chips (pages-9lsk): each is a live alternate URL; owners/admins
// get a × to remove it.
if (Array.isArray(row.aliases) && row.aliases.length) {
var aliasWrap = el("div", { className: "aliases" });
row.aliases.forEach(function (a) {
var chip = el("span", { className: "alias-chip" });
var alink = el("a", { text: a });
alink.href = "/" + a + "/";
chip.appendChild(alink);
if (canManage) {
var x = el("button", { className: "alias-remove", text: "×" });
x.type = "button";
x.title = "Remove alias '" + a + "'";
x.addEventListener("click", function () {
if (!window.confirm("Remove alias '" + a + "'? The page stays at '" + row.slug + "'.")) return;
x.disabled = true;
apiFetch("/api/pages/" + encodeURIComponent(row.slug) + "/aliases/" + encodeURIComponent(a),
{ method: "DELETE" }).then(function (res) {
if (res.ok) {
clearBanner(pagesBanner);
loadPages();
} else {
var msg = (res.body && res.body.error) || "Failed to remove alias.";
showBanner(pagesBanner, "error", msg);
x.disabled = false;
}
});
});
chip.appendChild(x);
}
aliasWrap.appendChild(chip);
});
slugTd.appendChild(aliasWrap);
}
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" });
// `canManage` (owner-or-admin) is the server's rule for DELETE, PUT and
// the alias routes; Update and Add-alias additionally need upload
// permission, since they publish content / claim a new name.
if (canManage && canUpload()) {
var updateBtn = el("button", { text: "Update" });
updateBtn.type = "button";
updateBtn.title = "Replace this page's content with a new upload.";
updateBtn.addEventListener("click", function () {
clearBanner(pagesBanner);
openUploadModal(row.slug, updateBtn);
});
actionsTd.appendChild(updateBtn);
var aliasBtn = el("button", { text: "Add alias" });
aliasBtn.type = "button";
aliasBtn.title = "Add another name that serves this page.";
aliasBtn.addEventListener("click", function () {
var name = window.prompt("New alias for '" + row.slug + "' (lowercase letters, numbers, hyphens):");
if (name === null) return;
name = name.trim();
if (!name) return;
aliasBtn.disabled = true;
apiFetch("/api/pages/" + encodeURIComponent(row.slug) + "/aliases", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ alias: name })
}).then(function (res) {
aliasBtn.disabled = false;
if (res.ok) {
clearBanner(pagesBanner);
showBanner(pagesBanner, "success", "Alias added — also live at ", "/" + res.body.alias + "/", "/" + res.body.alias + "/");
loadPages();
} else if (res.status === 409) {
showBanner(pagesBanner, "error", (res.body && res.body.error) || "That name is already taken.");
} else {
showBanner(pagesBanner, "error", (res.body && res.body.error) || "Failed to add alias.");
}
});
});
actionsTd.appendChild(aliasBtn);
}
if (canManage) {
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>