Files
volumen/internal/web/templates/layout.html
T

700 lines
30 KiB
HTML
Raw Normal View History

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