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
700 lines
30 KiB
HTML
700 lines
30 KiB
HTML
{{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}}
|