|
|
<!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">×</button>
|
|
|
</div>
|
|
|
<div id="upload-banner"></div>
|
|
|
<div id="dropzone" role="button" tabindex="0">
|
|
|
<div id="dropzone-label">Drag & 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>
|