Add i18n support: per-locale catalogs, URL-driven locale, translated core and examples
CI / full-gate (push) Successful in 2m37s

This commit is contained in:
2026-08-03 22:37:27 +02:00
parent c30cd95ebd
commit 245d1ad5b5
93 changed files with 2480 additions and 464 deletions
+2 -2
View File
@@ -20,9 +20,9 @@
<form class="auth-card" method="<%= method %>"<% if (locals.action) { %> action="<%= locals.action %>"<% } %>>
<div class="auth-head"><% if (back) { %><a class="auth-back" href="<%= back.href %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"/></svg><%= back.label %></a><% } %><h1><%= locals.title %></h1><% if (locals.sub) { %><p class="auth-sub"><%= locals.sub %></p><% } %></div>
<% if (providers.length) { -%>
<div class="sso" aria-label="<%= sso.label || "Single sign-on options" %>">
<div class="sso" aria-label="<%= sso.label || t("auth.sso.label") %>">
<ul class="sso-list"><% providers.forEach((p) => { %><li><% if (p.href) { %><a class="sso-btn" href="<%= p.href %>"><% } else { %><button type="<%= p.name ? "submit" : "button" %>" class="sso-btn"<% if (p.name) { %> name="<%= p.name %>" value="<%= p.value %>" formnovalidate<% } %>><% } %><span class="sso-logo" aria-hidden="true"><% if (p.icon) { %><svg class="ico ico-sm"><use href="#<%= p.icon %>"/></svg><% } else { %><%= p.logo %><% } %></span><span class="sso-label"><%= p.label %></span><% if (p.href) { %></a><% } else { %></button><% } %></li><% }) %></ul>
<div class="auth-divider"><%= sso.divider || "or" %></div>
<div class="auth-divider"><%= sso.divider || t("auth.sso.divider") %></div>
</div>
<% } -%>
<div class="auth-form"><%- locals.body || "" %></div>
+4 -4
View File
@@ -4,9 +4,9 @@
`decision` field). Locals: account?, challenge, csrfField, csrfToken, scopes (string[]). Captured
by views/oauth-consent.ejs.
-%>
<% const labels = { email: "Your email address", offline_access: "Stay signed in (offline access)", openid: "Verify your identity", profile: "Your basic profile (name)" }; -%>
<% const labels = { email: t("consent.scope.email"), offline_access: t("consent.scope.offline_access"), openid: t("consent.scope.openid"), profile: t("consent.scope.profile") }; -%>
<% if (locals.account) { -%>
<p class="auth-sub">Signed in as <strong><%= account %></strong></p>
<p class="auth-sub"><%= t("consent.signedInAs") %> <strong><%= account %></strong></p>
<% } -%>
<input type="hidden" name="<%= csrfField %>" value="<%= csrfToken %>">
<input type="hidden" name="consent_challenge" value="<%= challenge %>">
@@ -17,5 +17,5 @@
<% }) -%>
</ul>
<% } -%>
<button type="submit" class="btn btn-block btn-primary" name="decision" value="allow">Allow</button>
<button type="submit" class="btn btn-block" name="decision" value="deny">Deny</button>
<button type="submit" class="btn btn-block btn-primary" name="decision" value="allow"><%= t("consent.allow") %></button>
<button type="submit" class="btn btn-block" name="decision" value="deny"><%= t("consent.deny") %></button>
+5 -5
View File
@@ -14,7 +14,7 @@
const withActions = !!locals.actions;
const columns = locals.columns || [];
const rows = locals.rows || [];
const emptyText = locals.emptyText || "Nothing here yet."; // shown when a table that has columns has no rows
const emptyText = locals.emptyText || t("table.empty"); // shown when a table that has columns has no rows
-%>
<div class="table-wrap">
<table class="table">
@@ -24,7 +24,7 @@
<thead>
<tr>
<% if (selectable) { -%>
<th class="col-check" scope="col"><input type="checkbox" aria-label="Select all rows"></th>
<th class="col-check" scope="col"><input type="checkbox" aria-label="<%= t("table.selectAll") %>"></th>
<% } -%>
<% columns.forEach((col) => { -%>
<% if (col.sortable) { -%>
@@ -34,7 +34,7 @@
<% } -%>
<% }) -%>
<% if (withActions) { -%>
<th class="col-actions" scope="col"><span class="sr-only">Actions</span></th>
<th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th>
<% } -%>
</tr>
</thead>
@@ -45,7 +45,7 @@
<% rows.forEach((row) => { -%>
<tr>
<% if (selectable) { -%>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select <%= row.name || "row" %>"></td>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="<%= t("table.select", { name: row.name || t("table.row") }) %>"></td>
<% } -%>
<% (row.cells || []).forEach((cell) => { -%>
<% if (typeof cell === "string") { -%>
@@ -64,7 +64,7 @@
<% }) -%>
<% if (withActions) { -%>
<% if ((row.actions || []).length) { -%>
<td class="col-actions"><details class="menu kebab"><summary aria-label="Row actions for <%= row.name || "row" %>"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary><div class="menu-pop"><% row.actions.forEach((a) => { -%>
<td class="col-actions"><details class="menu kebab"><summary aria-label="<%= t("table.rowActions", { name: row.name || t("table.row") }) %>"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary><div class="menu-pop"><% row.actions.forEach((a) => { -%>
<% if (a.separatorBefore) { %><div class="menu-sep"></div><% } %><% if (a.href) { %><a class="menu-item<% if (a.danger) { %> danger<% } %>" href="<%= a.href %>"><% if (a.icon) { %><svg class="ico"><use href="#<%= a.icon %>"/></svg><% } %><%= a.label %></a><% } else { %><button class="menu-item<% if (a.danger) { %> danger<% } %>" type="button"><% if (a.icon) { %><svg class="ico"><use href="#<%= a.icon %>"/></svg><% } %><%= a.label %></button><% } %><% }) -%>
</div></details></td>
<% } else { -%>
+1 -1
View File
@@ -21,7 +21,7 @@
-%>
<div class="field<% if (error) { %> has-error<% } %>">
<% if (link || optional) { -%>
<div class="field-top"><label for="<%= id %>"><%= locals.label %></label><% if (link) { %><a class="field-link" href="<%= link.href %>"><%= link.label %></a><% } else { %><span class="optional">Optional</span><% } %></div>
<div class="field-top"><label for="<%= id %>"><%= locals.label %></label><% if (link) { %><a class="field-link" href="<%= link.href %>"><%= link.label %></a><% } else { %><span class="optional"><%= t("field.optional") %></span><% } %></div>
<% } else { -%>
<label for="<%= id %>"><%= locals.label %></label>
<% } -%>
+6 -6
View File
@@ -10,11 +10,11 @@
daterange { legend?, from:{name,value?,label?}, to:{name,value?,label?} }
%><%
const action = locals.action || "";
const label = locals.label || "Filter";
const label = locals.label || t("filter.label");
const rows = locals.rows || [];
const pills = locals.pills || [];
const clearHref = locals.clearHref || "?";
const applyLabel = locals.applyLabel || "Apply filters";
const applyLabel = locals.applyLabel || t("filter.apply");
const eq = (a, b) => String(a ?? "") === String(b);
-%>
<form class="filters" method="get"<% if (action) { %> action="<%= action %>"<% } %> aria-label="<%= label %>">
@@ -22,7 +22,7 @@
<div class="filter-row">
<% row.forEach((c) => { -%>
<% if (c.type === "search") { -%>
<label class="search"><span class="sr-only"><%= c.label || "Search" %></span><svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"/></svg><input type="search" name="<%= c.name %>" placeholder="<%= c.placeholder || "" %>"<% if (c.value) { %> value="<%= c.value %>"<% } %>></label>
<label class="search"><span class="sr-only"><%= c.label || t("filter.search") %></span><svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"/></svg><input type="search" name="<%= c.name %>" placeholder="<%= c.placeholder || "" %>"<% if (c.value) { %> value="<%= c.value %>"<% } %>></label>
<% } else if (c.type === "segmented") { -%>
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || c.name %></legend><div class="segmented"><% c.options.forEach((o) => { %><label><input type="radio" name="<%= c.name %>" value="<%= o.value %>"<% if (eq(c.value, o.value)) { %> checked<% } %>><span><%= o.label %></span><% if (o.count != null) { %><span class="seg-count"><%= o.count %></span><% } %></label><% }) %></div></fieldset>
<% } else if (c.type === "select") { -%>
@@ -30,7 +30,7 @@
<% } else if (c.type === "chips") { -%>
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || c.name %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || c.name %></span><div class="chips"><% (c.options).forEach((o) => { const on = (c.value || []).map(String).includes(String(o.value)); %><label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="<%= c.name %>" value="<%= o.value %>"<% if (on) { %> checked<% } %>><%= o.label %></label><% }) %></div></fieldset>
<% } else if (c.type === "daterange") { -%>
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || "Date range" %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || "Date range" %></span><div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"/></svg><label class="sr-only" for="f-<%= c.from.name %>"><%= c.from.label || "From" %></label><input type="date" id="f-<%= c.from.name %>" name="<%= c.from.name %>"<% if (c.from.value) { %> value="<%= c.from.value %>"<% } %>><span class="to" aria-hidden="true">to</span><label class="sr-only" for="f-<%= c.to.name %>"><%= c.to.label || "To" %></label><input type="date" id="f-<%= c.to.name %>" name="<%= c.to.name %>"<% if (c.to.value) { %> value="<%= c.to.value %>"<% } %>></div></fieldset>
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || t("filter.dateRange") %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || t("filter.dateRange") %></span><div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"/></svg><label class="sr-only" for="f-<%= c.from.name %>"><%= c.from.label || t("filter.from") %></label><input type="date" id="f-<%= c.from.name %>" name="<%= c.from.name %>"<% if (c.from.value) { %> value="<%= c.from.value %>"<% } %>><span class="to" aria-hidden="true"><%= t("filter.toSeparator") %></span><label class="sr-only" for="f-<%= c.to.name %>"><%= c.to.label || t("filter.to") %></label><input type="date" id="f-<%= c.to.name %>" name="<%= c.to.name %>"<% if (c.to.value) { %> value="<%= c.to.value %>"<% } %>></div></fieldset>
<% } else if (c.type === "spacer") { -%>
<div class="spacer"></div>
<% } -%>
@@ -39,11 +39,11 @@
<% }) -%>
<div class="filter-row filter-foot">
<% if (pills.length) { -%>
<div class="active-pills" aria-label="Applied filters"><span class="filter-legend">Applied</span><% pills.forEach((p) => { %><span class="pill"><b><%= p.label %>:</b> <%= p.value %> <a class="pill-x" href="<%= p.remove %>" aria-label="Remove <%= p.label %> filter"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg></a></span><% }) %><a class="pill-clear" href="<%= clearHref %>">Clear all</a></div>
<div class="active-pills" aria-label="<%= t("filter.appliedFilters") %>"><span class="filter-legend"><%= t("filter.applied") %></span><% pills.forEach((p) => { %><span class="pill"><b><%= p.label %>:</b> <%= p.value %> <a class="pill-x" href="<%= p.remove %>" aria-label="<%= t("filter.remove", { label: p.label }) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg></a></span><% }) %><a class="pill-clear" href="<%= clearHref %>"><%= t("filter.clearAll") %></a></div>
<% } -%>
<div class="spacer"></div>
<div class="filter-actions">
<button type="reset" class="btn">Reset</button>
<button type="reset" class="btn"><%= t("filter.reset") %></button>
<button type="submit" class="btn btn-primary"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"/></svg><%= applyLabel %></button>
</div>
</div>
+1 -1
View File
@@ -13,7 +13,7 @@
<%- include("field", field) %>
<% }) -%>
<% if (flow.recoverHref) { -%>
<p class="auth-aside"><a href="<%= flow.recoverHref %>">Forgot password?</a></p>
<p class="auth-aside"><a href="<%= flow.recoverHref %>"><%= t("auth.forgotPassword") %></a></p>
<% } -%>
<% flow.buttons.forEach((b, i) => { -%>
<button type="submit" class="btn btn-block<%= i === 0 ? " btn-primary" : "" %>"<% if (b.name) { %> name="<%= b.name %>"<% } %><% if (b.value != null) { %> value="<%= b.value %>"<% } %>><%= b.label %></button>
+4 -4
View File
@@ -3,11 +3,11 @@
Rendered into the app-shell content. Locals: brand (name), signedIn (bool).
%><div class="shell-auth">
<div class="landing">
<h1 class="landing-title">Operational web apps, without the boilerplate.</h1>
<p class="landing-lead"><%= brand %> is a self-hostable foundation for admin and operational UIs — sign-in, a config-driven menu, and a server-rendered, zero-JS design system. You add the domain-specific screens by dropping in plugin folders.</p>
<h1 class="landing-title"><%= t("landing.title") %></h1>
<p class="landing-lead"><%= t("landing.lead", { brand }) %></p>
<div class="landing-actions">
<% if (signedIn) { %><a class="btn btn-primary" href="/dashboard">Go to your dashboard</a>
<% } else { %><a class="btn btn-primary" href="/login">Log in</a><a class="btn" href="/registration">Create account</a><% } %>
<% if (signedIn) { %><a class="btn btn-primary" href="<%= localeHref("/dashboard") %>"><%= t("landing.dashboard") %></a>
<% } else { %><a class="btn btn-primary" href="<%= localeHref("/login") %>"><%= t("landing.signIn") %></a><a class="btn" href="<%= localeHref("/registration") %>"><%= t("landing.register") %></a><% } %>
</div>
</div>
</div>
+14
View File
@@ -0,0 +1,14 @@
<%#
Language picker: one link per installed locale, each pointing at this same page with ?locale set,
so switching is a plain navigation — zero-JS, and the address bar always says which language the
page is in. Renders nothing for a single-language deployment.
Locals: localeSwitch (host-supplied: { current, href, label, tag }[]) · up? (open upward, default true)
-%>
<% const choices = locals.localeSwitch || []; -%>
<% if (choices.length > 1) { -%>
<%- include("menu", {
up: locals.up !== false,
trigger: { class: "btn icon-btn", icon: "i-globe", label: t("locale.label") },
items: [{ head: t("locale.label") }, ...choices.map((c) => ({ current: c.current, href: c.href, hreflang: c.tag, label: c.label }))],
}) %>
<% } -%>
+2 -2
View File
@@ -8,7 +8,7 @@
kebab? boolean bare kebab trigger (adds .kebab)
width? number|string popover min-width (number ⇒ px)
items: Item[] popover content, top→bottom
Item ∈ { head } · { sep } · { label, icon?, href? ⇒ <a>, danger? } (default: menu-item button)
Item ∈ { head } · { sep } · { label, icon?, href? ⇒ <a>, hreflang?, current?, danger? } (default: menu-item button)
· { group: { legend?, name, control?(="checkbox"|"radio"), options:{value,label,checked?}[] } }
%><%
const t = locals.trigger || {};
@@ -28,7 +28,7 @@
<% } else if (it.group) { const g = it.group; -%>
<fieldset class="menu-field"><% if (g.legend) { %><legend class="menu-head"><%= g.legend %></legend><% } %><% g.options.forEach((o) => { %><label class="menu-check"><input type="<%= g.control || "checkbox" %>" name="<%= g.name %>" value="<%= o.value %>"<%= o.checked ? " checked" : "" %>><%= o.label %></label><% }) %></fieldset>
<% } else if (it.href) { -%>
<a class="menu-item<%= it.danger ? " danger" : "" %>" href="<%= it.href %>"><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></a>
<a class="menu-item<%= it.danger ? " danger" : "" %>" href="<%= it.href %>"<% if (it.hreflang) { %> hreflang="<%= it.hreflang %>" lang="<%= it.hreflang %>"<% } %><% if (it.current) { %> aria-current="true"<% } %>><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></a>
<% } else { -%>
<button class="menu-item<%= it.danger ? " danger" : "" %>" type="button"><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></button>
<% } -%>
+1 -1
View File
@@ -13,7 +13,7 @@
<div class="nav-row">
<% if (header) { -%>
<details class="nav-disc"<%= node.open ? " open" : "" %>>
<summary class="nav-tog" aria-label="Toggle <%= node.label %>"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
<summary class="nav-tog" aria-label="<%= t("shell.toggleSection", { label: node.label }) %>"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<% } else { -%>
<span class="nav-spacer" aria-hidden="true"></span>
+9 -9
View File
@@ -9,7 +9,7 @@
prev?, next? { href? } page step; omit href ⇒ disabled
pages? { label, href?, current?, ellipsis? }[]
%><%
const label = locals.label || "Pagination";
const label = locals.label || t("pagination.label");
const summary = locals.summary;
const rows = locals.rows;
const prev = locals.prev;
@@ -19,25 +19,25 @@
-%>
<footer class="pager">
<% if (summary) { -%>
<span><%= summary.from %><%= summary.to %> of <b><%= summary.total %></b></span>
<span><%= summary.from %><%= summary.to %> <%= t("pagination.of") %> <b><%= summary.total %></b></span>
<% } -%>
<% if (rows) { -%>
<form class="pager-rows" method="get"<% if (rows.action) { %> action="<%= rows.action %>"<% } %>>
<% (rows.hidden || []).forEach((h) => { -%>
<input type="hidden" name="<%= h.name %>" value="<%= h.value %>">
<% }) -%>
<label for="pager-rows"><%= rows.label || "Rows" %></label>
<span class="select"><select id="pager-rows" name="<%= rows.name %>"><% (rows.options || []).forEach((o) => { const v = o && o.value != null ? o.value : o; const t = o && o.label != null ? o.label : o; %><option value="<%= v %>"<% if (eq(rows.value, v)) { %> selected<% } %>><%= t %></option><% }) %></select></span>
<button class="page-btn" type="submit"><%= rows.submitLabel || "Go" %></button>
<label for="pager-rows"><%= rows.label || t("pagination.rows") %></label>
<span class="select"><select id="pager-rows" name="<%= rows.name %>"><% (rows.options || []).forEach((o) => { const v = o && o.value != null ? o.value : o; const text = o && o.label != null ? o.label : o; %><option value="<%= v %>"<% if (eq(rows.value, v)) { %> selected<% } %>><%= text %></option><% }) %></select></span>
<button class="page-btn" type="submit"><%= rows.submitLabel || t("pagination.go") %></button>
</form>
<% } -%>
<div class="spacer"></div>
<nav class="page-nums" aria-label="<%= label %>">
<% if (prev) { -%>
<% if (prev.href) { -%>
<a class="page-btn" href="<%= prev.href %>" aria-label="Previous page"><svg class="ico ico-sm" style="transform:rotate(180deg)"><use href="#i-chev"/></svg></a>
<a class="page-btn" href="<%= prev.href %>" aria-label="<%= t("pagination.previous") %>"><svg class="ico ico-sm" style="transform:rotate(180deg)"><use href="#i-chev"/></svg></a>
<% } else { -%>
<button class="page-btn" type="button" disabled aria-label="Previous page"><svg class="ico ico-sm" style="transform:rotate(180deg)"><use href="#i-chev"/></svg></button>
<button class="page-btn" type="button" disabled aria-label="<%= t("pagination.previous") %>"><svg class="ico ico-sm" style="transform:rotate(180deg)"><use href="#i-chev"/></svg></button>
<% } -%>
<% } -%>
<% pages.forEach((p) => { -%>
@@ -51,9 +51,9 @@
<% }) -%>
<% if (next) { -%>
<% if (next.href) { -%>
<a class="page-btn" href="<%= next.href %>" aria-label="Next page"><svg class="ico ico-sm"><use href="#i-chev"/></svg></a>
<a class="page-btn" href="<%= next.href %>" aria-label="<%= t("pagination.next") %>"><svg class="ico ico-sm"><use href="#i-chev"/></svg></a>
<% } else { -%>
<button class="page-btn" type="button" disabled aria-label="Next page"><svg class="ico ico-sm"><use href="#i-chev"/></svg></button>
<button class="page-btn" type="button" disabled aria-label="<%= t("pagination.next") %>"><svg class="ico ico-sm"><use href="#i-chev"/></svg></button>
<% } -%>
<% } -%>
</nav>
+17 -14
View File
@@ -9,7 +9,8 @@
(the <title> tag; defaults to title or the brand), `brand` ({ name, logo?, sub? }), `theme`
(theme-switch default), `user`, `breadcrumbs`, `csrfToken` (the Sign-out form's hidden field),
`signInHref` (anonymous "Sign in" target; default /login). `menu` (default true) — set false to
drop the sidebar and render a focused single-column page.
drop the sidebar and render a focused single-column page. `t`, `locale`, `dir` and `localeSwitch`
come from the host with every render.
%><%
const brand = locals.brand || { name: "Plainpages" };
const title = locals.title || ""; // topbar heading; empty ⇒ no topbar <h1> (the body owns it)
@@ -23,7 +24,7 @@
const body = locals.body || "";
const styles = locals.styles || []; // extra per-page stylesheet hrefs (e.g. a plugin's own CSS)
%><!doctype html>
<html lang="en">
<html lang="<%= locale %>" dir="<%= dir %>">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -33,7 +34,7 @@
<% }) %><link rel="icon" href="/public/favicon.svg" />
</head>
<body>
<a class="skip-link" href="#main-content">Skip to content</a>
<a class="skip-link" href="#main-content"><%= t("shell.skipToContent") %></a>
<%- include("icons") %>
<% if (menu) { %>
<!-- nav-toggle drives the mobile overlay (pure CSS) -->
@@ -42,14 +43,14 @@
<div class="app<%= menu ? "" : " app-bare" %>">
<% if (menu) { %>
<aside class="sidebar" aria-label="Primary">
<aside class="sidebar" aria-label="<%= t("shell.sidebar") %>">
<div class="brand">
<% if (brand.logo) { %><img class="brand-logo" src="<%= brand.logo %>" alt="" /><% } else { %><span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box" /></svg></span><% } %>
<span class="brand-name"><%= brand.name %></span>
<% if (brand.sub) { %><span class="brand-sub"><%= brand.sub %></span><% } %>
</div>
<nav class="nav" aria-label="Main navigation"><%- nav %></nav>
<nav class="nav" aria-label="<%= t("shell.mainNav") %>"><%- nav %></nav>
<div class="side-footer">
<%- include("theme-switch", { value: locals.theme }) %>
@@ -66,12 +67,12 @@
</span>
</summary>
<div class="menu-pop left up" style="min-width:220px">
<div class="menu-head">Signed in as <%= user.name %></div>
<button class="menu-item" type="button"><svg class="ico"><use href="#i-user" /></svg>Profile</button>
<div class="menu-head"><%= t("shell.signedInAs", { name: user.name }) %></div>
<button class="menu-item" type="button"><svg class="ico"><use href="#i-user" /></svg><%= t("shell.profile") %></button>
<%# Sign out is a state change → a POST form (not a GET link), CSRF-guarded by app.ts %>
<form class="menu-item-form" method="post" action="/logout">
<input type="hidden" name="_csrf" value="<%= locals.csrfToken || '' %>" />
<button class="menu-item danger" type="submit"><svg class="ico"><use href="#i-logout" /></svg>Sign out</button>
<button class="menu-item danger" type="submit"><svg class="ico"><use href="#i-logout" /></svg><%= t("shell.signOut") %></button>
</form>
</div>
</details>
@@ -79,28 +80,30 @@
<%# anonymous (a public page in the shell): no session to end — offer a way in instead.
signInHref carries this page as return_to (chrome.signInHref); falls back to bare /login.
Suppressed on the auth pages themselves (hideSignIn) — a Sign-in there only loops back. %>
<a class="btn btn-primary" href="<%= locals.signInHref || '/login' %>" style="flex:1 1 auto"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-user" /></svg>Sign in</a>
<a class="btn btn-primary" href="<%= locals.signInHref || '/login' %>" style="flex:1 1 auto"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-user" /></svg><%= t("shell.signIn") %></a>
<% } %>
<%- include("locale-switch") %>
<%- include("menu", {
up: true,
trigger: { class: "btn icon-btn", label: "Settings", html: '<svg class="ico"><use href="#i-gear"/></svg>' },
items: [{ head: "Settings" }, { label: "Preferences", icon: "i-gear" }],
trigger: { class: "btn icon-btn", label: t("shell.settings"), html: '<svg class="ico"><use href="#i-gear"/></svg>' },
items: [{ head: t("shell.settings") }, { label: t("shell.preferences"), icon: "i-gear" }],
}) %>
</div>
</div>
</aside>
<!-- scrim closes the mobile menu (label toggles the checkbox) -->
<label class="scrim" for="nav-toggle" aria-label="Close menu"></label>
<label class="scrim" for="nav-toggle" aria-label="<%= t("shell.closeMenu") %>"></label>
<% } %>
<main class="content" id="main-content">
<header class="topbar">
<% if (menu) { %><label class="btn icon-btn hamburger" for="nav-toggle" aria-label="Open menu"><svg class="ico"><use href="#i-menu" /></svg></label><% } %>
<% if (menu) { %><label class="btn icon-btn hamburger" for="nav-toggle" aria-label="<%= t("shell.openMenu") %>"><svg class="ico"><use href="#i-menu" /></svg></label><% } %>
<% if (title) { %><h1 class="page-title"><%= title %></h1><% } %>
<% if (breadcrumbs.length) { %>
<nav class="crumbs" aria-label="Breadcrumb">
<nav class="crumbs" aria-label="<%= t("shell.breadcrumb") %>">
<% breadcrumbs.forEach((c, i) => { %><% if (i) { %><span class="sep">/</span><% } %><% if (c.href) { %><a href="<%= c.href %>"><%= c.label %></a><% } else { %><span><%= c.label %></span><% } %><% }) %>
</nav>
<% } %>
+4 -4
View File
@@ -5,10 +5,10 @@
rendered checked) · label?.
%><%
const value = locals.value || "auto";
const label = locals.label || "Color theme";
const label = locals.label || t("theme.label");
-%>
<div class="theme-switch" role="radiogroup" aria-label="<%= label %>">
<label><input type="radio" name="theme" id="theme-light"<%= value === "light" ? " checked" : "" %>><span>Light</span></label>
<label><input type="radio" name="theme" id="theme-auto"<%= value === "auto" ? " checked" : "" %>><span>Auto</span></label>
<label><input type="radio" name="theme" id="theme-dark"<%= value === "dark" ? " checked" : "" %>><span>Dark</span></label>
<label><input type="radio" name="theme" id="theme-light"<%= value === "light" ? " checked" : "" %>><span><%= t("theme.light") %></span></label>
<label><input type="radio" name="theme" id="theme-auto"<%= value === "auto" ? " checked" : "" %>><span><%= t("theme.auto") %></span></label>
<label><input type="radio" name="theme" id="theme-dark"<%= value === "dark" ? " checked" : "" %>><span><%= t("theme.dark") %></span></label>
</div>