feat: redesign public navigation UI

This commit is contained in:
2026-09-15 06:36:55 +08:00
parent 8cf91aed9a
commit 32daa27712
2 changed files with 13 additions and 307 deletions
+1
View File
@@ -0,0 +1 @@
:root{--bg:#fafafa;--surface:#fff;--surface2:#f5f5f5;--line:#e7e7e8;--text:#18181b;--muted:#71717a;--faint:#a1a1aa;--accent:#2563eb;--green:#16a34a;--red:#dc2626;--radius:8px}*{box-sizing:border-box}html{background:var(--bg);color:var(--text)}html.dark{--bg:#09090b;--surface:#111113;--surface2:#18181b;--line:#27272a;--text:#f4f4f5;--muted:#a1a1aa;--faint:#71717a}body{margin:0;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Inter","Segoe UI","PingFang SC",sans-serif;background:var(--bg);color:var(--text)}button,input{font:inherit}.app-layout{display:flex;min-height:100vh}.sidebar{width:224px;border-right:1px solid var(--line);padding:20px 12px;display:flex;flex-direction:column;flex:none;background:var(--surface)}.brand{font-size:15px;font-weight:650;color:var(--text);padding:6px 10px 24px;text-decoration:none}.section-label{font-size:10px;letter-spacing:.08em;color:var(--faint);padding:0 10px 7px;font-weight:600}.nav-item{width:100%;border:0;background:none;color:var(--muted);display:flex;justify-content:space-between;align-items:center;text-align:left;padding:7px 10px;border-radius:6px;cursor:pointer;margin:1px 0}.nav-item span{font-size:11px;color:var(--faint)}.nav-item:hover{background:var(--surface2);color:var(--text)}.nav-item.active{background:var(--surface2);color:var(--text);font-weight:550}.sidebar-footer{margin-top:auto;padding:12px 10px;border-top:1px solid var(--line)}.sidebar-footer a{color:var(--muted);text-decoration:none;font-size:12px}.main{max-width:1100px;width:100%;margin:0 auto;padding:0 40px}.topbar{height:88px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between}.eyebrow{font-size:10px;letter-spacing:.1em;color:var(--faint);font-weight:650}.topbar h1{font-size:20px;letter-spacing:-.02em;margin:3px 0 0;font-weight:650}.top-actions{display:flex;align-items:center;gap:10px}.icon-button,.admin-link{border:1px solid var(--line);background:var(--surface);color:var(--muted);border-radius:6px;padding:6px 9px;text-decoration:none;cursor:pointer}.admin-link{font-size:12px}.toolbar{display:flex;align-items:center;gap:14px;padding:20px 0}.search{height:38px;border:1px solid var(--line);background:var(--surface);border-radius:7px;display:flex;align-items:center;gap:9px;padding:0 10px;max-width:620px;width:100%;color:var(--faint)}.search input{border:0;outline:0;background:none;color:var(--text);width:100%}.search kbd{font-size:11px;border:1px solid var(--line);border-radius:4px;padding:1px 5px;color:var(--faint)}.result-count{font-size:12px;color:var(--faint)}.service-list{border-top:1px solid var(--line)}.service-row{min-height:68px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;text-decoration:none;color:inherit;padding:10px 8px;transition:background .12s}.service-row:hover{background:var(--surface2)}.service-icon{width:34px;height:34px;border:1px solid var(--line);border-radius:7px;display:grid;place-items:center;background:var(--surface);font-size:16px;flex:none}.service-main{min-width:0;flex:1;display:flex;flex-direction:column}.service-main strong{font-size:14px;font-weight:570}.service-main span{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.service-meta{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px;min-width:120px;justify-content:flex-end}.status{width:6px;height:6px;border-radius:50%;background:var(--faint)}.status.online{background:var(--green)}.status.offline{background:var(--red)}.service-arrow{width:24px;color:var(--faint);font-size:16px;text-align:right}.empty-state{padding:70px 10px;text-align:center;color:var(--faint)}.page-footer{display:flex;justify-content:space-between;padding:24px 8px;color:var(--faint);font-size:11px}@media(max-width:760px){.sidebar{width:180px}.main{padding:0 20px}.service-meta{display:none}}@media(max-width:560px){.sidebar{display:none}.main{padding:0 16px}.topbar{height:76px}.admin-link{display:none}.toolbar{padding:14px 0}.service-row{padding:10px 2px}.service-icon{width:32px;height:32px}.service-arrow{display:none}}
+12 -307
View File
@@ -1,308 +1,13 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ .site_title }}</title><link rel="stylesheet" href="/static/css/tonav.css"></head>
<head> <body class="app-shell">
<meta charset="UTF-8"> <div class="app-layout">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <aside class="sidebar"><a class="brand" href="/">{{ .site_title }}</a><div class="sidebar-section"><div class="section-label">SERVICES</div><button class="nav-item active" data-category="all">All <span id="allCount"></span></button><div id="categoryNav"></div></div><div class="sidebar-footer"><a href="/admin/dashboard">Administration</a></div></aside>
<title>{{ .site_title }} - 导航服务</title> <main class="main"><header class="topbar"><div><div class="eyebrow">SERVICES</div><h1>Service directory</h1></div><div class="top-actions"><button class="icon-button" id="themeBtn" title="Toggle theme">◐</button><a class="admin-link" href="/admin/dashboard">Admin</a></div></header>
<style> <div class="toolbar"><label class="search"><span>⌕</span><input id="searchInput" placeholder="Search services, tags, descriptions…" autocomplete="off"><kbd>/</kbd></label><div class="result-count" id="resultCount"></div></div>
:root { <section class="service-list" id="servicesGrid"><div class="empty-state">Loading…</div></section>
--main-red: #ff4d4f; <footer class="page-footer"><span>ToNav</span><span id="lastCheckTime"></span></footer></main></div>
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); <script>const categoriesData={{ .categories_json }};const servicesData={{ .services_json }};let currentTab='all',currentKeyword='';const map={};categoriesData.forEach(c=>map[c.id]=c.name);const esc=s=>{const d=document.createElement('div');d.textContent=s||'';return d.innerHTML};
--header-gradient: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); function renderNav(){const n=document.getElementById('categoryNav');document.getElementById('allCount').textContent=servicesData.length;n.innerHTML=categoriesData.map(c=>`<button class="nav-item" data-category="${esc(c.name)}">${esc(c.name)} <span>${servicesData.filter(s=>s.category_id===c.id).length}</span></button>`).join('');document.querySelectorAll('.nav-item').forEach(b=>b.onclick=()=>{currentTab=b.dataset.category;document.querySelectorAll('.nav-item').forEach(x=>x.classList.toggle('active',x.dataset.category===currentTab));render()})}
} function render(){let a=currentTab==='all'?servicesData:servicesData.filter(s=>map[s.category_id]===currentTab);if(currentKeyword)a=a.filter(s=>(s.name+' '+(s.description||'')+' '+(s.tags||'')).toLowerCase().includes(currentKeyword));document.getElementById('resultCount').textContent=`${a.length} services`;document.getElementById('servicesGrid').innerHTML=a.length?a.map(s=>`<a class="service-row" href="${esc(s.url)}" target="_blank" rel="noopener"><span class="service-icon">${esc(s.icon)||'↗'}</span><span class="service-main"><strong>${esc(s.name)}</strong><span>${esc(s.description||s.url)}</span></span><span class="service-meta"><span class="status ${s.status==='online'?'online':s.status==='offline'?'offline':''}"></span>${esc(map[s.category_id]||'Uncategorized')}</span><span class="service-arrow">→</span></a>`).join(''):'<div class="empty-state">No services found</div>'}
* { margin: 0; padding: 0; box-sizing: border-box; } document.getElementById('searchInput').oninput=e=>{currentKeyword=e.target.value.toLowerCase().trim();render()};document.addEventListener('keydown',e=>{if(e.key==='/'&&document.activeElement.tagName!=='INPUT'){e.preventDefault();document.getElementById('searchInput').focus()}});document.getElementById('themeBtn').onclick=()=>document.documentElement.classList.toggle('dark');renderNav();render();document.getElementById('lastCheckTime').textContent=new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});</script></body></html>
body {
font-family: "PingFang SC", -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--primary-gradient);
min-height: 100vh;
padding: 20px;
}
a { text-decoration: none; color: inherit; }
.container { max-width: 800px; margin: 0 auto; }
/* 头部 */
.header {
background: var(--header-gradient);
color: #fff;
padding: 25px 20px;
border-radius: 20px 20px 0 0;
text-align: center;
}
.header h1 { font-size: 24px; font-weight: 700; margin-bottom: 5px; }
.subtitle { font-size: 13px; color: #8c8c8c; margin-bottom: 10px; }
.status-bar { font-size: 12px; color: #595959; }
/* 搜索栏 */
.search-bar {
background: #262626;
padding: 15px 20px;
position: relative;
display: flex;
align-items: center;
}
.search-bar input {
width: 100%;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 10px;
padding: 10px 15px 10px 40px;
color: #fff;
font-size: 14px;
transition: all 0.3s;
-webkit-appearance: none;
}
.search-bar input:focus {
outline: none;
background: rgba(255,255,255,0.15);
border-color: var(--main-red);
}
.search-bar input::placeholder { color: #8c8c8c; }
.search-icon { position: absolute; left: 35px; color: #8c8c8c; }
/* 分类 Tabs */
.tabs {
display: flex;
background: #262626;
padding: 8px;
gap: 5px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tabs::-webkit-scrollbar { display: none; }
.tab-btn {
flex: 1;
min-width: fit-content;
padding: 12px 10px;
color: #8c8c8c;
border: none;
background: none;
cursor: pointer;
transition: all 0.3s ease;
font-size: 14px;
font-weight: 600;
border-radius: 10px;
white-space: nowrap;
}
.tab-btn:hover { color: #bfbfbf; background: rgba(255,255,255,0.05); }
.tab-btn.active {
color: #fff;
background: var(--main-red);
box-shadow: 0 4px 15px rgba(255, 77, 79, 0.4);
}
/* 服务卡片网格 */
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
padding: 20px 0;
min-height: 200px;
}
.service-card {
background: #f7f7f8;
border-radius: 14px;
padding: 18px 20px;
transition: all 0.25s ease;
cursor: pointer;
animation: fadeInUp 0.5s ease backwards;
display: flex;
align-items: center;
border: 1px solid transparent;
}
.service-card:hover {
transform: translateY(-4px);
background: #fff;
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
border-color: rgba(102,126,234,0.3);
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.card-icon { font-size: 30px; margin-right: 14px; flex-shrink: 0; }
.card-info { min-width: 0; flex: 1; }
.card-name {
font-size: 16px; font-weight: 600; color: #262626;
display: flex; align-items: center; gap: 6px;
margin: 0;
}
.card-desc {
font-size: 13px; color: #8c8c8c; margin-top: 4px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
display: inline-block; flex-shrink: 0;
}
.status-dot.online {
background: #52c41a;
box-shadow: 0 0 6px rgba(82,196,26,0.4);
}
.status-dot.offline {
background: #f5222d;
box-shadow: 0 0 6px rgba(245,34,45,0.4);
}
.status-dot.unknown {
background: #d9d9d9;
}
.card-tags { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
.card-tag {
font-size: 11px;
color: #667eea;
background: rgba(102,126,234,0.1);
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 60px;
color: #999;
}
/* 底部 */
.footer {
background: var(--header-gradient);
color: #8c8c8c;
padding: 20px;
border-radius: 0 0 20px 20px;
text-align: center;
font-size: 13px;
}
.footer a { color: var(--main-red); transition: opacity 0.3s; }
.footer a:hover { opacity: 0.8; }
@media (max-width: 480px) {
body { padding: 10px; }
.services-grid { grid-template-columns: 1fr; }
.tab-btn { font-size: 13px; padding: 10px 8px; }
.header { padding: 20px 15px; border-radius: 16px 16px 0 0; }
.footer { border-radius: 0 0 16px 16px; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🧭 {{ .site_title }}</h1>
<div class="subtitle">个人导航站</div>
<div class="status-bar"><span id="lastCheckTime">加载中...</span></div>
</div>
<div class="search-bar">
<input type="text" id="searchInput" placeholder="搜索服务或描述..." oninput="handleSearch()">
<span class="search-icon">🔍</span>
</div>
<div class="tabs" id="categoryTabs"></div>
<div class="services-grid" id="servicesGrid">
<div class="empty-state">加载中...</div>
</div>
<div class="footer">
<div>© 2026 ToNav - <a href="/admin">管理后台</a></div>
</div>
</div>
<script>
// 服务端渲染的数据
const categoriesData = {{ .categories_json }};
const servicesData = {{ .services_json }};
// 构建分类ID → 名称映射
const categoryMap = {};
categoriesData.forEach(c => { categoryMap[c.id] = c.name; });
let currentTab = 'all';
let currentKeyword = '';
document.addEventListener('DOMContentLoaded', function() {
renderTabs();
renderServices('all');
updateLastCheckTime();
});
function renderTabs() {
const container = document.getElementById('categoryTabs');
let html = '<button class="tab-btn active" data-category="all">全部</button>';
categoriesData.forEach(c => {
html += `<button class="tab-btn" data-category="${c.name}">${c.name}</button>`;
});
container.innerHTML = html;
container.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', function() {
currentTab = this.dataset.category;
container.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
renderServices(currentTab);
});
});
}
function handleSearch() {
currentKeyword = document.getElementById('searchInput').value.toLowerCase();
renderServices(currentTab);
}
function renderServices(category) {
const container = document.getElementById('servicesGrid');
let filtered = category === 'all'
? servicesData
: servicesData.filter(s => categoryMap[s.category_id] === category);
if (currentKeyword) {
filtered = filtered.filter(s =>
s.name.toLowerCase().includes(currentKeyword) ||
(s.description && s.description.toLowerCase().includes(currentKeyword)) ||
(s.tags && s.tags.toLowerCase().includes(currentKeyword))
);
}
if (filtered.length === 0) {
container.innerHTML = '<div class="empty-state">暂无服务</div>';
return;
}
container.innerHTML = filtered.map((s, i) => {
const statusClass = s.status === 'online' ? 'online' : (s.status === 'offline' ? 'offline' : 'unknown');
return `
<a href="${escapeAttr(s.url)}" target="_blank" class="service-card" style="animation-delay: ${i * 0.05}s">
<div class="card-icon">${s.icon || '🔗'}</div>
<div class="card-info">
<div class="card-name">${escapeHTML(s.name)} <span class="status-dot ${statusClass}"></span></div>
${s.description ? `<div class="card-desc">${escapeHTML(s.description)}</div>` : ''}
${s.tags ? `<div class="card-tags">${s.tags.split(',').map(t => `<span class="card-tag">${escapeHTML(t.trim())}</span>`).join('')}</div>` : ''}
</div>
</a>
`;
}).join('');
}
function escapeHTML(str) {
const d = document.createElement('div');
d.textContent = str || '';
return d.innerHTML;
}
function escapeAttr(str) {
return (str||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function updateLastCheckTime() {
const now = new Date();
document.getElementById('lastCheckTime').textContent = '最后更新: ' +
now.toLocaleDateString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
}
setInterval(() => { location.reload(); }, 30000);
document.addEventListener('visibilitychange', function() {
if (!document.hidden) location.reload();
});
</script>
</body>
</html>