Initial stun navigation app

This commit is contained in:
zeer
2026-07-12 16:22:32 +08:00
commit 8570eb84da
12 changed files with 2086 additions and 0 deletions

138
public/admin.html Normal file
View File

@@ -0,0 +1,138 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>导航管理</title>
<link rel="stylesheet" href="/styles.css?v=6" />
</head>
<body>
<main class="shell admin-shell">
<header class="topbar hero compact-hero">
<div class="hero-copy">
<p id="adminBrand" class="eyebrow">STUN NAV</p>
<h1>导航管理</h1>
<p class="subtitle">调整首页展示内容,也可以手动维护导航入口</p>
</div>
<div class="actions">
<button class="quick-action primary-button compact-button" type="button" data-target="servicePanel" data-mode="new">新增导航</button>
<button class="quick-action ghost-button compact-button" type="button" data-target="settingsPanel">页面设置</button>
<a class="text-link" href="/">返回导航</a>
<button id="refreshButton" class="icon-button" type="button" title="刷新">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M21 12a9 9 0 0 1-15.18 6.57M3 12A9 9 0 0 1 18.18 5.43M21 5v6h-6M3 19v-6h6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</header>
<section class="admin-console">
<aside class="admin-sidebar">
<label class="field token-field">
<span>管理密钥</span>
<input id="tokenInput" type="password" autocomplete="off" placeholder="填写 WEBHOOK_TOKEN" />
</label>
<nav class="admin-tabs" aria-label="后台栏目">
<button class="tab-button active" type="button" data-target="settingsPanel">页面设置</button>
<button class="tab-button" type="button" data-target="servicePanel" data-mode="new">新增导航</button>
<button class="tab-button" type="button" data-target="listPanel">导航列表</button>
</nav>
<section id="statusBar" class="status admin-status" aria-live="polite">正在读取导航...</section>
</aside>
<section class="admin-workspace">
<form id="settingsForm" class="admin-panel settings-form admin-section active" data-panel="settingsPanel">
<div class="panel-heading">
<div>
<p class="panel-kicker">DISPLAY</p>
<h2>页面设置</h2>
</div>
</div>
<label class="field">
<span>浏览器标题</span>
<input id="browserTitleInput" name="browserTitle" autocomplete="off" placeholder="家中服务导航" />
</label>
<label class="field">
<span>顶部标识</span>
<input id="brandInput" name="brand" autocomplete="off" placeholder="STUN NAV" />
</label>
<label class="field">
<span>首页标题</span>
<input id="titleInput" name="title" autocomplete="off" placeholder="家中服务" />
</label>
<label class="field full">
<span>首页副标题</span>
<input id="subtitleInput" name="subtitle" autocomplete="off" placeholder="聚合 Lucky STUN 最新入口,一处打开所有家中应用" />
</label>
<div class="form-actions">
<button class="primary-button" type="submit">保存页面设置</button>
</div>
</form>
<form id="serviceForm" class="admin-panel service-form admin-section" data-panel="servicePanel">
<div class="panel-heading">
<div>
<p class="panel-kicker">SERVICE</p>
<h2 id="serviceFormTitle">新增导航</h2>
</div>
</div>
<input id="originalIdInput" type="hidden" />
<label class="field">
<span>ID</span>
<input id="idInput" name="id" autocomplete="off" required placeholder="alist" />
</label>
<label class="field">
<span>名称</span>
<input id="nameInput" name="name" autocomplete="off" required placeholder="AList" />
</label>
<label class="field">
<span>分组</span>
<input id="groupInput" name="group" autocomplete="off" placeholder="文件" />
</label>
<label class="field full">
<span>地址</span>
<input id="urlInput" name="url" autocomplete="off" required placeholder="http://1.2.3.4:12345" />
</label>
<label class="field full">
<span>备注</span>
<input id="noteInput" name="note" autocomplete="off" placeholder="可选" />
</label>
<div class="form-actions">
<button id="resetButton" class="ghost-button" type="button">清空</button>
<button class="primary-button" type="submit">保存导航</button>
</div>
</form>
<section class="admin-section" data-panel="listPanel">
<div class="admin-list-card">
<div class="panel-heading list-heading">
<div>
<p class="panel-kicker">ENTRIES</p>
<h2>导航列表</h2>
</div>
<button id="newFromListButton" class="primary-button compact-button" type="button">新增导航</button>
</div>
<div class="admin-list-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>分组</th>
<th>地址</th>
<th>更新</th>
<th>操作</th>
</tr>
</thead>
<tbody id="serviceRows"></tbody>
</table>
</div>
</div>
</section>
</section>
</section>
</main>
<script src="/admin.js?v=6" type="module"></script>
</body>
</html>

314
public/admin.js Normal file
View File

@@ -0,0 +1,314 @@
const tokenInput = document.querySelector("#tokenInput");
const statusBar = document.querySelector("#statusBar");
const refreshButton = document.querySelector("#refreshButton");
const form = document.querySelector("#serviceForm");
const settingsForm = document.querySelector("#settingsForm");
const rowsEl = document.querySelector("#serviceRows");
const adminBrand = document.querySelector("#adminBrand");
const browserTitleInput = document.querySelector("#browserTitleInput");
const brandInput = document.querySelector("#brandInput");
const titleInput = document.querySelector("#titleInput");
const subtitleInput = document.querySelector("#subtitleInput");
const originalIdInput = document.querySelector("#originalIdInput");
const idInput = document.querySelector("#idInput");
const nameInput = document.querySelector("#nameInput");
const groupInput = document.querySelector("#groupInput");
const urlInput = document.querySelector("#urlInput");
const noteInput = document.querySelector("#noteInput");
const resetButton = document.querySelector("#resetButton");
const serviceFormTitle = document.querySelector("#serviceFormTitle");
const newFromListButton = document.querySelector("#newFromListButton");
const tabButtons = document.querySelectorAll(".tab-button");
const quickActionButtons = document.querySelectorAll(".quick-action");
const adminSections = document.querySelectorAll(".admin-section");
let services = [];
let settings = {};
function setStatus(message, isError = false) {
statusBar.textContent = message;
statusBar.classList.toggle("error", isError);
}
function showPanel(panelId) {
for (const button of tabButtons) {
button.classList.toggle("active", button.dataset.target === panelId);
}
for (const button of quickActionButtons) {
button.classList.toggle("active", button.dataset.target === panelId);
}
for (const section of adminSections) {
section.classList.toggle("active", section.dataset.panel === panelId);
}
}
function formatTime(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(date);
}
function getToken() {
return tokenInput.value.trim();
}
function apiUrl(path) {
const url = new URL(path, window.location.origin);
const token = getToken();
if (token) url.searchParams.set("token", token);
return url;
}
function loadSavedToken() {
tokenInput.value = localStorage.getItem("stun-nav-admin-token") || "";
}
function saveToken() {
localStorage.setItem("stun-nav-admin-token", getToken());
}
function applySettings(nextSettings = {}) {
settings = nextSettings;
document.title = settings.browserTitle ? `${settings.browserTitle} - 管理` : "导航管理";
adminBrand.textContent = settings.brand || "STUN NAV";
browserTitleInput.value = settings.browserTitle || "";
brandInput.value = settings.brand || "";
titleInput.value = settings.title || "";
subtitleInput.value = settings.subtitle || "";
}
function clearForm() {
originalIdInput.value = "";
form.reset();
tokenInput.value = localStorage.getItem("stun-nav-admin-token") || tokenInput.value;
serviceFormTitle.textContent = "新增导航";
showPanel("servicePanel");
idInput.focus();
}
function fillForm(service) {
originalIdInput.value = service.id;
idInput.value = service.id;
nameInput.value = service.name || "";
groupInput.value = service.group || "";
urlInput.value = service.url || "";
noteInput.value = service.note || "";
serviceFormTitle.textContent = "编辑导航";
showPanel("servicePanel");
window.scrollTo({ top: 0, behavior: "smooth" });
}
async function requestJson(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
"content-type": "application/json",
...(options.headers || {})
}
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false) {
throw new Error(data.error || `请求失败:${response.status}`);
}
return data;
}
function renderRows() {
rowsEl.replaceChildren();
if (!services.length) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = 6;
cell.className = "empty-cell";
cell.textContent = "还没有导航项";
row.append(cell);
rowsEl.append(row);
return;
}
for (const service of services) {
const row = document.createElement("tr");
row.innerHTML = `
<td class="mono"></td>
<td></td>
<td></td>
<td class="url-cell"></td>
<td></td>
<td></td>
`;
const cells = row.querySelectorAll("td");
cells[0].textContent = service.id;
cells[1].textContent = service.name || "";
cells[2].textContent = service.group || "";
cells[3].textContent = service.url || "";
cells[4].textContent = formatTime(service.updatedAt);
const actions = document.createElement("div");
actions.className = "row-actions";
const editButton = document.createElement("button");
editButton.type = "button";
editButton.className = "small-button";
editButton.textContent = "编辑";
editButton.addEventListener("click", () => fillForm(service));
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "small-button danger-button";
deleteButton.textContent = "删除";
deleteButton.addEventListener("click", () => deleteService(service));
actions.append(editButton, deleteButton);
cells[5].append(actions);
rowsEl.append(row);
}
}
async function loadServices() {
try {
const data = await requestJson("/api/services", { method: "GET" });
services = Array.isArray(data.services) ? data.services : [];
applySettings(data.settings || {});
renderRows();
setStatus(`已读取 ${services.length} 个导航项`);
} catch (error) {
setStatus(error.message || "读取失败", true);
}
}
async function deleteService(service) {
if (!getToken()) {
setStatus("请先填写管理密钥", true);
tokenInput.focus();
return;
}
const confirmed = window.confirm(`确定删除「${service.name || service.id}」吗?`);
if (!confirmed) return;
try {
saveToken();
await requestJson(apiUrl(`/api/services/${encodeURIComponent(service.id)}`), {
method: "DELETE"
});
if (originalIdInput.value === service.id) clearForm();
await loadServices();
setStatus(`已删除 ${service.name || service.id}`);
} catch (error) {
setStatus(error.message || "删除失败", true);
}
}
async function saveService(event) {
event.preventDefault();
if (!getToken()) {
setStatus("请先填写管理密钥", true);
tokenInput.focus();
return;
}
const id = idInput.value.trim();
const originalId = originalIdInput.value.trim();
if (!id) {
setStatus("请填写 ID", true);
return;
}
const payload = {
id,
name: nameInput.value.trim(),
group: groupInput.value.trim() || "默认",
url: urlInput.value.trim(),
note: noteInput.value.trim()
};
try {
saveToken();
await requestJson(apiUrl(`/api/webhook/${encodeURIComponent(id)}`), {
method: "POST",
body: JSON.stringify(payload)
});
if (originalId && originalId !== id) {
await requestJson(apiUrl(`/api/services/${encodeURIComponent(originalId)}`), {
method: "DELETE"
});
}
originalIdInput.value = id;
await loadServices();
setStatus(`已保存 ${payload.name || id}`);
clearForm();
showPanel("listPanel");
} catch (error) {
setStatus(error.message || "保存失败", true);
}
}
async function saveSettings(event) {
event.preventDefault();
if (!getToken()) {
setStatus("请先填写管理密钥", true);
tokenInput.focus();
return;
}
const payload = {
browserTitle: browserTitleInput.value.trim(),
brand: brandInput.value.trim(),
title: titleInput.value.trim(),
subtitle: subtitleInput.value.trim()
};
try {
saveToken();
const data = await requestJson(apiUrl("/api/settings"), {
method: "PUT",
body: JSON.stringify(payload)
});
applySettings(data.settings || payload);
setStatus("页面设置已保存");
} catch (error) {
setStatus(error.message || "保存页面设置失败", true);
}
}
loadSavedToken();
loadServices();
tokenInput.addEventListener("change", saveToken);
refreshButton.addEventListener("click", loadServices);
resetButton.addEventListener("click", clearForm);
newFromListButton.addEventListener("click", clearForm);
form.addEventListener("submit", saveService);
settingsForm.addEventListener("submit", saveSettings);
for (const button of tabButtons) {
button.addEventListener("click", () => {
if (button.dataset.mode === "new") {
clearForm();
return;
}
showPanel(button.dataset.target);
});
}
for (const button of quickActionButtons) {
button.addEventListener("click", () => {
if (button.dataset.mode === "new") {
clearForm();
return;
}
showPanel(button.dataset.target);
});
}

118
public/app.js Normal file
View File

@@ -0,0 +1,118 @@
const servicesEl = document.querySelector("#services");
const statusBar = document.querySelector("#statusBar");
const searchInput = document.querySelector("#searchInput");
const refreshButton = document.querySelector("#refreshButton");
const groupTemplate = document.querySelector("#groupTemplate");
const cardTemplate = document.querySelector("#cardTemplate");
const appBrand = document.querySelector("#appBrand");
const appTitle = document.querySelector("#appTitle");
const appSubtitle = document.querySelector("#appSubtitle");
let services = [];
let settings = {};
function formatTime(value) {
if (!value) return "尚未更新";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(date);
}
function setStatus(message, isError = false) {
statusBar.textContent = message;
statusBar.classList.toggle("error", isError);
}
function applySettings(nextSettings = {}) {
settings = nextSettings;
document.title = settings.browserTitle || "家中服务导航";
appBrand.textContent = settings.brand || "STUN NAV";
appTitle.textContent = settings.title || "家中服务";
appSubtitle.textContent = settings.subtitle || "";
appSubtitle.hidden = !appSubtitle.textContent.trim();
}
function serviceMatches(service, keyword) {
if (!keyword) return true;
const text = `${service.name} ${service.group} ${service.note} ${service.url}`.toLowerCase();
return text.includes(keyword.toLowerCase());
}
function groupServices(items) {
return items.reduce((groups, service) => {
const group = service.group || "默认";
if (!groups.has(group)) groups.set(group, []);
groups.get(group).push(service);
return groups;
}, new Map());
}
async function copyUrl(url, button) {
await navigator.clipboard.writeText(url);
const oldTitle = button.title;
button.title = "已复制";
setTimeout(() => {
button.title = oldTitle;
}, 1200);
}
function render() {
const keyword = searchInput.value.trim();
const filtered = services.filter((service) => serviceMatches(service, keyword));
servicesEl.replaceChildren();
if (!filtered.length) {
setStatus(services.length ? "没有匹配的服务" : "还没有收到 Lucky 推送的地址");
return;
}
setStatus(`${filtered.length} 个服务可用,最近刷新 ${formatTime(new Date().toISOString())}`);
for (const [group, items] of groupServices(filtered)) {
const groupNode = groupTemplate.content.firstElementChild.cloneNode(true);
groupNode.querySelector("h2").textContent = group;
const grid = groupNode.querySelector(".grid");
for (const service of items) {
const card = cardTemplate.content.firstElementChild.cloneNode(true);
card.querySelector(".service-mark").dataset.initial = (service.name || service.id || "?").trim().slice(0, 1).toUpperCase();
card.querySelector("h3").textContent = service.name;
card.querySelector(".note").textContent = service.note || "";
card.querySelector(".meta").textContent = `${service.url} · ${formatTime(service.updatedAt)}`;
const openButton = card.querySelector(".open-button");
openButton.href = service.url;
const copyButton = card.querySelector(".copy-button");
copyButton.addEventListener("click", () => copyUrl(service.url, copyButton));
grid.append(card);
}
servicesEl.append(groupNode);
}
}
async function loadServices() {
try {
setStatus("正在读取最新地址...");
const response = await fetch("/api/services", { cache: "no-store" });
if (!response.ok) throw new Error(`读取失败:${response.status}`);
const data = await response.json();
services = Array.isArray(data.services) ? data.services : [];
applySettings(data.settings || {});
render();
} catch (error) {
setStatus(error.message || "读取失败", true);
}
}
searchInput.addEventListener("input", render);
refreshButton.addEventListener("click", loadServices);
loadServices();
setInterval(loadServices, 30000);

75
public/index.html Normal file
View File

@@ -0,0 +1,75 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>家中服务导航</title>
<link rel="stylesheet" href="/styles.css?v=3" />
</head>
<body>
<main class="shell">
<header class="topbar hero">
<div class="hero-copy">
<p id="appBrand" class="eyebrow">STUN NAV</p>
<h1 id="appTitle">家中服务</h1>
<p id="appSubtitle" class="subtitle">聚合 Lucky STUN 最新入口,一处打开所有家中应用</p>
</div>
<div class="actions">
<a class="text-link" href="/admin">管理</a>
<label class="search">
<span class="search-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="m21 21-4.35-4.35m2.35-5.65a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</span>
<input id="searchInput" type="search" placeholder="搜索服务" autocomplete="off" />
</label>
<button id="refreshButton" class="icon-button" type="button" title="刷新">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M21 12a9 9 0 0 1-15.18 6.57M3 12A9 9 0 0 1 18.18 5.43M21 5v6h-6M3 19v-6h6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</header>
<section id="statusBar" class="status" aria-live="polite">正在读取最新地址...</section>
<section id="services" class="services"></section>
</main>
<template id="groupTemplate">
<section class="group">
<h2></h2>
<div class="grid"></div>
</section>
</template>
<template id="cardTemplate">
<article class="service-card">
<div class="card-main">
<div class="service-mark" aria-hidden="true"></div>
<div class="service-text">
<h3></h3>
<p class="note"></p>
<p class="meta"></p>
</div>
</div>
<div class="card-actions">
<button class="icon-button copy-button" type="button" title="复制地址">
<svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true">
<rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<a class="open-button" target="_blank" rel="noreferrer">
<span>打开</span>
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path d="M7 17 17 7M9 7h8v8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
</div>
</article>
</template>
<script src="/app.js?v=3" type="module"></script>
</body>
</html>

652
public/styles.css Normal file
View File

@@ -0,0 +1,652 @@
:root {
color-scheme: light;
--bg: #f4f6f2;
--surface: #ffffff;
--surface-soft: #f9faf7;
--ink: #17211f;
--muted: #66736f;
--line: #dde4df;
--accent: #117c6f;
--accent-strong: #095f55;
--indigo: #405f9f;
--amber: #c47a2c;
--danger: #b23b4a;
--shadow-sm: 0 8px 22px rgba(23, 33, 31, 0.06);
--shadow-md: 0 20px 48px rgba(23, 33, 31, 0.1);
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background:
linear-gradient(180deg, rgba(17, 124, 111, 0.1), transparent 320px),
linear-gradient(120deg, rgba(196, 122, 44, 0.09), transparent 360px),
var(--bg);
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
a {
font: inherit;
}
input {
min-width: 0;
}
.shell {
width: min(1160px, calc(100vw - 32px));
margin: 0 auto;
padding: 30px 0 56px;
}
.admin-shell {
width: min(1220px, calc(100vw - 32px));
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.hero {
margin-bottom: 22px;
padding: 26px;
border: 1px solid rgba(221, 228, 223, 0.9);
border-radius: 8px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.86), rgba(249, 250, 247, 0.72)),
var(--surface);
box-shadow: var(--shadow-md);
}
.compact-hero {
margin-bottom: 18px;
}
.hero-copy {
min-width: 0;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent);
font-size: 12px;
font-weight: 850;
letter-spacing: 0;
}
h1 {
margin: 0;
font-size: clamp(34px, 5vw, 58px);
line-height: 1;
letter-spacing: 0;
}
h2 {
margin: 0;
font-size: 18px;
line-height: 1.25;
}
.subtitle {
max-width: 650px;
margin: 12px 0 0;
color: var(--muted);
font-size: 15px;
line-height: 1.55;
}
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
}
.search {
display: flex;
align-items: center;
width: min(340px, 38vw);
min-height: 44px;
gap: 10px;
padding: 0 13px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, 0.9);
box-shadow: var(--shadow-sm);
}
.search-icon {
display: inline-flex;
color: var(--muted);
}
.search input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: var(--ink);
}
.text-link,
.icon-button,
.ghost-button,
.small-button,
.primary-button,
.open-button {
transition: background 150ms ease, border-color 150ms ease, color 150ms ease, transform 150ms ease;
}
.text-link {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0 4px;
color: var(--accent);
font-weight: 780;
text-decoration: none;
}
.text-link:hover {
color: var(--accent-strong);
}
.icon-button {
display: inline-grid;
width: 44px;
height: 44px;
place-items: center;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
color: var(--ink);
cursor: pointer;
}
.icon-button:hover,
.ghost-button:hover,
.small-button:hover {
transform: translateY(-1px);
border-color: rgba(17, 124, 111, 0.45);
color: var(--accent);
}
.status {
display: flex;
align-items: center;
min-height: 42px;
margin-bottom: 22px;
padding: 10px 14px;
border: 1px solid rgba(221, 228, 223, 0.9);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
color: var(--muted);
font-size: 14px;
box-shadow: var(--shadow-sm);
}
.status.error {
border-color: rgba(178, 59, 74, 0.35);
color: var(--danger);
}
.services {
display: grid;
gap: 28px;
}
.group h2 {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 12px;
color: #24302d;
font-size: 16px;
}
.group h2::before {
content: "";
width: 8px;
height: 20px;
border-radius: 8px;
background: var(--accent);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 14px;
}
.service-card {
display: grid;
min-height: 176px;
align-content: space-between;
gap: 18px;
padding: 18px;
border: 1px solid rgba(221, 228, 223, 0.95);
border-radius: 8px;
background: rgba(255, 255, 255, 0.94);
box-shadow: var(--shadow-sm);
}
.service-card:hover {
border-color: rgba(17, 124, 111, 0.36);
box-shadow: var(--shadow-md);
}
.card-main {
display: flex;
min-width: 0;
gap: 13px;
}
.service-mark {
display: grid;
flex: 0 0 auto;
width: 44px;
height: 44px;
place-items: center;
border-radius: 8px;
background: var(--accent);
color: #fff;
font-size: 18px;
font-weight: 850;
}
.service-mark::before {
content: attr(data-initial);
}
.service-card:nth-child(3n + 2) .service-mark {
background: var(--amber);
}
.service-card:nth-child(3n) .service-mark {
background: var(--indigo);
}
.service-text {
min-width: 0;
}
.service-text h3 {
margin: 0 0 8px;
overflow-wrap: anywhere;
font-size: 19px;
line-height: 1.22;
}
.note,
.meta {
margin: 0;
overflow-wrap: anywhere;
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}
.note {
color: #43514d;
}
.note:empty {
display: none;
}
.card-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.open-button,
.primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
gap: 8px;
padding: 0 16px;
border: 1px solid var(--accent);
border-radius: 8px;
background: var(--accent);
color: #fff;
font-weight: 780;
text-decoration: none;
cursor: pointer;
}
.compact-button {
min-height: 38px;
padding: 0 12px;
font-size: 13px;
}
.quick-action.active {
outline: 2px solid rgba(17, 124, 111, 0.18);
outline-offset: 2px;
}
.open-button:hover,
.primary-button:hover {
transform: translateY(-1px);
border-color: var(--accent-strong);
background: var(--accent-strong);
}
.admin-console {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 18px;
}
.admin-sidebar,
.admin-list-card,
.admin-panel {
border: 1px solid rgba(221, 228, 223, 0.95);
border-radius: 8px;
background: rgba(255, 255, 255, 0.94);
box-shadow: var(--shadow-sm);
}
.admin-sidebar {
position: sticky;
top: 18px;
align-self: start;
padding: 16px;
}
.token-field {
margin-bottom: 16px;
}
.admin-tabs {
display: grid;
gap: 8px;
}
.tab-button {
display: flex;
align-items: center;
justify-content: flex-start;
min-height: 44px;
padding: 0 13px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--ink);
font-weight: 780;
cursor: pointer;
text-align: left;
}
.tab-button:hover {
background: var(--surface-soft);
}
.tab-button.active {
border-color: rgba(17, 124, 111, 0.28);
background: rgba(17, 124, 111, 0.1);
color: var(--accent-strong);
}
.admin-status {
margin: 16px 0 0;
box-shadow: none;
}
.admin-workspace {
min-width: 0;
}
.admin-section {
display: none;
}
.admin-section.active {
display: block;
}
.admin-panel.admin-section.active {
display: grid;
}
.admin-panel {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
padding: 18px;
}
.admin-section:not(.active) {
display: none;
}
.auth-panel {
padding: 14px;
}
.panel-heading {
display: flex;
grid-column: 1 / -1;
align-items: center;
justify-content: space-between;
min-height: 42px;
padding-bottom: 4px;
border-bottom: 1px solid var(--line);
}
.panel-kicker {
margin: 0 0 5px;
color: var(--accent);
font-size: 11px;
font-weight: 850;
letter-spacing: 0;
}
.settings-form,
.service-form {
align-items: end;
}
.field {
display: grid;
gap: 7px;
min-width: 0;
}
.field.full {
grid-column: 1 / -1;
}
.field span {
color: var(--muted);
font-size: 13px;
font-weight: 720;
}
.field input {
width: 100%;
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface-soft);
color: var(--ink);
outline: 0;
}
.field input:focus {
border-color: rgba(17, 124, 111, 0.6);
background: #fff;
}
.form-actions {
display: flex;
grid-column: 1 / -1;
justify-content: flex-end;
gap: 10px;
}
.ghost-button,
.small-button {
min-height: 40px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
color: var(--ink);
cursor: pointer;
}
.ghost-button {
padding: 0 16px;
}
.small-button {
padding: 0 12px;
font-size: 13px;
font-weight: 760;
}
.danger-button {
border-color: rgba(178, 59, 74, 0.32);
color: var(--danger);
}
.danger-button:hover {
border-color: rgba(178, 59, 74, 0.58);
color: var(--danger);
}
.admin-list-wrap {
overflow-x: auto;
}
.admin-list-card {
padding: 18px;
}
.list-heading {
margin-bottom: 12px;
}
.admin-table {
width: 100%;
min-width: 880px;
border-collapse: collapse;
}
.admin-table th,
.admin-table td {
padding: 14px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
font-size: 14px;
}
.admin-table th {
background: var(--surface-soft);
color: var(--muted);
font-size: 12px;
font-weight: 820;
}
.admin-table tr:last-child td {
border-bottom: 0;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.url-cell {
max-width: 380px;
overflow-wrap: anywhere;
color: var(--muted);
}
.row-actions {
display: flex;
gap: 8px;
}
.empty-cell {
color: var(--muted);
text-align: center;
}
@media (max-width: 760px) {
.shell,
.admin-shell {
width: min(100vw - 24px, 1220px);
padding-top: 18px;
}
.topbar {
align-items: stretch;
flex-direction: column;
}
.hero {
padding: 20px;
}
.actions {
justify-content: stretch;
width: 100%;
}
.search {
width: 100%;
flex: 1 1 220px;
}
.admin-panel {
grid-template-columns: 1fr;
}
.admin-console {
grid-template-columns: 1fr;
}
.admin-sidebar {
position: static;
}
.admin-tabs {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.tab-button {
justify-content: center;
padding: 0 8px;
text-align: center;
}
.form-actions {
justify-content: stretch;
}
.form-actions .ghost-button,
.form-actions .primary-button {
flex: 1;
}
.grid {
grid-template-columns: 1fr;
}
}