Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
{{define "title"}}{{ tr "Changes" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-head">
|
||||
<div class="page-head__text">
|
||||
<h1>{{ tr "Changes" }}: {{ .Heading }}</h1>
|
||||
<p class="page-head__lede">
|
||||
{{ tr "Archived version" }} <code>{{ .DiffName }}</code>{{if .DiffWhen}} · {{ .DiffWhen }}{{end}} · {{ tr "compared with the current content." }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<a class="btn btn--ghost" href="/admin/posts/{{ .Slug }}/history">{{ tr "Back to history" }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="diff-legend" aria-hidden="true">
|
||||
<span class="tag tag--warn">{{ tr "removed" }}</span>
|
||||
<span class="tag tag--ok">{{ tr "added" }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card card--flush">
|
||||
<div class="diff">
|
||||
{{if not .DiffChunks}}
|
||||
<div class="diff__line diff__line--equal">{{ tr "No differences from the current content." }}</div>
|
||||
{{end}}
|
||||
{{range .DiffChunks}}
|
||||
{{if .Op.Is "skipped"}}
|
||||
<div class="diff__line diff__line--skipped">··· {{ trn .Skipped "diff.unchanged" }} ···</div>
|
||||
{{else if .Op.Is "removed"}}
|
||||
{{range .Lines}}<div class="diff__line diff__line--removed"><span class="diff__sign">−</span>{{ . }}</div>{{end}}
|
||||
{{else if .Op.Is "added"}}
|
||||
{{range .Lines}}<div class="diff__line diff__line--added"><span class="diff__sign">+</span>{{ . }}</div>{{end}}
|
||||
{{else}}
|
||||
{{range .Lines}}<div class="diff__line diff__line--equal"><span class="diff__sign"> </span>{{ . }}</div>{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
{{define "title"}}{{ tr "History" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-head">
|
||||
<div class="page-head__text">
|
||||
<h1>{{ tr "History" }}: {{ .Heading }}</h1>
|
||||
<p class="page-head__lede">{{ trn (len .Revisions) "revisions.count" }}</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<a class="btn btn--ghost" href="/admin/posts/{{ .Slug }}/edit">{{ tr "Back to editor" }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if not .Revisions}}
|
||||
<div class="empty">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#clock"></use></svg>
|
||||
<h2>{{ tr "No revisions yet" }}</h2>
|
||||
<p class="empty__note">{{ tr "Every time this post is saved, the previous version is archived here." }}</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card card--flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ tr "Saved" }}</th>
|
||||
<th>{{ tr "Size" }}</th>
|
||||
<th class="table__actions">{{ tr "Actions" }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Revisions}}
|
||||
<tr>
|
||||
<td class="mono">{{ .When }}</td>
|
||||
<td class="mono">{{ .SizeKB }} kB</td>
|
||||
<td class="table__actions">
|
||||
<a class="btn btn--ghost btn--sm" href="/admin/posts/{{ $.Slug }}/history/{{ .Name }}">{{ tr "Download" }}</a>
|
||||
<a class="btn btn--ghost btn--sm" href="/admin/posts/{{ $.Slug }}/history/{{ .Name }}/diff">{{ tr "Compare" }}</a>
|
||||
<form method="post" action="/admin/posts/{{ $.Slug }}/history/{{ .Name }}/restore"
|
||||
class="form-inline"
|
||||
data-confirm="{{ tr "Restore this revision? The current content will be archived first." }}">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn btn--danger btn--sm">{{ tr "Restore" }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,189 @@
|
||||
{{define "title"}}{{ tr "Import post" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-head">
|
||||
<div class="page-head__text">
|
||||
<h1>{{ tr "Import post" }}</h1>
|
||||
<p class="page-head__lede">{{ tr "Bring a post written anywhere: a Markdown file with optional TOML frontmatter." }}</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<a class="btn btn--ghost" href="/admin/">{{ tr "Cancel" }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="status status--error" role="alert">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
|
||||
<div>{{ .Error }}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/admin/posts/import" enctype="multipart/form-data" id="import-form" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRFToken }}">
|
||||
|
||||
<label class="dropzone" id="dropzone">
|
||||
<input id="import-file" type="file" name="file" accept=".md,text/markdown" class="dropzone__input">
|
||||
<span class="dropzone__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#upload"></use></svg>
|
||||
</span>
|
||||
<span class="dropzone__title">{{ tr "Drop your post here" }}</span>
|
||||
<span class="dropzone__sub">{{ tr "or click to browse" }} · .md · UTF-8</span>
|
||||
<span class="dropzone__hint">markdown + toml frontmatter ( +++ )</span>
|
||||
<span class="dropzone__error" id="dz-error" role="alert" hidden></span>
|
||||
</label>
|
||||
|
||||
<div class="file-row" id="dz-file" hidden>
|
||||
<span class="file-row__ok" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#check"></use></svg>
|
||||
</span>
|
||||
<span class="file-row__info">
|
||||
<span class="file-row__name" id="dz-name"></span>
|
||||
<span class="file-row__meta" id="dz-meta"></span>
|
||||
</span>
|
||||
<button type="button" class="btn btn--ghost btn--sm" id="dz-clear">{{ tr "Remove" }}</button>
|
||||
</div>
|
||||
|
||||
<div class="card" id="dz-preview" hidden>
|
||||
<div class="card__head">
|
||||
<h2 class="mono">{{ tr "frontmatter" }}</h2>
|
||||
</div>
|
||||
<dl class="kv" id="dz-kv"></dl>
|
||||
<p class="field__hint" id="dz-note" hidden>
|
||||
{{ tr "No frontmatter found; the slug will be derived from the file name." }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn--primary" id="dz-submit" disabled>
|
||||
{{ tr "Import post" }}
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#arrow-right"></use></svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
(() => {
|
||||
const zone = document.getElementById("dropzone");
|
||||
const input = document.getElementById("import-file");
|
||||
const fileRow = document.getElementById("dz-file");
|
||||
const nameEl = document.getElementById("dz-name");
|
||||
const metaEl = document.getElementById("dz-meta");
|
||||
const preview = document.getElementById("dz-preview");
|
||||
const kvEl = document.getElementById("dz-kv");
|
||||
const noteEl = document.getElementById("dz-note");
|
||||
const errorEl = document.getElementById("dz-error");
|
||||
const clearBtn = document.getElementById("dz-clear");
|
||||
const submitBtn = document.getElementById("dz-submit");
|
||||
let depth = 0;
|
||||
|
||||
/* Keep the browser from navigating away when a file misses the zone. */
|
||||
["dragover", "drop"].forEach((evt) =>
|
||||
window.addEventListener(evt, (event) => event.preventDefault()));
|
||||
window.addEventListener("dragenter", () => {
|
||||
depth += 1;
|
||||
zone.classList.add("is-over");
|
||||
});
|
||||
window.addEventListener("dragleave", () => {
|
||||
depth = Math.max(0, depth - 1);
|
||||
if (depth === 0) zone.classList.remove("is-over");
|
||||
});
|
||||
zone.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
depth = 0;
|
||||
zone.classList.remove("is-over");
|
||||
const files = Array.from(event.dataTransfer?.files || []);
|
||||
if (files.length) acceptFile(files[0]);
|
||||
});
|
||||
input.addEventListener("change", () => {
|
||||
if (input.files?.length) acceptFile(input.files[0]);
|
||||
});
|
||||
|
||||
clearBtn.addEventListener("click", () => {
|
||||
input.value = "";
|
||||
zone.classList.remove("has-file");
|
||||
fileRow.hidden = true;
|
||||
preview.hidden = true;
|
||||
errorEl.hidden = true;
|
||||
submitBtn.disabled = true;
|
||||
});
|
||||
|
||||
function acceptFile(file) {
|
||||
errorEl.hidden = true;
|
||||
zone.classList.remove("is-rejected");
|
||||
if (!/\.md$/i.test(file.name)) {
|
||||
zone.classList.add("is-rejected");
|
||||
errorEl.textContent = window.__f("import.rejected", file.name);
|
||||
errorEl.hidden = false;
|
||||
return;
|
||||
}
|
||||
/* Re-attach a dropped file so the real form carries it. */
|
||||
if (!input.files?.length || input.files[0] !== file) {
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(file);
|
||||
input.files = transfer.files;
|
||||
}
|
||||
zone.classList.add("has-file");
|
||||
nameEl.textContent = file.name;
|
||||
metaEl.textContent = formatSize(file.size);
|
||||
fileRow.hidden = false;
|
||||
submitBtn.disabled = false;
|
||||
readPreview(file);
|
||||
}
|
||||
|
||||
async function readPreview(file) {
|
||||
const text = await file.text();
|
||||
const fm = parseFrontmatter(text);
|
||||
const body = text.replace(/^\+\+\+[\s\S]*?\+\+\+\r?\n?/, "");
|
||||
const words = (body.match(/\S+/g) || []).length;
|
||||
metaEl.textContent += " · " + window.__n("editor.words", words);
|
||||
|
||||
kvEl.replaceChildren();
|
||||
let shown = 0;
|
||||
for (const key of ["title", "slug", "tags", "date", "lang"]) {
|
||||
if (!fm || !(key in fm)) continue;
|
||||
const dt = document.createElement("dt");
|
||||
dt.textContent = key;
|
||||
const dd = document.createElement("dd");
|
||||
dd.textContent = formatValue(fm[key]);
|
||||
kvEl.append(dt, dd);
|
||||
shown += 1;
|
||||
}
|
||||
noteEl.hidden = shown > 0;
|
||||
preview.hidden = false;
|
||||
}
|
||||
|
||||
/* The preview shows values the way they read, not the way TOML quotes
|
||||
them: surrounding quotes go, and an array becomes a comma list. */
|
||||
function formatValue(raw) {
|
||||
const value = String(raw).trim();
|
||||
if (value.startsWith("[") && value.endsWith("]")) {
|
||||
return value.slice(1, -1)
|
||||
.split(",")
|
||||
.map((item) => unquote(item.trim()))
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
return unquote(value);
|
||||
}
|
||||
|
||||
function unquote(value) {
|
||||
const quoted = value.length > 1 &&
|
||||
((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'")));
|
||||
return quoted ? value.slice(1, -1) : value;
|
||||
}
|
||||
|
||||
function parseFrontmatter(text) {
|
||||
const m = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+/.exec(text);
|
||||
if (!m) return null;
|
||||
const out = {};
|
||||
for (const line of m[1].split(/\r?\n/)) {
|
||||
const pair = /^\s*([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.+?)\s*$/.exec(line);
|
||||
if (pair) out[pair[1]] = pair[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const formatSize = (bytes) =>
|
||||
bytes < 1024 ? bytes + " B" : (bytes / 1024).toFixed(1) + " kB";
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,699 @@
|
||||
{{define "layout"}}<!DOCTYPE html>
|
||||
<html lang="{{ .Lang }}" data-palette="{{ .Theme }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{block "title" .}}{{ tr "Volumen admin" }}{{end}}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/admin/icon.svg">
|
||||
<link rel="stylesheet" href="/admin/assets/fonts.css">
|
||||
<link rel="stylesheet" href="/admin/assets/admin.css">
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
// The scheme follows the system until this account pins one: read
|
||||
// the pin before the first paint so a reload never flashes.
|
||||
try {
|
||||
var scheme = localStorage.getItem("volumen.scheme");
|
||||
if (scheme === "dark" || scheme === "light") {
|
||||
document.documentElement.dataset.scheme = scheme;
|
||||
}
|
||||
} catch (error) {}
|
||||
// The rail fold is a device pin too: applying it before the first
|
||||
// paint is what lets the fold survive a reload without a flash,
|
||||
// and the data-rail attribute is the signal the fold toggle is
|
||||
// styled by: without JavaScript there is no toggle at all.
|
||||
try {
|
||||
document.documentElement.dataset.rail =
|
||||
localStorage.getItem("volumen.rail") === "compact" ? "compact" : "full";
|
||||
} catch (error) {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
{{/* The translation and toast helpers run before any page script:
|
||||
the page init blocks sit inline inside the content and call
|
||||
window.__, __n and the toast at parse time, so this block must
|
||||
be the first thing the body executes. The blocks that walk the
|
||||
DOM (scheme, rail, confirm, the date picker) stay at the end,
|
||||
where the tree they query already exists. */}}
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
// --- Toast and translation helpers ---------------------------------
|
||||
window.__volumen = window.__volumen || {};
|
||||
/* The strings the page scripts compose live in the same catalogue as
|
||||
the server-rendered ones and arrive as JSON for this request's
|
||||
language. Plural entries carry [one, few, other]; the selector is
|
||||
the CLDR Czech cardinal rule, matching the server's N(). */
|
||||
window.__volumen.i18n = {{ i18nJSON }};
|
||||
window.__ = (id) => (window.__volumen.i18n || {})[id] ?? id;
|
||||
window.__f = (id, arg) => {
|
||||
const s = window.__(id);
|
||||
return s.includes("%s") ? s.replace("%s", arg) : s;
|
||||
};
|
||||
window.__n = (id, n) => {
|
||||
const forms = window.__(id);
|
||||
if (!(forms instanceof Array)) return forms;
|
||||
let form = forms[2];
|
||||
if (n === 1) form = forms[0];
|
||||
else if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) form = forms[1] ?? forms[2];
|
||||
return String(form).replace("%d", String(n));
|
||||
};
|
||||
window.__volumen.toast = (message, opts = {}) => {
|
||||
let stack = document.getElementById("toast-stack");
|
||||
if (!stack) {
|
||||
stack = document.createElement("div");
|
||||
stack.id = "toast-stack";
|
||||
stack.className = "toast-stack";
|
||||
stack.setAttribute("role", "status");
|
||||
stack.setAttribute("aria-live", "polite");
|
||||
document.body.appendChild(stack);
|
||||
}
|
||||
const kind = opts.kind ?? "ok";
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "toast toast--" + kind;
|
||||
toast.setAttribute("role", kind === "error" ? "alert" : "status");
|
||||
const text = document.createElement("span");
|
||||
text.className = "toast__text";
|
||||
text.textContent = message;
|
||||
toast.append(text);
|
||||
if (opts.action?.label && typeof opts.action.onClick === "function") {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "toast__action";
|
||||
btn.textContent = opts.action.label;
|
||||
btn.addEventListener("click", () => {
|
||||
opts.action.onClick();
|
||||
dismiss();
|
||||
});
|
||||
toast.append(btn);
|
||||
}
|
||||
stack.append(toast);
|
||||
requestAnimationFrame(() => toast.classList.add("is-visible"));
|
||||
let timer = setTimeout(dismiss, opts.ttl ?? 3500);
|
||||
function dismiss() {
|
||||
clearTimeout(timer);
|
||||
toast.classList.remove("is-visible");
|
||||
toast.addEventListener("transitionend", () => toast.remove(), { once: true });
|
||||
setTimeout(() => toast.remove(), 400);
|
||||
}
|
||||
toast.addEventListener("click", (event) => {
|
||||
if (!event.target.closest(".toast__action")) dismiss();
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
{{if or .IsLogin .IsSetup}}
|
||||
<div class="login">
|
||||
<div class="login__tools">{{template "scheme-toggle" .}}</div>
|
||||
<main class="login__panel" id="main">
|
||||
<div class="login__brand">
|
||||
<a class="login__mark" href="/admin/">
|
||||
<img src="/admin/icon.svg" alt="">
|
||||
<span class="grad">Volumen</span>
|
||||
</a>
|
||||
<span class="eyebrow">{{ tr "Volumen · administration" }}</span>
|
||||
</div>
|
||||
{{block "login_content" .}}{{end}}
|
||||
<p class="login__foot">
|
||||
<a href="/api/volumen/feed.xml">rss</a> ·
|
||||
<a href="/api/volumen/site">api</a> ·
|
||||
<a href="/healthz">status</a>
|
||||
</p>
|
||||
<p class="login__version">{{ .Version }}</p>
|
||||
</main>
|
||||
</div>
|
||||
{{else}}
|
||||
<a class="skip-link" href="#main">{{ tr "Skip to content" }}</a>
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
{{if .IsAuthenticated}}
|
||||
<button type="button" class="icon-btn menu-toggle" popovertarget="menu"
|
||||
aria-label="{{ tr "Menu" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#menu"></use></svg>
|
||||
</button>
|
||||
{{end}}
|
||||
<a class="brand" href="/admin/">
|
||||
<img class="brand__mark" src="/admin/icon.svg" alt="">
|
||||
<span class="brand__text">
|
||||
<span class="brand__name">Volumen</span>
|
||||
<span class="brand__role">{{ tr "administration" }}</span>
|
||||
</span>
|
||||
</a>
|
||||
<div class="topbar__tools">
|
||||
{{template "scheme-toggle" .}}
|
||||
{{if .IsAuthenticated}}
|
||||
<span class="topbar__sep" aria-hidden="true"></span>
|
||||
<form method="post" action="/admin/logout" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRFToken }}">
|
||||
<button type="submit" class="icon-btn" title="{{ tr "Log out" }}" aria-label="{{ tr "Log out" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#log-out"></use></svg>
|
||||
</button>
|
||||
</form>
|
||||
<span class="topbar__user">
|
||||
{{if .UserPhoto}}
|
||||
<span class="avatar"><img src="{{ .UserPhoto }}" alt=""></span>
|
||||
{{else}}
|
||||
<span class="avatar">{{ .UserInitial }}</span>
|
||||
{{end}}
|
||||
<span class="topbar__user-text">
|
||||
<span class="topbar__user-name">{{ .DisplayName }}</span>
|
||||
<span class="topbar__user-role">{{ tr .CurrentRole }}</span>
|
||||
</span>
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</header>
|
||||
<aside class="rail">
|
||||
{{if .IsAuthenticated}}
|
||||
<button type="button" class="icon-btn rail-toggle"
|
||||
aria-label="{{ tr "Collapse sidebar" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#sidebar"></use></svg>
|
||||
</button>
|
||||
<nav class="rail__nav" aria-label="{{ tr "Main menu" }}">
|
||||
<p class="rail__label">{{ tr "Content" }}</p>
|
||||
<a href="/admin/" class="rail__link" {{if .NavPosts}}aria-current="page"{{end}} title="{{ tr "Posts" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#file-text"></use></svg>
|
||||
<span class="rail__text">{{ tr "Posts" }}</span>
|
||||
</a>
|
||||
<a href="/admin/posts/new" class="rail__link" {{if .NavNew}}aria-current="page"{{end}} title="{{ tr "New post" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#plus-circle"></use></svg>
|
||||
<span class="rail__text">{{ tr "New post" }}</span>
|
||||
</a>
|
||||
<a href="/admin/posts/import" class="rail__link" {{if .NavImport}}aria-current="page"{{end}} title="{{ tr "Import" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#upload"></use></svg>
|
||||
<span class="rail__text">{{ tr "Import" }}</span>
|
||||
</a>
|
||||
<a href="/admin/media" class="rail__link" {{if .NavMedia}}aria-current="page"{{end}} title="{{ tr "Media" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#image"></use></svg>
|
||||
<span class="rail__text">{{ tr "Media" }}</span>
|
||||
</a>
|
||||
<p class="rail__label">{{ tr "Configure" }}</p>
|
||||
<a href="/admin/settings" class="rail__link" {{if .NavSettings}}aria-current="page"{{end}} title="{{ tr "Settings" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#settings"></use></svg>
|
||||
<span class="rail__text">{{ tr "Settings" }}</span>
|
||||
</a>
|
||||
</nav>
|
||||
<p class="rail__version" title="{{ .Version }}">{{ .Version }}</p>
|
||||
{{end}}
|
||||
</aside>
|
||||
<div class="main">
|
||||
<main class="content" id="main">
|
||||
{{if and .IsAuthenticated .UpdateAvailable}}
|
||||
<div class="status status--info" role="status">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#refresh"></use></svg>
|
||||
<span><strong>Volumen {{ .UpdateAvailable }}</strong> {{ tr "is available; you are running" }} {{ .Version }}.</span>
|
||||
<span class="status__actions">
|
||||
<form method="post" action="/admin/settings/update"
|
||||
data-confirm="{{ trf "Update to %s now?" .UpdateAvailable }} {{ tr "The server will restart." }}">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRFToken }}">
|
||||
<button type="submit" class="btn btn--primary btn--sm">{{ tr "Update now" }}</button>
|
||||
</form>
|
||||
<a class="btn btn--ghost btn--sm" href="/admin/settings#version">{{ tr "Details" }}</a>
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if gt (len .Crumbs) 1}}
|
||||
<nav class="crumbs" aria-label="{{ tr "Breadcrumb" }}">
|
||||
{{range $i, $c := .Crumbs}}
|
||||
{{if $i}}<span aria-hidden="true">/</span>{{end}}
|
||||
{{if and $c.Href (not $c.IsLast)}}
|
||||
<a href="{{ $c.Href }}">{{ $c.Label }}</a>
|
||||
{{else}}
|
||||
<span class="crumbs__current">{{ $c.Label }}</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<div id="menu" class="menu" popover>
|
||||
<nav aria-label="{{ tr "Main menu" }}">
|
||||
<ul>
|
||||
<li><a href="/admin/" {{if .NavPosts}}aria-current="page"{{end}}>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#file-text"></use></svg>
|
||||
{{ tr "Posts" }}</a></li>
|
||||
<li><a href="/admin/posts/new" {{if .NavNew}}aria-current="page"{{end}}>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#plus-circle"></use></svg>
|
||||
{{ tr "New post" }}</a></li>
|
||||
<li><a href="/admin/posts/import" {{if .NavImport}}aria-current="page"{{end}}>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#upload"></use></svg>
|
||||
{{ tr "Import" }}</a></li>
|
||||
<li><a href="/admin/media" {{if .NavMedia}}aria-current="page"{{end}}>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#image"></use></svg>
|
||||
{{ tr "Media" }}</a></li>
|
||||
<li><a href="/admin/settings" {{if .NavSettings}}aria-current="page"{{end}}>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#settings"></use></svg>
|
||||
{{ tr "Settings" }}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{template "confirm-dialog" .}}
|
||||
|
||||
<div id="toast-stack" class="toast-stack" role="status" aria-live="polite"></div>
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
/* The scheme toggle: system, light, dark, remembered on this
|
||||
device. The head script re-applies the pin before the first
|
||||
paint of every page. */
|
||||
(() => {
|
||||
const SCHEMES = [null, "light", "dark"];
|
||||
document.querySelectorAll("[data-scheme-toggle]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const current = document.documentElement.dataset.scheme ?? null;
|
||||
const next = SCHEMES[(SCHEMES.indexOf(current) + 1) % SCHEMES.length];
|
||||
if (next) {
|
||||
document.documentElement.dataset.scheme = next;
|
||||
} else {
|
||||
delete document.documentElement.dataset.scheme;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem("volumen.scheme", next ?? "");
|
||||
} catch (error) {}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
/* The rail fold: the head script pins the device choice before
|
||||
the first paint, and the button flips it. The icon turns by
|
||||
itself, the sheet reads the attribute. */
|
||||
(() => {
|
||||
const btn = document.querySelector(".rail-toggle");
|
||||
if (!btn) return;
|
||||
const sync = () => {
|
||||
const compact = document.documentElement.dataset.rail === "compact";
|
||||
const label = window.__(compact ? "Expand sidebar" : "Collapse sidebar");
|
||||
btn.setAttribute("aria-label", label);
|
||||
btn.title = label;
|
||||
};
|
||||
btn.addEventListener("click", () => {
|
||||
const next = document.documentElement.dataset.rail === "compact" ? "full" : "compact";
|
||||
document.documentElement.dataset.rail = next;
|
||||
try {
|
||||
localStorage.setItem("volumen.rail", next);
|
||||
} catch (error) {}
|
||||
sync();
|
||||
});
|
||||
sync();
|
||||
})();
|
||||
|
||||
/* The strength meter of any field that sets a new password: the
|
||||
field opts in with data-pw-meter, the meter is the pw-level
|
||||
element that follows it (or its .pw wrapper). It scores length
|
||||
against the configured floor and the spread of character
|
||||
classes. Advice, not the gate: the common-password blocklist
|
||||
stays on the server. */
|
||||
(() => {
|
||||
const LEVELS = ["", "pw.weak", "pw.fair", "pw.good", "pw.strong"];
|
||||
const score = (value, min) => {
|
||||
if (!value) return 0;
|
||||
const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9\s]/]
|
||||
.filter((re) => re.test(value)).length;
|
||||
let steps = 0;
|
||||
if (value.length >= min) steps += 1;
|
||||
if (value.length >= min * 2) steps += 1;
|
||||
if (value.length >= min * 3) steps += 1;
|
||||
if (steps === 0) return value.length >= Math.ceil(min / 2) ? 1 : 0;
|
||||
return Math.min(4, steps + (classes >= 3 ? 1 : 0));
|
||||
};
|
||||
document.querySelectorAll("input[data-pw-meter]").forEach((input) => {
|
||||
let host = input.nextElementSibling;
|
||||
if (!host || !host.classList.contains("pw-level")) {
|
||||
const wrap = input.closest(".pw");
|
||||
host = wrap ? wrap.nextElementSibling : null;
|
||||
}
|
||||
if (!host || !host.classList.contains("pw-level")) return;
|
||||
const label = host.querySelector(".pw-level__label");
|
||||
const min = Number.parseInt(input.dataset.minLength || "10", 10);
|
||||
input.addEventListener("input", () => {
|
||||
const s = score(input.value, min);
|
||||
host.dataset.level = String(s);
|
||||
if (label) label.textContent = LEVELS[s] ? window.__(LEVELS[s]) : "";
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
/* A form that asks before it acts opts in with data-confirm: the
|
||||
native dialog answers in place of confirm(), which the platform
|
||||
retired. */
|
||||
(() => {
|
||||
const dialog = document.getElementById("confirm-dialog");
|
||||
if (!dialog) return;
|
||||
const message = document.getElementById("confirm-message");
|
||||
let pending = null;
|
||||
document.addEventListener("submit", (event) => {
|
||||
const form = event.target;
|
||||
if (form.dataset.confirmAccepted || !form.dataset.confirm) return;
|
||||
event.preventDefault();
|
||||
pending = form;
|
||||
message.textContent = form.dataset.confirm;
|
||||
dialog.showModal();
|
||||
});
|
||||
dialog.addEventListener("close", () => {
|
||||
if (dialog.returnValue === "ok" && pending) {
|
||||
pending.dataset.confirmAccepted = "1";
|
||||
pending.requestSubmit();
|
||||
}
|
||||
pending = null;
|
||||
});
|
||||
})();
|
||||
|
||||
/* The one style the pages compute at run time: the cover image of
|
||||
a post card, which belongs to the data and not to the sheet. */
|
||||
document.querySelectorAll("[data-cover]").forEach((node) => {
|
||||
node.style.setProperty("--cover-url", "url('" + node.dataset.cover + "')");
|
||||
});
|
||||
|
||||
/* A control that acts on change opts in with data-submit-on-change. */
|
||||
document.addEventListener("change", (event) => {
|
||||
const field = event.target;
|
||||
if (field.dataset.submitOnChange !== undefined && field.form) {
|
||||
field.form.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
/* The prompt the editor needs for a link URL and the clipboard
|
||||
fallback needs for a link to copy: a dialog with one field,
|
||||
because prompt() is gone from the platform. */
|
||||
window.__volumen.prompt = (label, initial = "") => {
|
||||
const dialog = document.getElementById("prompt-dialog");
|
||||
const field = document.getElementById("prompt-field");
|
||||
return new Promise((resolve) => {
|
||||
document.getElementById("prompt-label").textContent = label;
|
||||
field.value = initial;
|
||||
dialog.showModal();
|
||||
field.focus();
|
||||
const once = () => {
|
||||
resolve(dialog.returnValue === "ok" ? field.value : null);
|
||||
};
|
||||
dialog.addEventListener("close", once, { once: true });
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
/* Custom date picker: replaces <input type="date"> with a styled
|
||||
trigger that opens a calendar matching the design system. The
|
||||
native input stays in the DOM, so submitting works the same with
|
||||
or without JavaScript. */
|
||||
(() => {
|
||||
const MONTH_CACHE = {};
|
||||
const WEEK_CACHE = {};
|
||||
const FIRSTDAY_CACHE = {};
|
||||
|
||||
const langFor = (node) => node.closest("[lang]")?.getAttribute("lang")
|
||||
|| document.documentElement.lang || "en";
|
||||
|
||||
const monthNames = (lang) => MONTH_CACHE[lang] ??= Array.from(
|
||||
{ length: 12 },
|
||||
(_, i) => new Intl.DateTimeFormat(lang, { month: "long" })
|
||||
.format(new Date(2000, i, 15)),
|
||||
);
|
||||
|
||||
const weekdayNames = (lang, firstDay) => {
|
||||
const key = lang + "|" + firstDay;
|
||||
return WEEK_CACHE[key] ??= (() => {
|
||||
const fmt = new Intl.DateTimeFormat(lang, { weekday: "short" });
|
||||
const all = [1, 2, 3, 4, 5, 6, 7].map((d) => fmt.format(new Date(2001, 0, d)));
|
||||
const offset = firstDay - 1;
|
||||
return [...all.slice(offset), ...all.slice(0, offset)];
|
||||
})();
|
||||
};
|
||||
|
||||
const firstDay = (lang) => FIRSTDAY_CACHE[lang] ??= (() => {
|
||||
try {
|
||||
const info = new Intl.Locale(lang).getWeekInfo();
|
||||
if (info && typeof info.firstDay === "number") return info.firstDay;
|
||||
} catch (error) {}
|
||||
const mondayFirst = ["cs", "sk", "sl", "hr", "sr", "bs", "de", "fr", "es",
|
||||
"it", "nl", "pl", "pt", "ru", "uk", "sv", "da"];
|
||||
return mondayFirst.includes(lang) ? 1 : 7;
|
||||
})();
|
||||
|
||||
const formatDate = (lang, d) => new Intl.DateTimeFormat(lang, {
|
||||
day: "numeric", month: "long", year: "numeric",
|
||||
}).format(d);
|
||||
|
||||
const parseISO = (s) => {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s || "")) return null;
|
||||
const [y, m, d] = s.split("-").map(Number);
|
||||
const date = new Date(y, m - 1, d);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
};
|
||||
|
||||
const toISO = (d) =>
|
||||
d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0")
|
||||
+ "-" + String(d.getDate()).padStart(2, "0");
|
||||
|
||||
const sameDay = (a, b) => !!a && !!b
|
||||
&& a.getFullYear() === b.getFullYear()
|
||||
&& a.getMonth() === b.getMonth()
|
||||
&& a.getDate() === b.getDate();
|
||||
|
||||
const todayMidnight = () => {
|
||||
const n = new Date();
|
||||
return new Date(n.getFullYear(), n.getMonth(), n.getDate());
|
||||
};
|
||||
|
||||
const svgIcon = (name) => '<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#'
|
||||
+ name + '"></use></svg>';
|
||||
|
||||
const ICONS = {
|
||||
chevronLeft: "chevron-left",
|
||||
chevronRight: "chevron-right",
|
||||
calendar: "calendar",
|
||||
};
|
||||
|
||||
function buildPicker(input) {
|
||||
if (input.dataset.volumenPicker === "1") return;
|
||||
input.dataset.volumenPicker = "1";
|
||||
|
||||
const lang = langFor(input);
|
||||
const weekdayStart = firstDay(lang);
|
||||
let selected = parseISO(input.value || "");
|
||||
let viewYear, viewMonth;
|
||||
|
||||
/* Two pickers can share one page, so every control name carries
|
||||
the field's own label: a screen reader then hears which date
|
||||
the calendar belongs to. */
|
||||
const fieldLabel = input.closest(".field")
|
||||
?.querySelector(".field__label")?.textContent.trim() ?? "";
|
||||
const labelled = (id) => fieldLabel === ""
|
||||
? window.__(id)
|
||||
: window.__(id) + " (" + fieldLabel + ")";
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "date-input";
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "date-input__btn";
|
||||
button.setAttribute("aria-haspopup", "dialog");
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "date-input__label";
|
||||
|
||||
const iconEl = document.createElement("span");
|
||||
iconEl.className = "date-input__icon";
|
||||
iconEl.innerHTML = svgIcon(ICONS.calendar);
|
||||
button.append(labelEl, iconEl);
|
||||
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "date-input__popover";
|
||||
pop.setAttribute("role", "dialog");
|
||||
pop.setAttribute("aria-label", labelled("Pick a date"));
|
||||
pop.hidden = true;
|
||||
pop.innerHTML =
|
||||
'<div class="date-input__nav">'
|
||||
+ '<button type="button" class="date-input__nav-btn" data-prev aria-label="'
|
||||
+ labelled("datepicker.prev") + '">' + svgIcon(ICONS.chevronLeft) + "</button>"
|
||||
+ '<div class="date-input__nav-title"></div>'
|
||||
+ '<button type="button" class="date-input__nav-btn" data-next aria-label="'
|
||||
+ labelled("datepicker.next") + '">' + svgIcon(ICONS.chevronRight) + "</button>"
|
||||
+ "</div>"
|
||||
+ '<div class="date-input__weekdays"></div>'
|
||||
+ '<div class="date-input__days"></div>'
|
||||
+ '<div class="date-input__footer">'
|
||||
+ '<button type="button" class="date-input__footer-btn" data-clear>'
|
||||
+ labelled("Clear") + "</button>"
|
||||
+ '<button type="button" class="date-input__footer-btn date-input__footer-btn--primary" data-today>'
|
||||
+ labelled("Today") + "</button>"
|
||||
+ "</div>";
|
||||
|
||||
const weekdaysEl = pop.querySelector(".date-input__weekdays");
|
||||
const daysEl = pop.querySelector(".date-input__days");
|
||||
const titleEl = pop.querySelector(".date-input__nav-title");
|
||||
|
||||
const resetView = () => {
|
||||
const ref = selected || todayMidnight();
|
||||
viewYear = ref.getFullYear();
|
||||
viewMonth = ref.getMonth();
|
||||
};
|
||||
|
||||
function renderLabel() {
|
||||
if (selected) {
|
||||
labelEl.textContent = formatDate(lang, selected);
|
||||
button.dataset.empty = "0";
|
||||
} else {
|
||||
labelEl.textContent = input.placeholder || window.__("Pick a date");
|
||||
button.dataset.empty = "1";
|
||||
}
|
||||
}
|
||||
|
||||
function renderMonth() {
|
||||
titleEl.textContent = monthNames(lang)[viewMonth] + " " + viewYear;
|
||||
weekdaysEl.innerHTML = weekdayNames(lang, weekdayStart)
|
||||
.map((w) => '<div class="date-input__weekday">' + w + "</div>")
|
||||
.join("");
|
||||
const first = new Date(viewYear, viewMonth, 1);
|
||||
const firstWd = ((first.getDay() - weekdayStart + 7) % 7) + 1;
|
||||
const start = new Date(viewYear, viewMonth, 1 - (firstWd - 1));
|
||||
const today = todayMidnight();
|
||||
const cells = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
|
||||
let attrs = ' class="date-input__day" data-iso="' + toISO(d) + '"';
|
||||
if (d.getMonth() !== viewMonth) attrs += ' data-other-month="1"';
|
||||
if (sameDay(d, today)) attrs += ' data-today="1"';
|
||||
if (sameDay(d, selected)) attrs += ' data-selected="1"';
|
||||
cells.push("<button" + attrs + ' tabindex="-1" type="button">' + d.getDate() + "</button>");
|
||||
}
|
||||
daysEl.innerHTML = cells.join("");
|
||||
}
|
||||
|
||||
const commit = (d) => {
|
||||
selected = d;
|
||||
input.value = toISO(d);
|
||||
renderLabel();
|
||||
renderMonth();
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (!pop.hidden) return;
|
||||
resetView();
|
||||
renderMonth();
|
||||
pop.hidden = false;
|
||||
button.setAttribute("aria-expanded", "true");
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (pop.hidden) return;
|
||||
pop.hidden = true;
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
};
|
||||
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
pop.hidden ? open() : close();
|
||||
});
|
||||
|
||||
pop.addEventListener("click", (event) => {
|
||||
const day = event.target.closest(".date-input__day");
|
||||
if (day?.dataset.iso) {
|
||||
const d = parseISO(day.dataset.iso);
|
||||
if (d) {
|
||||
commit(d);
|
||||
close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.target.closest("[data-prev]")) {
|
||||
if (--viewMonth < 0) {
|
||||
viewMonth = 11;
|
||||
viewYear -= 1;
|
||||
}
|
||||
renderMonth();
|
||||
} else if (event.target.closest("[data-next]")) {
|
||||
if (++viewMonth > 11) {
|
||||
viewMonth = 0;
|
||||
viewYear += 1;
|
||||
}
|
||||
renderMonth();
|
||||
} else if (event.target.closest("[data-clear]")) {
|
||||
selected = null;
|
||||
input.value = "";
|
||||
renderLabel();
|
||||
renderMonth();
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
close();
|
||||
} else if (event.target.closest("[data-today]")) {
|
||||
commit(todayMidnight());
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mousedown", (event) => {
|
||||
if (!wrap.contains(event.target)) close();
|
||||
}, true);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && !pop.hidden) {
|
||||
close();
|
||||
button.focus();
|
||||
}
|
||||
}, true);
|
||||
|
||||
input.parentNode.insertBefore(wrap, input);
|
||||
input.classList.add("date-input__native");
|
||||
wrap.append(input, button, pop);
|
||||
renderLabel();
|
||||
}
|
||||
|
||||
const enhanceAll = () => document
|
||||
.querySelectorAll('input.control[type="date"], input[type="date"]')
|
||||
.forEach(buildPicker);
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", enhanceAll);
|
||||
} else {
|
||||
enhanceAll();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "scheme-toggle"}}
|
||||
<button class="icon-btn theme-toggle" type="button" data-scheme-toggle
|
||||
aria-label="{{ tr "Colour mode" }}"
|
||||
title="{{ tr "Follow system" }} / {{ tr "Light" }} / {{ tr "Dark" }}">
|
||||
<svg class="icon--system" aria-hidden="true"><use href="/admin/assets/graphis.svg#monitor"></use></svg>
|
||||
<svg class="icon--sun" aria-hidden="true"><use href="/admin/assets/graphis.svg#sun"></use></svg>
|
||||
<svg class="icon--moon" aria-hidden="true"><use href="/admin/assets/graphis.svg#moon"></use></svg>
|
||||
</button>
|
||||
{{end}}
|
||||
|
||||
{{define "confirm-dialog"}}
|
||||
<dialog id="confirm-dialog">
|
||||
<form method="dialog">
|
||||
<div class="dialog__head">
|
||||
<h2 id="confirm-title">{{ tr "Are you sure?" }}</h2>
|
||||
</div>
|
||||
<div class="dialog__body">
|
||||
<p id="confirm-message"></p>
|
||||
</div>
|
||||
<div class="dialog__foot">
|
||||
<button type="submit" value="cancel" class="btn btn--ghost">{{ tr "Cancel" }}</button>
|
||||
<button type="submit" value="ok" class="btn btn--primary">{{ tr "Continue" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="prompt-dialog">
|
||||
<form method="dialog">
|
||||
<div class="dialog__head">
|
||||
<h2 id="prompt-label"></h2>
|
||||
</div>
|
||||
<div class="dialog__body">
|
||||
<div class="field">
|
||||
<label class="field__label" for="prompt-field">{{ tr "URL" }}</label>
|
||||
<input id="prompt-field" class="control" type="text">
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog__foot">
|
||||
<button type="submit" value="cancel" class="btn btn--ghost">{{ tr "Cancel" }}</button>
|
||||
<button type="submit" value="ok" class="btn btn--primary">{{ tr "OK" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
{{end}}
|
||||
@@ -0,0 +1,462 @@
|
||||
{{define "title"}}{{ tr "Posts" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "content"}}
|
||||
{{if .Notice}}
|
||||
<div class="status status--ok" role="status">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#check-circle"></use></svg>
|
||||
<div>{{ .Notice }}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Stats.Total}}
|
||||
<dl class="stats">
|
||||
<div><dt>{{ tr "Published" }}</dt><dd>{{ .Stats.Published }}</dd></div>
|
||||
<div><dt>{{ tr "Drafts" }}</dt><dd>{{ .Stats.Drafts }}</dd></div>
|
||||
<div><dt>{{ tr "Scheduled" }}</dt><dd>{{ .Stats.Scheduled }}</dd></div>
|
||||
<div><dt>{{ tr "Total" }}</dt><dd>{{ .Stats.Total }}</dd></div>
|
||||
</dl>
|
||||
|
||||
{{if or .Stats.Recent .Stats.Upcoming}}
|
||||
<div class="grid--fit">
|
||||
{{if .Stats.Recent}}
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "Recent published" }}</h2>
|
||||
</div>
|
||||
<div class="quick">
|
||||
{{range .Stats.Recent}}
|
||||
<a class="quick__row" href="/admin/posts/{{ .Slug }}/edit">
|
||||
<span class="quick__title">{{ .Title }}</span>
|
||||
<span class="quick__when">{{ .DateString }}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{if .Stats.Upcoming}}
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "Upcoming" }}</h2>
|
||||
</div>
|
||||
<div class="quick">
|
||||
{{range .Stats.Upcoming}}
|
||||
<a class="quick__row" href="/admin/posts/{{ .Slug }}/edit">
|
||||
<span class="quick__title">{{ .Title }}</span>
|
||||
<span class="quick__when">{{ .When }}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="page-head__text">
|
||||
<h1>{{ tr "Posts" }}</h1>
|
||||
<p class="page-head__lede">{{ trn (len .Posts) "posts.total" }}</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<a class="btn btn--ghost" href="/admin/posts/import">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#upload"></use></svg>
|
||||
{{ tr "Import" }}
|
||||
</a>
|
||||
<a class="btn btn--primary" href="/admin/posts/new">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#plus"></use></svg>
|
||||
{{ tr "New post" }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<label class="bulk-all" id="bulk-select-wrap">
|
||||
<input type="checkbox" id="bulk-all" aria-label="{{ tr "Select all posts" }}">
|
||||
</label>
|
||||
<div class="search">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#search"></use></svg>
|
||||
<label class="visually-hidden" for="post-search">{{ tr "Search title, slug, tag..." }}</label>
|
||||
<input id="post-search" class="control" type="search"
|
||||
placeholder="{{ tr "Search title, slug, tag..." }}" value="{{ .Q }}">
|
||||
</div>
|
||||
<div class="filters" id="post-filters" role="group" aria-label="{{ tr "Filter by status" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#filter"></use></svg>
|
||||
<button type="button" class="chip" data-filter="all" aria-pressed="true">{{ tr "All" }}</button>
|
||||
<button type="button" class="chip" data-filter="published" aria-pressed="false">{{ tr "Published" }}</button>
|
||||
<button type="button" class="chip" data-filter="draft" aria-pressed="false">{{ tr "Drafts" }}</button>
|
||||
<button type="button" class="chip" data-filter="scheduled" aria-pressed="false">{{ tr "Scheduled" }}</button>
|
||||
</div>
|
||||
<span class="toolbar__count" id="post-count" aria-live="polite"></span>
|
||||
{{if .TagCounts}}
|
||||
<div class="filters" id="post-tag-filters" role="group" aria-label="{{ tr "Filter by tag" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#tag"></use></svg>
|
||||
{{range .TagCounts}}
|
||||
<button type="button" class="chip" data-tag="{{ .Name }}" aria-pressed="false"
|
||||
title="{{ trn .Count "posts.count" }}">{{ .Name }} <span class="chip__count">{{ .Count }}</span></button>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/posts/bulk" id="bulk-form" class="bulk-bar" hidden
|
||||
data-confirm="{{ tr "Delete the selected posts?" }}">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<input type="hidden" name="slugs" id="bulk-slugs" value="">
|
||||
<span class="bulk-bar__label"><span id="bulk-count">0</span></span>
|
||||
<button type="submit" name="action" value="publish" class="btn btn--sm">{{ tr "Publish" }}</button>
|
||||
<button type="submit" name="action" value="draft" class="btn btn--sm">{{ tr "Move to draft" }}</button>
|
||||
<button type="submit" name="action" value="delete" class="btn btn--sm btn--danger">{{ tr "Delete" }}</button>
|
||||
<span class="bulk-bar__spacer"></span>
|
||||
<button type="button" class="btn btn--ghost btn--sm" id="bulk-cancel">{{ tr "Cancel" }}</button>
|
||||
</form>
|
||||
|
||||
{{if not .Posts}}
|
||||
<div class="empty">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#file"></use></svg>
|
||||
<h2>{{ tr "No posts yet" }}</h2>
|
||||
<p class="empty__note">{{ tr "Create your first post to get started." }}</p>
|
||||
<a class="btn btn--primary" href="/admin/posts/new">{{ tr "Create post" }}</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="post-grid" id="post-grid" role="region" aria-label="{{ tr "Posts" }}">
|
||||
<div class="empty empty--span" id="post-no-results" hidden>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#search"></use></svg>
|
||||
<h2>{{ tr "No posts match your search" }}</h2>
|
||||
<p class="empty__note">{{ tr "Try a different search term or status filter." }}</p>
|
||||
<button type="button" class="btn btn--ghost" id="post-clear-filters">{{ tr "Clear filters" }}</button>
|
||||
</div>
|
||||
{{range .Posts}}
|
||||
<article class="post"
|
||||
data-title="{{ lower .Title }}"
|
||||
data-slug="{{ .Slug }}"
|
||||
data-tags="|{{ lower (join .Tags "|") }}|"
|
||||
data-excerpt="{{ lower .Excerpt }}"
|
||||
data-status="{{ .Status }}"
|
||||
{{if .VariantsJS}}data-variants="{{ .VariantsJS }}"{{end}}
|
||||
{{if .Timestamp}}data-timestamp="{{ .Timestamp }}"{{end}}>
|
||||
<label class="post__check">
|
||||
<input type="checkbox" class="bulk-one" value="{{ .GroupSlugs }}" aria-label="{{ trf "Select %s" .Title }}">
|
||||
</label>
|
||||
<a class="post__link" href="/admin/posts/{{ .Slug }}/edit">
|
||||
<div class="post__cover"{{if .Cover}} data-cover="{{ .Cover }}"{{end}}>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#image"></use></svg>
|
||||
</div>
|
||||
<div class="post__body">
|
||||
<h2 class="post__title">{{ .Title }}</h2>
|
||||
<p class="post__excerpt"{{if not .Excerpt}} hidden{{end}}>{{ .Excerpt }}</p>
|
||||
<div class="post__meta">
|
||||
{{if not .Variants}}<span class="tag">{{ .Lang }}</span>{{end}}
|
||||
<span class="tag tag--warn badge-draft" {{if not .Draft}}hidden{{end}}>{{ tr "Draft" }}</span>
|
||||
<span class="tag tag--accent badge-scheduled" {{if not .Scheduled}}hidden{{end}}>{{ tr "Scheduled" }}</span>
|
||||
{{if .Series}}<span class="tag tag--accent badge-series">{{ .Series }}{{if .HasOrder}} · {{ .SeriesOrder }}{{end}}</span>{{else}}<span class="tag tag--accent badge-series" hidden></span>{{end}}
|
||||
{{if .DateString}}<span class="post__date" data-date="{{ .DateString }}" title="{{ .DateString }}">{{ .DateString }}</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="post__foot">
|
||||
<a class="btn btn--ghost btn--sm" href="/admin/posts/{{ .Slug }}/edit">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#pencil"></use></svg>
|
||||
{{ tr "Edit" }}
|
||||
</a>
|
||||
{{if gt (len .Variants) 1}}
|
||||
<span class="post__langs" role="group" aria-label="{{ tr "Language version" }}">
|
||||
{{range .Variants}}<button type="button" class="chip chip--lang" data-lang="{{ .Lang }}" lang="{{ .Lang }}"
|
||||
aria-pressed="{{if eq .Lang $.Lang}}true{{else}}false{{end}}">{{ .Lang }}</button>{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
<span class="flex"></span>
|
||||
<form method="post" action="/admin/posts/{{ .Slug }}/delete"
|
||||
data-confirm="{{ tr "Delete this post?" }}" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="icon-btn icon-btn--danger" aria-label="{{ tr "Delete post" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#trash"></use></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
(() => {
|
||||
const search = document.getElementById("post-search");
|
||||
const filterButtons = document.querySelectorAll("#post-filters .chip");
|
||||
const tagButtons = document.querySelectorAll("#post-tag-filters .chip");
|
||||
const cards = document.querySelectorAll("#post-grid .post");
|
||||
const countEl = document.getElementById("post-count");
|
||||
const noResultsEl = document.getElementById("post-no-results");
|
||||
const clearBtn = document.getElementById("post-clear-filters");
|
||||
const bulkAll = document.getElementById("bulk-all");
|
||||
const bulkForm = document.getElementById("bulk-form");
|
||||
const bulkCount = document.getElementById("bulk-count");
|
||||
const bulkSlugs = document.getElementById("bulk-slugs");
|
||||
const bulkCancel = document.getElementById("bulk-cancel");
|
||||
const checkboxes = document.querySelectorAll(".bulk-one");
|
||||
let currentFilter = "all";
|
||||
let currentTag = "";
|
||||
const total = cards.length;
|
||||
|
||||
function applyFilters() {
|
||||
const q = (search.value || "").toLowerCase().trim();
|
||||
const tagQ = currentTag.toLowerCase();
|
||||
let visible = 0;
|
||||
cards.forEach((card) => {
|
||||
const tags = card.dataset.tags || "";
|
||||
const haystack = [card.dataset.title, card.dataset.slug, tags, card.dataset.excerpt]
|
||||
.join(" ");
|
||||
const hit = (!q || haystack.includes(q))
|
||||
&& (currentFilter === "all" || card.dataset.status === currentFilter)
|
||||
// Tags are matched with delimiters around each one, so a tag
|
||||
// containing a space still matches exactly.
|
||||
&& (!tagQ || tags.includes("|" + tagQ + "|"));
|
||||
card.classList.toggle("is-hidden", !hit);
|
||||
if (hit) visible += 1;
|
||||
});
|
||||
if (countEl) {
|
||||
const hasFilter = q || currentFilter !== "all" || currentTag !== "";
|
||||
countEl.textContent = hasFilter
|
||||
? window.__("filter.of").replace("%d", String(visible)).replace("%d", String(total))
|
||||
: window.__n("posts.count", total);
|
||||
countEl.classList.toggle("is-empty", visible === 0);
|
||||
}
|
||||
// The no-results panel lives inside the post grid, which an empty
|
||||
// instance does not render at all.
|
||||
if (noResultsEl) noResultsEl.hidden = visible !== 0;
|
||||
}
|
||||
|
||||
function visibleChecked() {
|
||||
return Array.from(checkboxes)
|
||||
.filter((cb) => cb.closest(".post") && !cb.closest(".post").classList.contains("is-hidden") && cb.checked);
|
||||
}
|
||||
|
||||
function updateBulkBar() {
|
||||
const selected = visibleChecked();
|
||||
const visibleCards = Array.from(cards).filter((c) => !c.classList.contains("is-hidden"));
|
||||
const visCbs = visibleCards.map((c) => c.querySelector(".bulk-one")).filter(Boolean);
|
||||
const allChecked = visCbs.length > 0 && visCbs.every((cb) => cb.checked);
|
||||
bulkAll.checked = allChecked;
|
||||
bulkAll.indeterminate = !allChecked && selected.length > 0;
|
||||
bulkForm.hidden = selected.length === 0;
|
||||
bulkCount.textContent = window.__n("bulk.selected", selected.length);
|
||||
bulkSlugs.value = selected.map((cb) => cb.value).join(",");
|
||||
}
|
||||
|
||||
const syncUrl = () => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const value = search.value.trim();
|
||||
if (value) params.set("q", value);
|
||||
else params.delete("q");
|
||||
const rest = params.toString();
|
||||
history.replaceState(null, "", location.pathname + (rest ? "?" + rest : "") + location.hash);
|
||||
};
|
||||
|
||||
search.addEventListener("input", () => { applyFilters(); updateBulkBar(); syncUrl(); });
|
||||
search.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && search.value !== "") {
|
||||
search.value = "";
|
||||
applyFilters();
|
||||
updateBulkBar();
|
||||
syncUrl();
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
filterButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
filterButtons.forEach((b) => { b.setAttribute("aria-pressed", String(b === btn)); });
|
||||
currentFilter = btn.dataset.filter;
|
||||
applyFilters();
|
||||
updateBulkBar();
|
||||
});
|
||||
});
|
||||
tagButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tag = btn.dataset.tag || "";
|
||||
currentTag = currentTag === tag ? "" : tag;
|
||||
tagButtons.forEach((b) => { b.setAttribute("aria-pressed", String(currentTag === b.dataset.tag)); });
|
||||
applyFilters();
|
||||
updateBulkBar();
|
||||
});
|
||||
});
|
||||
// The clear button rides the no-results panel, so an instance with no
|
||||
// posts at all never sees it.
|
||||
if (clearBtn) clearBtn.addEventListener("click", () => {
|
||||
search.value = "";
|
||||
currentFilter = "all";
|
||||
currentTag = "";
|
||||
filterButtons.forEach((b) => { b.setAttribute("aria-pressed", String(b.dataset.filter === "all")); });
|
||||
tagButtons.forEach((b) => { b.setAttribute("aria-pressed", "false"); });
|
||||
applyFilters();
|
||||
updateBulkBar();
|
||||
search.focus();
|
||||
syncUrl();
|
||||
});
|
||||
checkboxes.forEach((cb) => cb.addEventListener("change", updateBulkBar));
|
||||
bulkAll.addEventListener("change", () => {
|
||||
cards.forEach((card) => {
|
||||
if (card.classList.contains("is-hidden")) return;
|
||||
const cb = card.querySelector(".bulk-one");
|
||||
if (cb) cb.checked = bulkAll.checked;
|
||||
});
|
||||
updateBulkBar();
|
||||
});
|
||||
bulkCancel.addEventListener("click", () => {
|
||||
checkboxes.forEach((cb) => { cb.checked = false; });
|
||||
updateBulkBar();
|
||||
});
|
||||
|
||||
// --- Language variants ----------------------------------------------------
|
||||
/* A publication with language versions shows one card; the chips move
|
||||
it to another version: the name, the summary, the state, the date
|
||||
and the links of the card all follow the picked language file. */
|
||||
for (const card of document.querySelectorAll(".post[data-variants]")) {
|
||||
let variants;
|
||||
try { variants = JSON.parse(card.dataset.variants); } catch (error) { continue; }
|
||||
if (!Array.isArray(variants) || variants.length < 2) continue;
|
||||
const byLang = Object.groupBy(variants, (v) => v.lang);
|
||||
card.querySelectorAll("[data-lang]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const v = byLang[btn.dataset.lang]?.[0];
|
||||
if (!v) return;
|
||||
card.querySelectorAll("[data-lang]").forEach((b) => {
|
||||
b.setAttribute("aria-pressed", String(b === btn));
|
||||
});
|
||||
card.querySelector(".post__title").textContent = v.title;
|
||||
card.dataset.title = (v.title || "").toLowerCase();
|
||||
const excerpt = card.querySelector(".post__excerpt");
|
||||
excerpt.textContent = v.excerpt || "";
|
||||
excerpt.hidden = !v.excerpt;
|
||||
card.dataset.excerpt = (v.excerpt || "").toLowerCase();
|
||||
const dateEl = card.querySelector(".post__date");
|
||||
if (dateEl) {
|
||||
dateEl.textContent = v.date;
|
||||
dateEl.title = v.date;
|
||||
dateEl.dataset.date = v.date;
|
||||
}
|
||||
card.dataset.timestamp = v.timestamp || "";
|
||||
card.dataset.status = v.status;
|
||||
card.dataset.slug = v.slug;
|
||||
card.querySelector(".badge-draft").hidden = !v.draft;
|
||||
card.querySelector(".badge-scheduled").hidden = !v.scheduled;
|
||||
const series = card.querySelector(".badge-series");
|
||||
series.hidden = !v.series;
|
||||
series.textContent = v.series
|
||||
? v.series + (v.hasOrder ? " · " + v.seriesOrder : "") : "";
|
||||
card.querySelector(".post__link").href =
|
||||
"/admin/posts/" + encodeURIComponent(v.slug) + "/edit";
|
||||
card.querySelector(".post__foot form").action =
|
||||
"/admin/posts/" + encodeURIComponent(v.slug) + "/delete";
|
||||
const cover = card.querySelector(".post__cover");
|
||||
if (v.cover) {
|
||||
cover.dataset.cover = v.cover;
|
||||
cover.style.setProperty("--cover-url", "url('" + v.cover + "')");
|
||||
} else {
|
||||
delete cover.dataset.cover;
|
||||
cover.style.removeProperty("--cover-url");
|
||||
}
|
||||
applyFilters();
|
||||
updateBulkBar();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Pick up an initial ?q=… so a deep link or reload filters at once.
|
||||
{
|
||||
const urlQ = new URLSearchParams(location.search).get("q");
|
||||
if (urlQ && search.value === "") search.value = urlQ;
|
||||
}
|
||||
applyFilters();
|
||||
updateBulkBar();
|
||||
|
||||
// --- Relative time ----------------------------------------------------
|
||||
const rtf = Intl.RelativeTimeFormat
|
||||
? new Intl.RelativeTimeFormat(document.documentElement.lang || undefined, { numeric: "auto" })
|
||||
: null;
|
||||
const UNITS = [
|
||||
["year", 31536000000], ["month", 2592000000], ["week", 604800000],
|
||||
["day", 86400000], ["hour", 3600000], ["minute", 60000], ["second", 1000],
|
||||
];
|
||||
function applyRelativeTimes() {
|
||||
if (!rtf) return;
|
||||
const now = Date.now();
|
||||
cards.forEach((card) => {
|
||||
const ms = Number.parseInt(card.dataset.timestamp || "", 10);
|
||||
if (Number.isNaN(ms)) return;
|
||||
const diff = ms - now;
|
||||
const abs = Math.abs(diff);
|
||||
let unit = "second";
|
||||
let value = Math.round(diff / 1000);
|
||||
for (const [name, span] of UNITS) {
|
||||
if (abs >= span) {
|
||||
unit = name;
|
||||
value = Math.round(diff / span);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const dateEl = card.querySelector(".post__date");
|
||||
if (dateEl) dateEl.textContent = rtf.format(value, unit);
|
||||
});
|
||||
}
|
||||
applyRelativeTimes();
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) applyRelativeTimes();
|
||||
});
|
||||
|
||||
// --- Save toast ---------------------------------------------------------
|
||||
const SAVED_MESSAGES = {
|
||||
created: window.__("post.created"),
|
||||
updated: window.__("post.updated"),
|
||||
deleted: window.__("post.deleted"),
|
||||
duplicated: window.__("post.duplicated"),
|
||||
undone: window.__("post.undone"),
|
||||
undelete_failed: window.__("post.undelete_failed"),
|
||||
duplicate_failed: window.__("post.duplicate_failed"),
|
||||
};
|
||||
(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const saved = params.get("saved");
|
||||
if (!saved || !SAVED_MESSAGES[saved]) return;
|
||||
const drop = () => {
|
||||
params.delete("saved");
|
||||
params.delete("undo");
|
||||
const rest = params.toString();
|
||||
history.replaceState(null, "", location.pathname + (rest ? "?" + rest : "") + location.hash);
|
||||
};
|
||||
const undoSlug = params.get("undo");
|
||||
if (saved === "deleted" && undoSlug) {
|
||||
const csrf = document.querySelector("input[name='_csrf']")?.value || "";
|
||||
let consumed = false;
|
||||
window.__volumen.toast(SAVED_MESSAGES.deleted, {
|
||||
kind: "ok",
|
||||
ttl: 10000,
|
||||
action: {
|
||||
label: window.__("undo"),
|
||||
onClick: async () => {
|
||||
if (consumed) return;
|
||||
consumed = true;
|
||||
drop();
|
||||
try {
|
||||
const response = await fetch("/admin/posts/" + encodeURIComponent(undoSlug) + "/undelete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ _csrf: csrf }).toString(),
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (response.ok || response.redirected) window.location.reload();
|
||||
else window.__volumen.toast(window.__("undo.failed"), { kind: "error" });
|
||||
} catch (error) {
|
||||
window.__volumen.toast(window.__("undo.failed"), { kind: "error" });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// A failure reports as an error whatever its wording; the rest are
|
||||
// confirmations.
|
||||
const kind = saved.endsWith("_failed") ? "error" : "ok";
|
||||
window.__volumen.toast(SAVED_MESSAGES[saved], { kind });
|
||||
drop();
|
||||
}
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,76 @@
|
||||
{{define "title"}}{{ tr "Sign in" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "login_content"}}
|
||||
<div class="login__card">
|
||||
<h1 class="login__title">{{ tr "Sign in" }}</h1>
|
||||
<p class="login__pitch">{{ tr "Welcome back. Sign in to manage your posts." }}</p>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="status status--error" role="alert"
|
||||
{{if .RetryAfter}}id="login-lockout"{{end}}>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
|
||||
<div>
|
||||
<span class="lockout-msg">{{ .Error }}</span>
|
||||
{{if .RetryAfter}}<span class="lockout-count" id="lockout-countdown" data-seconds="{{ .RetryAfter }}"></span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/admin/login" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="username">{{ tr "Username" }}</label>
|
||||
<input id="username" class="control" type="text" name="username"
|
||||
autocomplete="username" required autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">{{ tr "Password" }}</label>
|
||||
<div class="pw">
|
||||
<input id="password" class="control" type="password" name="password"
|
||||
autocomplete="current-password" required>
|
||||
<button type="button" class="pw__toggle" id="pw-toggle" aria-label="{{ tr "Show password" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#eye"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary login__submit">
|
||||
{{ tr "Sign in" }}
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#arrow-right"></use></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
(() => {
|
||||
const btn = document.getElementById("pw-toggle");
|
||||
const input = document.getElementById("password");
|
||||
btn.addEventListener("click", () => {
|
||||
const show = input.type === "password";
|
||||
input.type = show ? "text" : "password";
|
||||
btn.setAttribute("aria-label", window.__(show ? "Hide password" : "Show password"));
|
||||
});
|
||||
|
||||
/* The lockout counts down to the minute, and the form waits with
|
||||
it: an honest disabled state, not a submit that fails. */
|
||||
const countdown = document.getElementById("lockout-countdown");
|
||||
if (!countdown) return;
|
||||
let seconds = Number.parseInt(countdown.dataset.seconds || "0", 10);
|
||||
const form = document.querySelector("form[action='/admin/login']");
|
||||
if (form) {
|
||||
form.querySelectorAll("input, button").forEach((el) => { el.disabled = true; });
|
||||
}
|
||||
const tick = () => {
|
||||
if (seconds <= 0) {
|
||||
countdown.textContent = window.__("login.retry");
|
||||
if (form) {
|
||||
form.querySelectorAll("input, button").forEach((el) => { el.disabled = false; });
|
||||
}
|
||||
return;
|
||||
}
|
||||
countdown.textContent = window.__("login.unlock").replace("{}", String(seconds));
|
||||
seconds -= 1;
|
||||
setTimeout(tick, 1000);
|
||||
};
|
||||
tick();
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,196 @@
|
||||
{{define "title"}}{{ tr "Media" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-head">
|
||||
<div class="page-head__text">
|
||||
<h1>{{ tr "Media" }}</h1>
|
||||
<p class="page-head__lede">{{ trn (len .MediaItems) "media.count" }}{{if .MediaTotal}} · {{ .MediaTotal }}{{end}}</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<button type="button" class="btn btn--primary" id="media-upload-btn">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#plus"></use></svg>
|
||||
{{ tr "Upload images" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="status status--error" role="alert">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
|
||||
<div>{{ .Error }}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<label class="dropzone dropzone--row" id="media-drop">
|
||||
<input id="media-file" type="file" accept="image/webp,image/avif,image/svg+xml" multiple class="dropzone__input"
|
||||
data-csrf="{{ .CSRFToken }}">
|
||||
<span class="dropzone__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#upload"></use></svg>
|
||||
</span>
|
||||
<span>
|
||||
<span class="dropzone__title">{{ tr "Drop images here, or click to browse" }}</span>
|
||||
<span class="dropzone__hint">{{ tr "WebP, AVIF or SVG, up to 10 MB each." }}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{{if not .MediaItems}}
|
||||
<div class="empty gap-top">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#image"></use></svg>
|
||||
<h2>{{ tr "No media yet" }}</h2>
|
||||
<p class="empty__note">{{ tr "Upload an image to use as a cover or inline content. WebP, AVIF and SVG are supported." }}</p>
|
||||
<p class="empty__note">{{ tr "You can also paste or drop an image directly into the editor body." }}</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#search"></use></svg>
|
||||
<label class="visually-hidden" for="media-search">{{ tr "Search files..." }}</label>
|
||||
<input id="media-search" class="control" type="search"
|
||||
placeholder="{{ tr "Search files..." }}" autocomplete="off">
|
||||
</div>
|
||||
<span class="toolbar__count" id="media-count" aria-live="polite" hidden></span>
|
||||
</div>
|
||||
|
||||
<div class="media-grid" id="media-grid">
|
||||
{{range .MediaItems}}
|
||||
<div class="card media-tile" data-name="{{ lower .Name }}">
|
||||
<a href="{{ .URL }}" target="_blank" rel="noopener" class="media-tile__thumb">
|
||||
<img src="{{ .URL }}" alt="{{ .Name }}" loading="lazy" decoding="async">
|
||||
</a>
|
||||
<div class="media-tile__name" title="{{ .Name }}">{{ .Name }}</div>
|
||||
<div class="media-tile__meta">{{ .SizeKB }} kB{{if .Dimensions}} · {{ .Dimensions }}{{end}}</div>
|
||||
<div class="media-tile__actions">
|
||||
<button type="button" class="icon-btn" data-copy="{{ .URL }}" title="{{ tr "Copy link" }}" aria-label="{{ tr "Copy link" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#copy"></use></svg>
|
||||
</button>
|
||||
<a class="icon-btn" href="{{ .URL }}" target="_blank" rel="noopener"
|
||||
title="{{ tr "Open in new tab" }}" aria-label="{{ tr "Open in new tab" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#external-link"></use></svg>
|
||||
</a>
|
||||
<span class="flex"></span>
|
||||
<form method="post" action="/admin/media/{{ .Name }}/delete"
|
||||
data-confirm="{{ trf "Delete %s?" .Name }}" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="icon-btn icon-btn--danger" title="{{ tr "Delete" }}" aria-label="{{ tr "Delete" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#trash"></use></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="empty empty--span gap-top" id="media-no-results" hidden>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#search"></use></svg>
|
||||
<h2>{{ tr "No files match your search." }}</h2>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
(() => {
|
||||
const drop = document.getElementById("media-drop");
|
||||
const input = document.getElementById("media-file");
|
||||
const headBtn = document.getElementById("media-upload-btn");
|
||||
const search = document.getElementById("media-search");
|
||||
const countEl = document.getElementById("media-count");
|
||||
const noResults = document.getElementById("media-no-results");
|
||||
const grid = document.getElementById("media-grid");
|
||||
let depth = 0;
|
||||
|
||||
headBtn.addEventListener("click", () => input.click());
|
||||
|
||||
/* The whole window tracks the drag so the strip answers anywhere. */
|
||||
["dragover", "drop"].forEach((evt) =>
|
||||
window.addEventListener(evt, (event) => event.preventDefault()));
|
||||
window.addEventListener("dragenter", (event) => {
|
||||
if (!event.dataTransfer?.types.includes("Files")) return;
|
||||
depth += 1;
|
||||
drop.classList.add("is-over");
|
||||
});
|
||||
window.addEventListener("dragleave", () => {
|
||||
depth = Math.max(0, depth - 1);
|
||||
if (depth === 0) drop.classList.remove("is-over");
|
||||
});
|
||||
drop.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
depth = 0;
|
||||
drop.classList.remove("is-over");
|
||||
const files = Array.from(event.dataTransfer?.files || []);
|
||||
if (files.length) uploadAll(files);
|
||||
});
|
||||
input.addEventListener("change", () => {
|
||||
const files = Array.from(input.files || []);
|
||||
if (files.length) uploadAll(files);
|
||||
input.value = "";
|
||||
});
|
||||
|
||||
const isUploadable = (file) => /\.(webp|avif|svg)$/i.test(file.name)
|
||||
|| file.type === "image/webp" || file.type === "image/avif" || file.type === "image/svg+xml";
|
||||
|
||||
async function uploadOne(file) {
|
||||
window.__volumen.toast(window.__f("media.uploading", file.name), { kind: "ok", ttl: 1500 });
|
||||
const data = new FormData();
|
||||
data.append("file", file);
|
||||
data.append("_csrf", input.dataset.csrf);
|
||||
try {
|
||||
const response = await fetch("/admin/uploads", { method: "POST", body: data, credentials: "same-origin" });
|
||||
const json = await response.json();
|
||||
if (response.ok && json.url) return true;
|
||||
window.__volumen.toast(
|
||||
window.__f("media.upload_failed", json.message || json.error || file.name),
|
||||
{ kind: "error" });
|
||||
} catch (error) {
|
||||
window.__volumen.toast(window.__f("media.upload_failed", file.name), { kind: "error" });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function uploadAll(files) {
|
||||
let done = 0;
|
||||
for (const file of files) {
|
||||
if (!isUploadable(file)) {
|
||||
window.__volumen.toast(window.__f("upload.rejected", file.name), { kind: "error" });
|
||||
continue;
|
||||
}
|
||||
if (await uploadOne(file)) done += 1;
|
||||
}
|
||||
if (done > 0) {
|
||||
window.__volumen.toast(window.__("upload.complete"), { kind: "ok" });
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Copy link -----------------------------------------------------------
|
||||
grid?.addEventListener("click", async (event) => {
|
||||
const btn = event.target.closest("[data-copy]");
|
||||
if (!btn) return;
|
||||
const url = btn.dataset.copy;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
window.__volumen.toast(window.__("media.copied"), { kind: "ok" });
|
||||
} catch (error) {
|
||||
const shown = await window.__volumen.prompt(window.__("media.copy_prompt"), url);
|
||||
if (shown) try { await navigator.clipboard.writeText(shown); } catch (inner) {}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Search ----------------------------------------------------------------
|
||||
search?.addEventListener("input", () => {
|
||||
const q = search.value.toLowerCase().trim();
|
||||
let visible = 0;
|
||||
grid.querySelectorAll(".media-tile").forEach((tile) => {
|
||||
const hit = !q || (tile.dataset.name || "").includes(q);
|
||||
tile.classList.toggle("is-hidden", !hit);
|
||||
if (hit) visible += 1;
|
||||
});
|
||||
const total = grid.querySelectorAll(".media-tile").length;
|
||||
countEl.hidden = !q;
|
||||
if (q) {
|
||||
countEl.textContent = window.__("filter.of")
|
||||
.replace("%d", String(visible)).replace("%d", String(total));
|
||||
}
|
||||
noResults.hidden = visible !== 0;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "title"}}{{ tr "Page not found" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="empty">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#compass"></use></svg>
|
||||
<h1>{{ tr "Page not found" }}</h1>
|
||||
<p class="empty__note">{{ tr "The address does not exist; it may have been moved or deleted." }}</p>
|
||||
<a class="btn btn--primary" href="/admin/">{{ tr "Back to dashboard" }}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,753 @@
|
||||
{{define "title"}}{{ tr "Settings" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-head">
|
||||
<div class="page-head__text">
|
||||
<h1>{{ tr "Settings" }}</h1>
|
||||
<p class="page-head__lede">{{ tr "Manage your account and users" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Notice}}
|
||||
<div class="status status--ok" role="status">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#check"></use></svg>
|
||||
<div>{{ .Notice }}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Error}}
|
||||
<div class="status status--error" role="alert">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
|
||||
<div>{{ .Error }}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="settings">
|
||||
<nav class="settings__nav" aria-label="{{ tr "Settings" }}">
|
||||
<a href="#account" class="settings__link">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#user"></use></svg>
|
||||
{{ tr "Account" }}
|
||||
</a>
|
||||
{{if .IsAdmin}}
|
||||
<a href="#users" class="settings__link">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#users"></use></svg>
|
||||
{{ tr "Users" }}
|
||||
</a>
|
||||
<a href="#templates" class="settings__link">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#file-text"></use></svg>
|
||||
{{ tr "Templates" }}
|
||||
</a>
|
||||
<a href="#backup" class="settings__link">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#download"></use></svg>
|
||||
{{ tr "Backup" }}
|
||||
</a>
|
||||
<a href="#version" class="settings__link">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#repeat"></use></svg>
|
||||
{{ tr "Version" }}
|
||||
</a>
|
||||
<a href="#webhooks" class="settings__link">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#compare"></use></svg>
|
||||
{{ tr "Webhooks" }}
|
||||
</a>
|
||||
<a href="#tokens" class="settings__link">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#key"></use></svg>
|
||||
{{ tr "API tokens" }}
|
||||
</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
|
||||
<div class="settings__panels">
|
||||
<section id="account" class="card">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "Account" }}</h2>
|
||||
<p class="card__note">{{ tr "Signed in as" }} <strong>{{ .CurrentUser }}</strong> · {{ tr .CurrentRole }}</p>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#user"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Profile photo" }}</h3>
|
||||
<p class="setting__desc">{{ tr "WebP, AVIF or SVG, max 10 MB. Shown in the sidebar." }}</p>
|
||||
<form method="post" action="/admin/settings/photo" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="photo-row">
|
||||
{{if .CurrentUserRecord.Photo}}
|
||||
<img src="{{ .CurrentUserRecord.Photo }}" alt="" class="photo-row__preview">
|
||||
{{else}}
|
||||
<span class="photo-row__preview photo-row__placeholder">{{ .UserInitial }}</span>
|
||||
{{end}}
|
||||
<div class="photo-row__fields">
|
||||
<input type="file" name="photo" accept="image/webp,image/avif,image/svg+xml" required>
|
||||
<div class="photo-row__buttons">
|
||||
<button type="submit" class="btn btn--primary btn--sm">{{ tr "Upload" }}</button>
|
||||
{{if .CurrentUserRecord.Photo}}
|
||||
<button type="submit" form="remove-photo-form" class="btn btn--danger btn--sm">{{ tr "Remove" }}</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{if .CurrentUserRecord.Photo}}
|
||||
<form id="remove-photo-form" method="post" action="/admin/settings/photo/remove" hidden>
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#target"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Language" }}</h3>
|
||||
<p class="setting__desc">{{ tr "The interface language of your account. The login screen follows your last choice." }}</p>
|
||||
<form method="post" action="/admin/settings/language" class="pill-group">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" name="language" value="en" class="chip" aria-pressed="{{if eq .Lang "en"}}true{{else}}false{{end}}" lang="en">English</button>
|
||||
<button type="submit" name="language" value="cs" class="chip" aria-pressed="{{if eq .Lang "cs"}}true{{else}}false{{end}}" lang="cs">Čeština</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#droplet"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Colour scheme" }}</h3>
|
||||
<p class="setting__desc">{{ tr "The colour scheme of your account. The login screen follows your last choice." }}</p>
|
||||
<form method="post" action="/admin/settings/theme" class="pill-group">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" name="theme" value="viridis" class="chip" aria-pressed="{{if eq .Theme "viridis"}}true{{else}}false{{end}}">Viridis</button>
|
||||
<button type="submit" name="theme" value="plasma" class="chip" aria-pressed="{{if eq .Theme "plasma"}}true{{else}}false{{end}}">Plasma</button>
|
||||
<button type="submit" name="theme" value="magma" class="chip" aria-pressed="{{if eq .Theme "magma"}}true{{else}}false{{end}}">Magma</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#type"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Identity" }}</h3>
|
||||
<p class="setting__desc">{{ tr "Display name pre-fills the author field. Fediverse handle pre-fills the creator, and the ORCID pre-fills the author identifier of new publications." }}</p>
|
||||
<form method="post" action="/admin/settings/name" class="setting__row">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">{{ tr "Display name" }}</label>
|
||||
<input id="name" class="control" type="text" name="name" value="{{ .CurrentUserRecord.Name }}"
|
||||
placeholder="{{ tr "Your real name" }}">
|
||||
</div>
|
||||
<button type="submit" class="btn">{{ tr "Save name" }}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/settings/fediverse" class="setting__row gap-top">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="fediverse_creator">{{ tr "Fediverse handle" }}</label>
|
||||
<input id="fediverse_creator" class="control" type="text" name="fediverse_creator"
|
||||
value="{{ .CurrentUserRecord.FediverseCreator }}" placeholder="{{ tr "@user@instance.tld" }}">
|
||||
</div>
|
||||
<button type="submit" class="btn">{{ tr "Save handle" }}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/settings/orcid" class="setting__row gap-top">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="orcid">{{ tr "ORCID iD" }}</label>
|
||||
<input id="orcid" class="control" type="text" name="orcid"
|
||||
value="{{ .CurrentUserRecord.Orcid }}" placeholder="{{ tr "0000-0002-1825-0097" }}">
|
||||
<p class="field__hint">{{ tr "Your Open Researcher and Contributor ID; it pre-fills the author field of new publications." }}</p>
|
||||
</div>
|
||||
<button type="submit" class="btn">{{ tr "Save ORCID" }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#lock"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Security" }}</h3>
|
||||
<p class="setting__desc">{{ tr "Change your password and username. You can also lock yourself out." }}</p>
|
||||
<form method="post" action="/admin/settings/password">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label class="field__label" for="current_password">{{ tr "Current password" }}</label>
|
||||
<input id="current_password" class="control" type="password" name="current_password"
|
||||
autocomplete="current-password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new_password">{{ tr "New password" }}</label>
|
||||
<input id="new_password" class="control" type="password" name="new_password"
|
||||
autocomplete="new-password" data-pw-meter
|
||||
data-min-length="{{ .Config.Admin.MinPasswordLength }}"
|
||||
aria-describedby="new-password-strength">
|
||||
<div class="pw-level" data-level="0">
|
||||
<div class="pw-level__bar" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
|
||||
<span class="pw-level__label" id="new-password-strength" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn gap-top">{{ tr "Update password" }}</button>
|
||||
</form>
|
||||
|
||||
<div class="totp gap-top" id="twofactor">
|
||||
<h4 class="totp__title">{{ tr "Two-factor authentication" }}</h4>
|
||||
{{if .RecoveryCodes}}
|
||||
<div class="status status--info" role="status">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#key"></use></svg>
|
||||
<div>{{ .RecoveryNotice }}</div>
|
||||
</div>
|
||||
<div class="recovery">
|
||||
{{range .RecoveryCodes}}<code class="recovery__code">{{ . }}</code>{{end}}
|
||||
</div>
|
||||
{{else if .TotpEnabled}}
|
||||
<p class="setting__desc">{{ tr "Your sign-in asks for a code from your application after the password." }}</p>
|
||||
<div class="totp__forms">
|
||||
<form method="post" action="/admin/settings/twofactor/codes" class="totp__form">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="codes-code">{{ tr "Current code" }}</label>
|
||||
<input id="codes-code" class="control mono" type="text" name="code"
|
||||
inputmode="numeric" autocomplete="one-time-code" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">{{ tr "Regenerate recovery codes" }}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/settings/twofactor/disable" class="totp__form"
|
||||
data-confirm="{{ tr "Turn two-factor off? Your application will no longer be asked." }}">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="off-code">{{ tr "Current code" }}</label>
|
||||
<input id="off-code" class="control mono" type="text" name="code"
|
||||
inputmode="numeric" autocomplete="one-time-code" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--danger">{{ tr "Turn off" }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{{else if .TotpPending}}
|
||||
<p class="setting__desc">{{ tr "Scan the code with your authenticator application, or enter the secret by hand, then confirm with the code it shows." }}</p>
|
||||
<div class="totp__setup">
|
||||
<div class="totp__qr">{{ .TotpSVG }}</div>
|
||||
<div class="totp__setup-side">
|
||||
<p class="totp__secret-label">{{ tr "Secret" }}</p>
|
||||
<code class="totp__secret">{{ .TotpSecret }}</code>
|
||||
<form method="post" action="/admin/settings/twofactor/verify" class="totp__form">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="verify-code">{{ tr "Verification code" }}</label>
|
||||
<input id="verify-code" class="control mono" type="text" name="code"
|
||||
inputmode="numeric" autocomplete="one-time-code" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary">{{ tr "Verify and enable" }}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/settings/twofactor/cancel">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn btn--ghost btn--sm">{{ tr "Cancel" }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="setting__desc">{{ tr "Ask for a code from your authenticator application after the password. Voluntary: nothing changes until you finish the setup." }}</p>
|
||||
<form method="post" action="/admin/settings/twofactor/start">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn">{{ tr "Set up two-factor" }}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/settings/username" class="setting__row gap-top">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="username">{{ tr "Username" }}</label>
|
||||
<input id="username" class="control mono" type="text" name="username" value="{{ .CurrentUser }}">
|
||||
</div>
|
||||
<button type="submit" class="btn">{{ tr "Update username" }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if .IsAdmin}}
|
||||
<section id="users" class="card card--flush">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "Users" }}</h2>
|
||||
<p class="card__note">{{ tr "Add or remove admin and editor accounts" }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{range .UserRows}}
|
||||
<div class="user-row">
|
||||
{{if .Photo}}
|
||||
<span class="avatar"><img src="{{ .Photo }}" alt=""></span>
|
||||
{{else}}
|
||||
<span class="avatar">{{ .Initial }}</span>
|
||||
{{end}}
|
||||
<span class="user-row__info">
|
||||
<span class="user-row__name">
|
||||
{{ .Display }}
|
||||
{{if .IsSelf}} <span class="tag tag--accent">{{ tr "you" }}</span>{{end}}
|
||||
</span>
|
||||
<span class="user-row__username">@{{ .Username }}</span>
|
||||
</span>
|
||||
<div class="user-row__actions">
|
||||
{{if .IsSelf}}
|
||||
<span class="tag">{{ tr .Role }}</span>
|
||||
{{else}}
|
||||
<form method="post" action="/admin/settings/users/{{ .Username }}/role" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<select class="control control--sm" name="role" data-submit-on-change>
|
||||
{{range .RoleOptions}}
|
||||
<option value="{{ .Value }}" {{if .Selected}}selected{{end}}>{{ tr .Value }}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</form>
|
||||
<details class="user-row__reset">
|
||||
<summary class="btn btn--ghost btn--sm">{{ tr "Reset password" }}</summary>
|
||||
<form method="post" action="/admin/settings/users/{{ .Username }}/password"
|
||||
class="user-row__reset-form"
|
||||
data-confirm="{{ tr "Reset this user's password? Their sessions will be signed out." }}">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<input class="control control--sm" type="password" name="password"
|
||||
autocomplete="new-password" placeholder="{{ tr "New password" }}" required
|
||||
data-pw-meter data-min-length="{{ $.Config.Admin.MinPasswordLength }}"
|
||||
aria-describedby="pw-strength-{{ .Username }}">
|
||||
<div class="pw-level" data-level="0">
|
||||
<div class="pw-level__bar" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
|
||||
<span class="pw-level__label" id="pw-strength-{{ .Username }}" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--sm">{{ tr "Reset password" }}</button>
|
||||
</form>
|
||||
</details>
|
||||
<form method="post" action="/admin/settings/users/{{ .Username }}/delete"
|
||||
data-confirm="{{ tr "Remove this user?" }}" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn btn--danger btn--sm">{{ tr "Remove" }}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/settings/users" class="user-add">
|
||||
<h3>{{ tr "Add user" }}</h3>
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-username">{{ tr "Username" }}</label>
|
||||
<input id="new-username" class="control mono" type="text" name="username" placeholder="{{ tr "jane-doe" }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-password">{{ tr "Password" }}</label>
|
||||
<input id="new-password" class="control" type="password" name="password" autocomplete="new-password"
|
||||
data-pw-meter data-min-length="{{ .Config.Admin.MinPasswordLength }}"
|
||||
aria-describedby="new-user-password-strength">
|
||||
<div class="pw-level" data-level="0">
|
||||
<div class="pw-level__bar" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
|
||||
<span class="pw-level__label" id="new-user-password-strength" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-role">{{ tr "Role" }}</label>
|
||||
<select id="new-role" class="control" name="role">
|
||||
{{range .Roles}}
|
||||
<option value="{{ . }}" {{if eq . $.DefaultRole}}selected{{end}}>{{ tr . }}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary">{{ tr "Add user" }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="templates" class="card">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "Post templates" }}</h2>
|
||||
<p class="card__note">{{ tr "Pre-fill new posts with a reusable structure" }}</p>
|
||||
</div>
|
||||
|
||||
{{if .TemplatesList}}
|
||||
<ul class="kv-stack">
|
||||
{{range .TemplatesList}}
|
||||
<li class="kv-row">
|
||||
<span><strong>{{ .Name }}</strong>{{if .Tags}}<span class="muted">: {{ join .Tags ", " }}</span>{{end}}{{if .FieldsText}}<span class="muted mono"> · {{ .FieldsText }}</span>{{end}}</span>
|
||||
<form method="post" action="/admin/settings/templates/{{ .Name }}/delete"
|
||||
data-confirm="{{ trf "Delete %s?" .Name }}" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn btn--danger btn--sm">{{ tr "Delete" }}</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="setting__desc">{{ tr "No templates yet. Templates pre-fill the new-post form so you can keep your favourite structure on hand." }}</p>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/admin/settings/templates" class="stack gap-top">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label class="field__label" for="tpl-name">{{ tr "Name" }}</label>
|
||||
<input id="tpl-name" class="control" type="text" name="name" placeholder="{{ tr "e.g. Review" }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="tpl-title">{{ tr "Default title" }}</label>
|
||||
<input id="tpl-title" class="control" type="text" name="title" placeholder="{{ tr "Optional" }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="tpl-slug">{{ tr "Default slug" }}</label>
|
||||
<input id="tpl-slug" class="control mono" type="text" name="slug" placeholder="{{ tr "Optional" }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="tpl-tags">{{ tr "Tags (comma-separated)" }}</label>
|
||||
<input id="tpl-tags" class="control" type="text" name="tags" placeholder="review, opinion">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="tpl-body">{{ tr "Body template (Markdown)" }}</label>
|
||||
<textarea id="tpl-body" class="control control--mono" name="body" rows="5"
|
||||
placeholder="## Summary ## Verdict "></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="tpl-fields">{{ tr "Fields to pre-fill" }}</label>
|
||||
<textarea id="tpl-fields" class="control control--mono" name="fields" rows="4"
|
||||
placeholder="series = "tds" doi = "10.5281/…" series_order = 3"></textarea>
|
||||
<p class="field__hint">{{ tr "Extra editor inputs as TOML key = value lines, one per line, strings quoted: author, lang, doi, orcid, series, series_order, cover, excerpt." }}</p>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary">{{ tr "Add template" }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="backup" class="card">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "Backup & restore" }}</h2>
|
||||
<p class="card__note">{{ tr "Export or import all posts, media, users, and templates" }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="stack">
|
||||
<h3>{{ tr "Export" }}</h3>
|
||||
<p class="setting__desc">{{ trh "Download a <code>.tar.gz</code> archive of your entire site." }}</p>
|
||||
<a href="/admin/settings/export" class="btn btn--primary">{{ tr "Download backup" }}</a>
|
||||
</div>
|
||||
<div class="stack">
|
||||
<h3>{{ tr "Import" }}</h3>
|
||||
<p class="setting__desc">{{ tr "Restore from a previously exported archive. Overwrites existing data." }}</p>
|
||||
<form method="post" action="/admin/settings/import" enctype="multipart/form-data"
|
||||
data-confirm="{{ tr "This will overwrite existing posts and users. Continue?" }}">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="visually-hidden" for="backup-file">{{ tr "Restore backup" }}</label>
|
||||
<input id="backup-file" type="file" name="backup" accept=".tar.gz,.tgz" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--danger gap-top">{{ tr "Restore backup" }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="version" class="card">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "Version" }}</h2>
|
||||
<p class="card__note">{{ tr "Keep Volumen up to date" }}</p>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#refresh"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Installed version" }}</h3>
|
||||
<p class="setting__desc">
|
||||
{{ tr "Running" }} <strong class="mono">{{ .Version }}</strong>
|
||||
{{if .UpdateAvailable}}
|
||||
· {{ tr "latest" }} <strong class="mono">{{ .UpdateAvailable }}</strong>
|
||||
{{end}}
|
||||
</p>
|
||||
{{if .UpdateAvailable}}
|
||||
<div class="status status--warn">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
|
||||
<div>
|
||||
<strong>Volumen {{ .UpdateAvailable }}</strong> {{ tr "is available; you are running" }} {{ .Version }}.
|
||||
{{ tr "The server will restart." }}
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/admin/settings/update"
|
||||
data-confirm="{{ trf "Update to %s now?" .UpdateAvailable }} {{ tr "The server will restart." }}">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn btn--primary">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#refresh"></use></svg>
|
||||
{{ trf "Update to %s" .UpdateAvailable }}
|
||||
</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="post" action="/admin/settings/check-update">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn btn--ghost">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#repeat"></use></svg>
|
||||
{{ tr "Check for updates" }}
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="webhooks" class="card">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "Webhooks" }}</h2>
|
||||
<p class="card__note">{{ tr "Notify external services when posts change" }}</p>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#compare"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Endpoints" }}</h3>
|
||||
{{if not .WebhookRows}}
|
||||
<p class="setting__desc">
|
||||
{{ trh "No webhooks yet. Add an endpoint below, or declare <code>[[webhooks]]</code> blocks in <code>config.toml</code> (those are read-only here):" }}
|
||||
</p>
|
||||
<pre class="webhook-example"><code>[[webhooks]]
|
||||
url = "https://example.com/hooks/rebuild"
|
||||
secret = "a-long-random-string" # HMAC-SHA256 signing key
|
||||
events = ["post.created", "post.updated", "post.deleted", "post.published"]</code></pre>
|
||||
{{else}}
|
||||
<p class="setting__desc">{{ tr "Signed JSON payloads are POSTed on post changes. Changes apply without a restart." }}</p>
|
||||
<div class="card card--flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>URL</th><th>{{ tr "Events" }}</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .WebhookRows}}
|
||||
<tr>
|
||||
<td class="mono break-all">
|
||||
{{ .URL }}
|
||||
{{if not .Enabled}}<span class="tag tag--warn">{{ tr "disabled" }}</span>{{end}}
|
||||
{{if .Signed}}<span class="tag">{{ tr "signed" }}</span>{{end}}
|
||||
{{if not .Managed}}<span class="tag">{{ tr "from config" }}</span>{{end}}
|
||||
</td>
|
||||
<td>{{ .EventsText }}</td>
|
||||
<td class="table__actions">
|
||||
<form method="post" action="/admin/settings/webhooks/{{ .Index }}/test" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn btn--ghost btn--sm">{{ tr "Send test" }}</button>
|
||||
</form>
|
||||
{{if .Managed}}
|
||||
<form method="post" action="/admin/settings/webhooks/toggle" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<input type="hidden" name="url" value="{{ .URL }}">
|
||||
{{if .Enabled}}<button type="submit" class="btn btn--ghost btn--sm">{{ tr "Disable" }}</button>
|
||||
{{else}}<button type="submit" class="btn btn--ghost btn--sm">{{ tr "Enable" }}</button>{{end}}
|
||||
</form>
|
||||
<form method="post" action="/admin/settings/webhooks/delete" class="form-inline"
|
||||
data-confirm="{{ tr "Remove this webhook?" }}">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<input type="hidden" name="url" value="{{ .URL }}">
|
||||
<button type="submit" class="btn btn--danger btn--sm">{{ tr "Remove" }}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
<details>
|
||||
<summary class="btn btn--ghost btn--sm">{{ tr "Add webhook" }}</summary>
|
||||
<form method="post" action="/admin/settings/webhooks" class="stack gap-top">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label class="field__label" for="hook-url">{{ tr "Endpoint URL" }}</label>
|
||||
<input id="hook-url" class="control mono" type="url" name="url" required
|
||||
placeholder="https://example.com/hooks/rebuild">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="hook-secret">{{ tr "Signing secret (optional)" }}</label>
|
||||
<input id="hook-secret" class="control" type="text" name="secret" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="hook-events">{{ tr "Events (comma-separated, empty for all)" }}</label>
|
||||
<input id="hook-events" class="control mono" type="text" name="events"
|
||||
placeholder="post.created, post.updated, post.deleted, post.published">
|
||||
</div>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="enabled" checked>
|
||||
<span>{{ tr "Enabled" }}</span>
|
||||
</label>
|
||||
<button type="submit" class="btn">{{ tr "Add" }}</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .WebhookDeliveries}}
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#activity"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Recent deliveries" }}</h3>
|
||||
<p class="setting__desc">{{ tr "Kept in memory; cleared on restart." }}</p>
|
||||
<div class="card card--flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>{{ tr "When" }}</th><th>{{ tr "Event" }}</th><th>{{ tr "Endpoint" }}</th><th>{{ tr "Result" }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .WebhookDeliveries}}
|
||||
<tr>
|
||||
<td class="mono">{{ .Timestamp }}</td>
|
||||
<td><span class="tag">{{ .Event }}</span></td>
|
||||
<td class="mono break-all">{{ .HookURL }}</td>
|
||||
<td>
|
||||
{{if .OK}}
|
||||
<span class="ok-text mono">HTTP {{ .StatusCode }}</span>
|
||||
{{else}}
|
||||
<span class="danger-text" title="{{ .Error }}">{{ .Result }}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section id="tokens" class="card">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "API tokens" }}</h2>
|
||||
<p class="card__note">{{ tr "Programmatic write access to the public API" }}</p>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#key"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Access tokens" }}</h3>
|
||||
<p class="setting__desc">{{ trh "Send a token as <code>Authorization: Bearer <token></code> on <code>POST/PUT/DELETE /api/volumen/posts</code>." }}</p>
|
||||
{{if .NewToken}}
|
||||
<div class="status status--warn">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-triangle"></use></svg>
|
||||
<div>
|
||||
<strong>{{ tr "Copy this token now; it will never be shown again." }}</strong>
|
||||
<div class="gap-top"><code class="select-all">{{ .NewToken }}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .TokenRows}}
|
||||
<div class="card card--flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>{{ tr "Name" }}</th><th>{{ tr "Created" }}</th><th>{{ tr "Last used" }}</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .TokenRows}}
|
||||
<tr>
|
||||
<td><strong>{{ .Name }}</strong></td>
|
||||
<td class="mono">{{ .CreatedDay }}</td>
|
||||
<td class="mono">{{ .LastUsedDay }}</td>
|
||||
<td class="table__actions">
|
||||
<form method="post" action="/admin/settings/tokens/{{ .Name }}/delete" class="form-inline"
|
||||
data-confirm="{{ trf "Revoke token “%s”? Scripts using it will stop working." .Name }}">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn btn--danger btn--sm">{{ tr "Revoke" }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="setting__desc">{{ tr "No tokens yet. Create one to publish posts from scripts or CI." }}</p>
|
||||
{{end}}
|
||||
<form method="post" action="/admin/settings/tokens" class="stack gap-top">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="setting__row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="token-name">{{ tr "Token name" }}</label>
|
||||
<input id="token-name" class="control" type="text" name="name"
|
||||
placeholder="{{ tr "e.g. deploy-script" }}" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary">{{ tr "Create token" }}</button>
|
||||
</div>
|
||||
<fieldset class="field">
|
||||
<legend class="field__label">{{ tr "Scopes" }}</legend>
|
||||
<div class="check-row">
|
||||
<label class="check">
|
||||
<input type="checkbox" name="scope" value="write" checked>
|
||||
<span>{{ tr "Write" }}</span>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="scope" value="delete">
|
||||
<span>{{ tr "Delete" }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field__hint">{{ tr "Write creates and edits posts; delete removes them. Read endpoints are public, so no scope is needed for them. Selecting neither grants full access." }}</p>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
/* The settings panels switch one at a time: the URL keeps the section,
|
||||
and a form POST that lands without a fragment restores the section
|
||||
that was last open. */
|
||||
(() => {
|
||||
const sections = document.querySelectorAll(".settings__panels section[id]");
|
||||
const navLinks = document.querySelectorAll(".settings__nav a[href^='#']");
|
||||
if (!sections.length || !navLinks.length) return;
|
||||
|
||||
const KEY = "volumen.settings.section";
|
||||
const byId = (id) => Array.from(sections).find((s) => s.id === id);
|
||||
|
||||
function showSection(id) {
|
||||
sections.forEach((s) => { s.hidden = s.id !== id; });
|
||||
navLinks.forEach((a) => {
|
||||
a.setAttribute("aria-current", String(a.getAttribute("href") === "#" + id));
|
||||
});
|
||||
try {
|
||||
history.replaceState(null, "", "/admin/settings#" + id);
|
||||
sessionStorage.setItem(KEY, id);
|
||||
} catch (error) {}
|
||||
window.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
navLinks.forEach((link) => {
|
||||
link.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
showSection(link.getAttribute("href").slice(1));
|
||||
});
|
||||
});
|
||||
window.addEventListener("hashchange", () => {
|
||||
const target = byId(window.location.hash.slice(1));
|
||||
if (target) showSection(target.id);
|
||||
});
|
||||
|
||||
let initial = byId(window.location.hash.slice(1));
|
||||
if (!initial) {
|
||||
let remembered = "";
|
||||
try { remembered = sessionStorage.getItem(KEY) || ""; } catch (error) {}
|
||||
initial = byId(remembered) || sections[0];
|
||||
}
|
||||
showSection(initial.id);
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,145 @@
|
||||
{{define "title"}}{{ tr "Set up Volumen" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "login_content"}}
|
||||
<div class="login__card setup">
|
||||
<h1 class="login__title" data-i18n="Welcome to Volumen">{{ tr "Welcome to Volumen" }}</h1>
|
||||
<p class="login__pitch" data-i18n="Set up the administrator account to open this installation.">{{ tr "Set up the administrator account to open this installation." }}</p>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="status status--error" role="alert">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
|
||||
<div>{{ .Error }}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/admin/setup" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRFToken }}">
|
||||
<input type="hidden" name="language" id="setup-language" value="{{ .Lang }}">
|
||||
|
||||
<fieldset class="setup__group">
|
||||
<legend data-i18n="Account">{{ tr "Account" }}</legend>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label class="field__label" for="username" data-i18n="Username">{{ tr "Username" }}</label>
|
||||
<input id="username" class="control mono" type="text" name="username" value="admin"
|
||||
autocomplete="username" required autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="name" data-i18n="Display name">{{ tr "Display name" }}</label>
|
||||
<input id="name" class="control" type="text" name="name" autocomplete="name"
|
||||
placeholder="{{ tr "Your real name" }}" data-i18n-placeholder="Your real name">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="password" data-i18n="Password">{{ tr "Password" }}</label>
|
||||
<div class="pw">
|
||||
<input id="password" class="control" type="password" name="password"
|
||||
autocomplete="new-password" required data-pw-meter
|
||||
data-min-length="{{ .Config.Admin.MinPasswordLength }}"
|
||||
aria-describedby="pw-strength-text">
|
||||
<button type="button" class="pw__toggle" id="pw-toggle" aria-label="{{ tr "Show password" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#eye"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pw-level" id="pw-level" data-level="0">
|
||||
<div class="pw-level__bar" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
|
||||
<span class="pw-level__label" id="pw-strength-text" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<p class="field__hint" data-i18n="password.hint">{{ trn .Config.Admin.MinPasswordLength "password.min" }}</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="setup__group">
|
||||
<legend data-i18n="Language">{{ tr "Language" }}</legend>
|
||||
<div class="pill-group">
|
||||
<a class="chip chip--switch" lang="en" href="/admin/setup?lang=en" data-lang-chip="en"
|
||||
{{if eq .Lang "en"}}aria-current="true"{{end}}>English</a>
|
||||
<a class="chip chip--switch" lang="cs" href="/admin/setup?lang=cs" data-lang-chip="cs"
|
||||
{{if eq .Lang "cs"}}aria-current="true"{{end}}>Čeština</a>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="setup__group">
|
||||
<legend data-i18n="Colour scheme">{{ tr "Colour scheme" }}</legend>
|
||||
<div class="pill-group pill-group--themes">
|
||||
<label class="chip chip--radio chip--theme">
|
||||
<input type="radio" name="theme" value="viridis" {{if eq .Theme "viridis"}}checked{{end}}>
|
||||
<span class="chip__swatch chip__swatch--viridis" aria-hidden="true"></span><span>Viridis</span>
|
||||
</label>
|
||||
<label class="chip chip--radio chip--theme">
|
||||
<input type="radio" name="theme" value="plasma" {{if eq .Theme "plasma"}}checked{{end}}>
|
||||
<span class="chip__swatch chip__swatch--plasma" aria-hidden="true"></span><span>Plasma</span>
|
||||
</label>
|
||||
<label class="chip chip--radio chip--theme">
|
||||
<input type="radio" name="theme" value="magma" {{if eq .Theme "magma"}}checked{{end}}>
|
||||
<span class="chip__swatch chip__swatch--magma" aria-hidden="true"></span><span>Magma</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field__hint" data-i18n="The page takes the colours as you choose.">{{ tr "The page takes the colours as you choose." }}</p>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" class="btn btn--primary login__submit">
|
||||
<span data-i18n="Create account">{{ tr "Create account" }}</span>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#arrow-right"></use></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
(() => {
|
||||
const btn = document.getElementById("pw-toggle");
|
||||
const input = document.getElementById("password");
|
||||
btn.addEventListener("click", () => {
|
||||
const show = input.type === "password";
|
||||
input.type = show ? "text" : "password";
|
||||
btn.setAttribute("aria-label", window.__(show ? "Hide password" : "Show password"));
|
||||
});
|
||||
|
||||
/* The wizard previews its own choices: the palette rides the same
|
||||
html attribute the sheets read, and the language swaps the page
|
||||
text from the bundle the server carried. The chips are real
|
||||
links to the same page with ?lang=, so both choices survive a
|
||||
browser without JavaScript: the link reloads, the theme radios
|
||||
submit. */
|
||||
const BUNDLE = {{ .SetupI18n }};
|
||||
const apply = (lang) => {
|
||||
document.documentElement.lang = lang;
|
||||
document.querySelectorAll("[data-i18n]").forEach((el) => {
|
||||
const pair = BUNDLE.ui[el.dataset.i18n];
|
||||
if (pair && pair[lang]) el.textContent = pair[lang];
|
||||
});
|
||||
document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
|
||||
const pair = BUNDLE.ui[el.dataset.i18nPlaceholder];
|
||||
if (pair && pair[lang]) el.placeholder = pair[lang];
|
||||
});
|
||||
window.__volumen.i18n = BUNDLE.js[lang];
|
||||
btn.setAttribute("aria-label",
|
||||
window.__(input.type === "password" ? "Show password" : "Hide password"));
|
||||
/* The meter's label comes from the shared catalogue: re-score
|
||||
the field so the word follows the language swap. */
|
||||
input.dispatchEvent(new Event("input"));
|
||||
document.getElementById("setup-language").value = lang;
|
||||
document.querySelectorAll("[data-lang-chip]").forEach((chip) => {
|
||||
if (chip.dataset.langChip === lang) chip.setAttribute("aria-current", "true");
|
||||
else chip.removeAttribute("aria-current");
|
||||
});
|
||||
};
|
||||
document.querySelectorAll("[data-lang-chip]").forEach((chip) => {
|
||||
chip.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
apply(chip.dataset.langChip);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('input[name="theme"]').forEach((radio) => {
|
||||
radio.addEventListener("change", () => {
|
||||
document.documentElement.dataset.palette = radio.value;
|
||||
});
|
||||
});
|
||||
|
||||
/* The strength meter itself is the shared one the layout wires
|
||||
for every input[data-pw-meter]; only the label refresh on a
|
||||
language switch is local to this page. */
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,35 @@
|
||||
{{define "title"}}{{ tr "Two-factor code" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "login_content"}}
|
||||
<div class="login__card">
|
||||
<h1 class="login__title">{{ tr "Two-factor code" }}</h1>
|
||||
<p class="login__pitch">{{ tr "The password is accepted; answer the second question." }}</p>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="status status--error" role="alert"
|
||||
{{if .RetryAfter}}id="login-lockout"{{end}}>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
|
||||
<div>
|
||||
<span class="lockout-msg">{{ .Error }}</span>
|
||||
{{if .RetryAfter}}<span class="lockout-count" id="lockout-countdown" data-seconds="{{ .RetryAfter }}"></span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/admin/twofactor" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="code">{{ tr "Verification code" }}</label>
|
||||
<input id="code" class="control mono" type="text" name="code"
|
||||
inputmode="numeric" autocomplete="one-time-code"
|
||||
placeholder="{{ tr "six digits, or a recovery code" }}" required autofocus>
|
||||
<p class="field__hint">{{ tr "Six digits from your application, or one of your recovery codes." }}</p>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary login__submit">
|
||||
{{ tr "Continue" }}
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#arrow-right"></use></svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="login__alt"><a href="/admin/login">{{ tr "Back to sign in" }}</a></p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,34 @@
|
||||
{{define "title"}}{{ tr "Updated to" }} {{ .Target }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="empty narrow">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#refresh"></use></svg>
|
||||
<h1>{{ tr "Updated to" }} {{ .Target }}</h1>
|
||||
<p class="empty__note" id="update-status" role="status">{{ tr "The server is restarting. This page will reload automatically." }}</p>
|
||||
<div class="spinner" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
/* Wait for the engine to come back by polling its own health endpoint,
|
||||
never by a guessed delay. */
|
||||
(() => {
|
||||
const status = document.getElementById("update-status");
|
||||
let attempts = 0;
|
||||
const timer = setInterval(async () => {
|
||||
attempts += 1;
|
||||
try {
|
||||
const response = await fetch("/healthz", { cache: "no-store" });
|
||||
if (response.ok) {
|
||||
clearInterval(timer);
|
||||
status.textContent = window.__("update.reload");
|
||||
setTimeout(() => { window.location.href = "/admin/"; }, 500);
|
||||
return;
|
||||
}
|
||||
} catch (error) { /* still down */ }
|
||||
if (attempts > 60) {
|
||||
clearInterval(timer);
|
||||
status.textContent = window.__("update.gaveup");
|
||||
}
|
||||
}, 3000);
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user