Files

463 lines
20 KiB
HTML
Raw Permalink Normal View History

2026-09-18 12:03:35 +02:00
{{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}}