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
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 & 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>
|