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
463 lines
20 KiB
HTML
463 lines
20 KiB
HTML
{{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}}
|