docs: publish install/uninstall scripts, README, FUNDING, support footer

Add curl-one-liner install with generated password file; uninstall flags for image/data/src; optional ALLOW_DEFAULT_PASSWORD; footer mirroring GitHub/Boosty/Telegram layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-14 15:40:20 +03:00
commit ede0c66415
13 changed files with 1952 additions and 0 deletions

381
public/app.js Normal file
View File

@@ -0,0 +1,381 @@
const loginGate = document.querySelector("#login-gate");
const appRoot = document.querySelector("#app-root");
const loginForm = document.querySelector("#login-form");
const loginPassword = document.querySelector("#login-password");
const loginError = document.querySelector("#login-error");
const logoutBtn = document.querySelector("#logout");
const refreshBtn = document.querySelector("#refresh");
const rowsEl = document.querySelector("#rows");
const statusEl = document.querySelector("#status");
const peerCountEl = document.querySelector("#peer-count");
const wgShowEl = document.querySelector("#wg-show");
const pwForm = document.querySelector("#pw-form");
const pwCurrent = document.querySelector("#pw-current");
const pwNew = document.querySelector("#pw-new");
const pwNew2 = document.querySelector("#pw-new2");
const pwMsg = document.querySelector("#pw-msg");
const dtDialog = document.querySelector("#disconnect-dt-dialog");
const dtTitle = document.querySelector("#dt-dialog-title");
const dtClientEl = document.querySelector("#dt-dialog-client");
const dtInput = document.querySelector("#dt-dialog-input");
const dtCancel = document.querySelector("#dt-dialog-cancel");
const dtOk = document.querySelector("#dt-dialog-ok");
let dtMode = "disable";
/** @type {{ clientId: string, name: string } | null} */
let dtClient = null;
function isoToDatetimeLocal(iso) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function datetimeLocalToIso(localVal) {
if (!localVal || !String(localVal).trim()) {
throw new Error("Укажите дату и время");
}
const d = new Date(localVal);
if (Number.isNaN(d.getTime())) {
throw new Error("Некорректная дата");
}
return d.toISOString();
}
function openDisableDialog(c) {
dtMode = "disable";
dtClient = c;
dtTitle.textContent = "Выключить клиента";
dtClientEl.textContent = c.name;
dtOk.textContent = "Выключить";
dtInput.value = isoToDatetimeLocal(new Date().toISOString());
dtDialog.showModal();
}
function openEditDisconnectDialog(c) {
dtMode = "edit";
dtClient = c;
dtTitle.textContent = "Дата последнего отключения";
dtClientEl.textContent = c.name;
dtOk.textContent = "Сохранить";
const iso =
c.lastDisconnectedAt || (!c.activeInConf && c.disabledAt) || new Date().toISOString();
dtInput.value = isoToDatetimeLocal(iso);
dtDialog.showModal();
}
dtCancel.addEventListener("click", () => {
dtDialog.close();
dtClient = null;
});
dtOk.addEventListener("click", async () => {
if (!dtClient) return;
let iso;
try {
iso = datetimeLocalToIso(dtInput.value);
} catch (e) {
setStatus(String(e.message || e), true);
return;
}
try {
if (dtMode === "disable") {
setStatus("Выполняю…", false);
await api("/api/clients/disable", {
method: "POST",
body: JSON.stringify({ clientId: dtClient.clientId, disconnectedAt: iso }),
});
} else {
setStatus("Сохраняю дату…", false);
await api("/api/clients/disconnect-date", {
method: "POST",
body: JSON.stringify({ clientId: dtClient.clientId, disconnectedAt: iso }),
});
}
dtDialog.close();
dtClient = null;
setStatus("Готово.", false);
await loadClients();
} catch (e) {
setStatus(String(e.message || e), true);
}
});
function showLogin() {
loginGate.classList.remove("hidden");
loginGate.setAttribute("aria-hidden", "false");
appRoot.classList.add("hidden");
}
function showApp() {
loginGate.classList.add("hidden");
loginGate.setAttribute("aria-hidden", "true");
appRoot.classList.remove("hidden");
}
function setStatus(text, isErr) {
statusEl.textContent = text || "";
statusEl.classList.toggle("err", Boolean(isErr));
}
function setPwMsg(text, isErr) {
pwMsg.textContent = text || "";
pwMsg.classList.toggle("err", Boolean(isErr));
}
async function api(path, opts = {}) {
const res = await fetch(path, {
...opts,
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
...opts.headers,
},
});
const text = await res.text();
let data;
try {
data = text ? JSON.parse(text) : {};
} catch {
data = { raw: text };
}
if (!res.ok) {
const msg = data.error || data.raw || res.statusText;
throw new Error(msg);
}
return data;
}
async function checkSession() {
try {
await api("/api/session");
return true;
} catch {
return false;
}
}
loginForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
loginError.textContent = "";
try {
await api("/api/login", {
method: "POST",
body: JSON.stringify({ password: loginPassword.value }),
});
loginPassword.value = "";
showApp();
await loadClients();
} catch (e) {
loginError.textContent = String(e.message || e);
}
});
logoutBtn.addEventListener("click", async () => {
try {
await api("/api/logout", { method: "POST", body: JSON.stringify({}) });
} catch {
/* ignore */
}
showLogin();
loginPassword.focus();
});
refreshBtn.addEventListener("click", () => {
loadClients();
});
const dtRu = new Intl.DateTimeFormat("ru-RU", {
dateStyle: "short",
timeStyle: "short",
});
function formatLastDisconnect(c) {
const iso =
c.lastDisconnectedAt ||
(!c.activeInConf && c.disabledAt) ||
null;
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return dtRu.format(d);
}
pwForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
setPwMsg("", false);
if (pwNew.value !== pwNew2.value) {
setPwMsg("Новый пароль и повтор не совпадают.", true);
return;
}
try {
const data = await api("/api/change-password", {
method: "POST",
body: JSON.stringify({
currentPassword: pwCurrent.value,
newPassword: pwNew.value,
}),
});
pwCurrent.value = "";
pwNew.value = "";
pwNew2.value = "";
setPwMsg(data.message || "Готово.", false);
showLogin();
loginPassword.focus();
} catch (e) {
setPwMsg(String(e.message || e), true);
}
});
function renderRows(clients) {
rowsEl.innerHTML = "";
clients.forEach((c) => {
const tr = document.createElement("tr");
const nameTd = document.createElement("td");
const nameWrap = document.createElement("div");
nameWrap.className = "name-cell";
const strong = document.createElement("strong");
strong.textContent = c.name;
const renameWrap = document.createElement("div");
renameWrap.className = "rename-inline";
renameWrap.appendChild(
btn("Переименовать", "btn small ghost", () => void renameClient(c))
);
nameWrap.append(strong, renameWrap);
nameTd.appendChild(nameWrap);
const ipTd = document.createElement("td");
ipTd.innerHTML = `<span class="ip">${escapeHtml(c.allowedIps || "—")}</span>`;
const stTd = document.createElement("td");
const badge = document.createElement("span");
badge.className = `badge ${c.activeInConf ? "on" : "off"}`;
badge.textContent = c.activeInConf ? "В туннеле" : "Выключен";
stTd.appendChild(badge);
const offTd = document.createElement("td");
offTd.className = "date-cell";
const dateLine = document.createElement("div");
dateLine.textContent = formatLastDisconnect(c);
const dtWrap = document.createElement("div");
dtWrap.className = "rename-inline";
dtWrap.appendChild(
btn("Задать дату", "btn small ghost", () => openEditDisconnectDialog(c))
);
offTd.append(dateLine, dtWrap);
const actTd = document.createElement("td");
actTd.className = "actions";
if (c.activeInConf) {
actTd.appendChild(btn("Выключить", "btn small ghost", () => openDisableDialog(c)));
} else {
actTd.appendChild(
btn("Включить", "btn small primary", () => mutate("/api/clients/enable", c.clientId))
);
}
actTd.appendChild(btn("Удалить", "btn small warn", () => confirmDelete(c.name, c.clientId)));
tr.append(nameTd, ipTd, stTd, offTd, actTd);
rowsEl.appendChild(tr);
});
}
function btn(label, cls, onClick) {
const b = document.createElement("button");
b.type = "button";
b.className = cls;
b.textContent = label;
b.addEventListener("click", onClick);
return b;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function renameClient(c) {
const next = prompt(`Новое имя для «${c.name}»:`, c.name);
if (next === null) return;
const trimmed = next.trim().replace(/\s+/g, " ");
if (!trimmed) {
setStatus("Имя не может быть пустым", true);
return;
}
try {
setStatus("Сохраняю имя…", false);
await api("/api/clients/rename", {
method: "POST",
body: JSON.stringify({ clientId: c.clientId, name: trimmed }),
});
setStatus("Готово.", false);
await loadClients();
} catch (e) {
setStatus(String(e.message || e), true);
}
}
async function mutate(path, clientId) {
try {
setStatus("Выполняю…", false);
await api(path, { method: "POST", body: JSON.stringify({ clientId }) });
setStatus("Готово.", false);
await loadClients();
} catch (e) {
setStatus(String(e.message || e), true);
}
}
async function confirmDelete(name, clientId) {
const ok = confirm(
`Удалить клиента «${name}»? Конфиг из приложения Amnezia перестанет совпадать с сервером.`
);
if (!ok) return;
await mutate("/api/clients/delete", clientId);
}
async function loadClients() {
try {
setStatus("Загрузка…", false);
const data = await api("/api/clients");
peerCountEl.textContent = `${data.clients.length} в таблице · ${data.peerCount} peer в awg0.conf`;
wgShowEl.textContent = data.wgShow || "";
renderRows(data.clients);
setStatus("", false);
} catch (e) {
const msg = String(e.message || e);
if (msg.includes("Unauthorized")) {
showLogin();
setStatus("", false);
loginError.textContent = "Сессия истекла — войдите снова.";
return;
}
setStatus(msg, true);
rowsEl.innerHTML = "";
wgShowEl.textContent = "";
peerCountEl.textContent = "";
}
}
async function boot() {
const ok = await checkSession();
if (ok) {
showApp();
await loadClients();
} else {
showLogin();
loginPassword.focus();
}
}
boot();

120
public/index.html Normal file
View File

@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AmneziaWG — клиенты</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
</head>
<body class="page">
<div id="login-gate" class="gate hidden" aria-hidden="true">
<div class="gate-card">
<p class="eyebrow">Панель сервера</p>
<h1>Вход</h1>
<p class="sub">Введите пароль администратора панели.</p>
<form id="login-form">
<label for="login-password">Пароль</label>
<input id="login-password" type="password" autocomplete="current-password" required>
<button type="submit" class="btn primary full">Войти</button>
<p id="login-error" class="status err" role="alert"></p>
</form>
</div>
</div>
<div id="app-root" class="shell hidden">
<header class="top">
<div>
<p class="eyebrow">Панель сервера</p>
<h1>Пользователи AmneziaWG</h1>
<p class="sub">Дату отключения вы задаёте сами: при нажатии «Выключить» или кнопкой «Задать дату» в колонке «Последнее отключение». Время берётся как локальное на вашем компьютере.</p>
</div>
<div class="token-box">
<div class="session-actions">
<button type="button" id="logout" class="btn ghost full">Выйти</button>
</div>
<details class="pw-change">
<summary>Сменить пароль</summary>
<form id="pw-form">
<label for="pw-current">Текущий пароль</label>
<input id="pw-current" type="password" autocomplete="current-password" required>
<label for="pw-new">Новый пароль</label>
<input id="pw-new" type="password" autocomplete="new-password" required minlength="8">
<label for="pw-new2">Повтор нового пароля</label>
<input id="pw-new2" type="password" autocomplete="new-password" required minlength="8">
<button type="submit" class="btn primary full">Сохранить новый пароль</button>
<p id="pw-msg" class="status" role="status"></p>
</form>
</details>
</div>
</header>
<section class="toolbar">
<div class="pill"><span class="dot ok"></span><span id="proto-label">Протокол: AmneziaWG</span></div>
<button type="button" id="refresh" class="btn primary">Обновить</button>
</section>
<p id="status" class="status" role="status"></p>
<section class="panel">
<div class="panel-head">
<h2>Пользователи</h2>
<span class="muted" id="peer-count"></span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Имя</th>
<th>IP</th>
<th>Статус</th>
<th>Последнее отключение</th>
<th></th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
</section>
<details class="raw">
<summary>Вывод awg show (отладка)</summary>
<pre id="wg-show"></pre>
</details>
</div>
<footer class="support-footer">
<div class="support-line">
<strong class="support-title">Amnezia Admin WebUI</strong>
<span class="support-sep" aria-hidden="true">·</span>
<a href="https://github.com/andrey271192/amnezia-admin" target="_blank" rel="noopener noreferrer">⭐ GitHub</a>
<span class="support-sep" aria-hidden="true">·</span>
<a href="https://boosty.to/lot_andrey" target="_blank" rel="noopener noreferrer">💖 Boosty</a>
<span class="support-sep" aria-hidden="true">·</span>
<a href="https://github.com/andrey271192/amnezia-admin/blob/main/README.md#support-links" target="_blank" rel="noopener noreferrer">💳 Ozon&nbsp;СБП</a>
<span class="support-sep" aria-hidden="true">·</span>
<a href="https://t.me/lot_andrey" target="_blank" rel="noopener noreferrer">✉️ Telegram&nbsp;@lot_andrey</a>
</div>
<p class="support-blurb">
Поддержать проект — поставь звезду на GitHub или донат. Связаться с автором — Telegram.
</p>
</footer>
<dialog id="disconnect-dt-dialog" class="dt-dialog">
<div class="dt-dialog-inner">
<h3 id="dt-dialog-title">Дата отключения</h3>
<p class="muted" id="dt-dialog-client"></p>
<label for="dt-dialog-input">Дата и время</label>
<input type="datetime-local" id="dt-dialog-input" step="60">
<div class="dt-dialog-actions">
<button type="button" class="btn ghost" id="dt-dialog-cancel">Отмена</button>
<button type="button" class="btn primary" id="dt-dialog-ok">Подтвердить</button>
</div>
</div>
</dialog>
<script src="/app.js" type="module"></script>
</body>
</html>

501
public/styles.css Normal file
View File

@@ -0,0 +1,501 @@
:root {
--bg: #070b10;
--card: #111722;
--line: rgba(255, 255, 255, 0.08);
--text: #f4f7ff;
--muted: #94a3b8;
--accent: #7dd3fc;
--danger: #fb7185;
--ok: #4ade80;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
font-family: "DM Sans", system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body.page {
margin: 0;
background: radial-gradient(circle at 20% 20%, rgba(125, 211, 252, 0.08), transparent 35%),
radial-gradient(circle at 80% 0%, rgba(94, 234, 212, 0.06), transparent 40%),
var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.shell {
flex: 1 0 auto;
max-width: 960px;
margin: 0 auto;
padding: clamp(1.5rem, 4vw, 2.5rem) clamp(1rem, 3vw, 1.5rem) 3rem;
}
.top {
display: grid;
gap: 1.25rem;
grid-template-columns: 1fr;
}
@media (min-width: 880px) {
.top {
grid-template-columns: 2fr 1fr;
align-items: start;
}
}
.eyebrow {
letter-spacing: 0.18em;
text-transform: uppercase;
font-size: 0.72rem;
color: var(--accent);
margin: 0 0 0.35rem;
}
h1 {
margin: 0 0 0.5rem;
font-size: clamp(1.6rem, 4vw, 2rem);
}
.sub {
margin: 0;
color: var(--muted);
line-height: 1.55;
max-width: 62ch;
}
.token-box {
border: 1px solid var(--line);
border-radius: 14px;
padding: 1rem;
background: #0c121b;
}
.token-box label {
display: block;
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 0.35rem;
}
.token-hint {
margin: 0 0 0.65rem;
font-size: 0.82rem;
line-height: 1.45;
color: var(--muted);
}
.token-hint code.inline {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.78rem;
padding: 0.1rem 0.35rem;
border-radius: 6px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid var(--line);
}
.hidden {
display: none !important;
}
.gate {
flex: 1;
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem 1rem;
}
.gate-card {
width: min(420px, 100%);
border: 1px solid var(--line);
border-radius: 16px;
padding: 1.35rem 1.25rem 1.5rem;
background: var(--card);
box-shadow: var(--shadow);
}
.gate-card h1 {
margin: 0 0 0.35rem;
font-size: 1.35rem;
}
.gate-card .sub {
margin: 0 0 1rem;
}
.gate-card label {
display: block;
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 0.35rem;
}
.gate-card input {
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid var(--line);
background: #0a0f16;
color: var(--text);
font: inherit;
margin-bottom: 0.75rem;
}
.btn.full {
width: 100%;
margin-top: 0.25rem;
}
.session-actions {
margin-bottom: 0.75rem;
}
.pw-change {
margin-top: 0.25rem;
color: var(--muted);
font-size: 0.88rem;
}
.pw-change summary {
cursor: pointer;
color: var(--accent);
font-weight: 600;
}
.pw-change form {
margin-top: 0.75rem;
display: grid;
gap: 0.35rem;
}
.pw-change label {
font-size: 0.8rem;
color: var(--muted);
}
.pw-change input {
width: 100%;
padding: 0.55rem 0.65rem;
border-radius: 10px;
border: 1px solid var(--line);
background: #0a0f16;
color: var(--text);
font: inherit;
}
.token-box input {
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid var(--line);
background: #0a0f16;
color: var(--text);
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.85rem;
}
.toolbar {
margin-top: 1.25rem;
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.75rem;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.03);
font-size: 0.9rem;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--muted);
}
.dot.ok {
background: var(--ok);
}
.btn {
border: 1px solid transparent;
border-radius: 11px;
padding: 0.55rem 0.95rem;
font-weight: 600;
cursor: pointer;
font: inherit;
}
.btn.primary {
background: linear-gradient(135deg, #38bdf8, #22d3ee);
color: #041018;
}
.btn.ghost {
border-color: var(--line);
background: transparent;
color: var(--text);
}
.token-box .btn.ghost {
margin-top: 0.5rem;
width: 100%;
}
.rename-inline .btn.small {
width: auto;
}
.btn.warn {
border-color: rgba(251, 113, 133, 0.35);
background: rgba(251, 113, 133, 0.08);
color: #fecdd3;
}
.btn.small {
padding: 0.35rem 0.65rem;
font-size: 0.82rem;
}
.status {
min-height: 1.25rem;
color: var(--muted);
margin: 0.75rem 0 0;
}
.status.err {
color: #fecaca;
}
.panel {
margin-top: 1rem;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--card);
overflow: hidden;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
padding: 1rem 1.1rem;
border-bottom: 1px solid var(--line);
}
.panel-head h2 {
margin: 0;
font-size: 1rem;
}
.muted {
color: var(--muted);
font-size: 0.88rem;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
}
th,
td {
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: middle;
}
th {
color: var(--muted);
font-weight: 600;
font-size: 0.78rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
tr:last-child td {
border-bottom: none;
}
.name-cell strong {
font-weight: 600;
}
.name-cell .rename-inline {
margin-top: 0.35rem;
}
.date-cell {
font-size: 0.88rem;
color: var(--muted);
white-space: nowrap;
}
.ip {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.85rem;
color: #dbeafe;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.55rem;
border-radius: 999px;
font-size: 0.82rem;
border: 1px solid var(--line);
}
.badge.on {
border-color: rgba(74, 222, 128, 0.35);
color: #bbf7d0;
background: rgba(74, 222, 128, 0.08);
}
.badge.off {
border-color: rgba(148, 163, 184, 0.35);
color: #e2e8f0;
background: rgba(148, 163, 184, 0.06);
}
.actions {
display: flex;
gap: 0.35rem;
justify-content: flex-end;
flex-wrap: wrap;
}
.raw {
margin-top: 1.25rem;
color: var(--muted);
}
.raw summary {
cursor: pointer;
}
.raw pre {
white-space: pre-wrap;
word-break: break-word;
background: #0a0f16;
border: 1px solid var(--line);
padding: 0.75rem;
border-radius: 12px;
color: #cbd5f5;
font-size: 0.78rem;
}
.dt-dialog {
border: none;
border-radius: 16px;
padding: 0;
background: var(--card);
color: var(--text);
max-width: min(420px, 92vw);
box-shadow: var(--shadow);
}
.dt-dialog::backdrop {
background: rgba(0, 0, 0, 0.55);
}
.dt-dialog-inner {
padding: 1.25rem 1.35rem 1.35rem;
}
.dt-dialog-inner h3 {
margin: 0 0 0.35rem;
font-size: 1.05rem;
}
.dt-dialog-inner label {
display: block;
font-size: 0.82rem;
color: var(--muted);
margin: 0.75rem 0 0.35rem;
}
.dt-dialog-inner input[type="datetime-local"] {
width: 100%;
padding: 0.55rem 0.65rem;
border-radius: 10px;
border: 1px solid var(--line);
background: #0a0f16;
color: var(--text);
font: inherit;
}
.dt-dialog-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
flex-wrap: wrap;
}
.dt-dialog-actions .btn {
width: auto;
}
.support-footer {
flex-shrink: 0;
margin-top: auto;
padding: 1.25rem clamp(1rem, 3vw, 1.5rem) 1.5rem;
border-top: 1px solid var(--line);
background: rgba(10, 15, 22, 0.92);
}
.support-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.5rem;
font-size: 0.92rem;
color: var(--muted);
}
.support-title {
color: var(--text);
font-weight: 700;
}
.support-sep {
opacity: 0.55;
user-select: none;
}
.support-line a {
color: var(--accent);
text-decoration: none;
white-space: nowrap;
}
.support-line a:hover {
text-decoration: underline;
}
.support-blurb {
margin: 0.65rem 0 0;
font-size: 0.82rem;
line-height: 1.45;
color: var(--muted);
max-width: 62rem;
}