Files
kb-server/public/index.html
T
newbieandClaude 696e6d8c92 init: personal markdown knowledge base server
- Auto-discovers .md files from configurable content roots
- On-the-fly markdown → HTML rendering (marked + highlight.js)
- Directory tree navigation sidebar
- Full-text search across all documents
- Dark mode, responsive design, Ctrl+K quick search
- Frontmatter (YAML) support via gray-matter
- Configurable via config.json

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 15:48:33 +00:00

318 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的知识库</title>
<style>
:root {
--bg: #ffffff; --bg2: #f8f9fa; --text: #1a1a2e; --text2: #555;
--border: #e5e7eb; --brand: #2563eb; --brand-light: #eff6ff;
--code-bg: #f1f5f9; --shadow: 0 1px 3px rgba(0,0,0,.08);
--sidebar-w: 280px;
}
.dark { --bg: #0f172a; --bg2: #1e293b; --text: #e2e8f0; --text2: #94a3b8;
--border: #334155; --brand: #60a5fa; --brand-light: #1e3a5f;
--code-bg: #1e293b; --shadow: 0 1px 3px rgba(0,0,0,.3); }
* { margin:0; padding:0; box-sizing:border-box; }
body { font: 15px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC",sans-serif;
background:var(--bg); color:var(--text); display:flex; height:100vh; overflow:hidden; }
/* Sidebar */
#sidebar { width:var(--sidebar-w); min-width:var(--sidebar-w); background:var(--bg2);
border-right:1px solid var(--border); display:flex; flex-direction:column; overflow:hidden; }
#sb-header { padding:16px; border-bottom:1px solid var(--border); }
#sb-header h2 { font-size:17px; font-weight:700; color:var(--brand); white-space:nowrap; }
#sb-search { width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:6px;
background:var(--bg); color:var(--text); font-size:13px; margin-top:8px; outline:none; }
#sb-search:focus { border-color:var(--brand); box-shadow:0 0 0 2px rgba(37,99,235,.15); }
#sb-tree { flex:1; overflow-y:auto; padding:8px 0; }
.tree-node { user-select:none; }
.tree-dir { display:flex; align-items:center; padding:4px 12px; cursor:pointer;
font-size:13px; font-weight:600; color:var(--text2); gap:4px; }
.tree-dir:hover { background:var(--brand-light); color:var(--brand); }
.tree-dir .arrow { font-size:10px; transition:transform .2s; width:14px; text-align:center; }
.tree-dir.open .arrow { transform:rotate(90deg); }
.tree-children { display:none; padding-left:12px; }
.tree-children.open { display:block; }
.tree-file { display:flex; align-items:center; padding:3px 12px 3px 24px;
cursor:pointer; font-size:13px; color:var(--text); text-decoration:none;
border-left:2px solid transparent; transition:all .15s; }
.tree-file:hover { background:var(--brand-light); color:var(--brand); }
.tree-file.active { border-left-color:var(--brand); background:var(--brand-light);
color:var(--brand); font-weight:600; }
.tree-file .file-icon { margin-right:6px; opacity:.5; font-size:11px; }
/* Search overlay */
#search-overlay { display:none; position:fixed; top:0;left:0;right:0;bottom:0;
background:rgba(0,0,0,.3); z-index:100; align-items:flex-start; justify-content:center;
padding-top:60px; }
#search-overlay.show { display:flex; }
#search-box { width:600px; max-width:90vw; background:var(--bg); border-radius:10px;
box-shadow:0 8px 30px rgba(0,0,0,.2); overflow:hidden; }
#search-input { width:100%; padding:14px 18px; font-size:16px; border:none;
background:var(--bg); color:var(--text); outline:none; border-bottom:1px solid var(--border); }
#search-results { max-height:60vh; overflow-y:auto; }
.search-item { padding:10px 18px; cursor:pointer; border-bottom:1px solid var(--border); }
.search-item:hover { background:var(--brand-light); }
.search-item .s-name { font-weight:600; color:var(--brand); font-size:14px; }
.search-item .s-dir { font-size:11px; color:var(--text2); }
.search-item .s-snippet { font-size:12px; color:var(--text2); margin-top:2px;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* Main content */
#main { flex:1; display:flex; flex-direction:column; overflow:hidden; }
#topbar { display:flex; align-items:center; justify-content:space-between;
padding:10px 20px; border-bottom:1px solid var(--border); background:var(--bg);
gap:12px; }
#topbar .tabs { display:flex; gap:4px; flex:1; overflow-x:auto; }
#topbar .tab { padding:6px 14px; border-radius:6px; font-size:13px; cursor:pointer;
white-space:nowrap; color:var(--text2); border:1px solid transparent; }
#topbar .tab:hover { background:var(--bg2); }
#topbar .tab.active { background:var(--brand); color:#fff; }
#top-actions { display:flex; gap:8px; align-items:center; }
#top-actions button { background:var(--bg2); border:1px solid var(--border); border-radius:6px;
padding:6px 10px; cursor:pointer; font-size:18px; line-height:1; color:var(--text); }
#top-actions button:hover { background:var(--brand-light); }
#content { flex:1; overflow-y:auto; padding:24px 32px; max-width:900px; }
#content h1 { font-size:2rem; margin-bottom:.3em; letter-spacing:-.02em; }
#content h2 { font-size:1.4rem; margin:1.8em 0 .5em; padding-bottom:.3em;
border-bottom:1px solid var(--border); }
#content h3 { font-size:1.15rem; margin:1.2em 0 .3em; }
#content p { margin:.6em 0; }
#content a { color:var(--brand); text-decoration:none; }
#content a:hover { text-decoration:underline; }
#content code { font-family:"JetBrains Mono","Fira Code","Cascadia Code",monospace;
font-size:.88em; background:var(--code-bg); padding:1px 5px; border-radius:3px; }
#content pre { background:var(--code-bg); border-radius:8px; padding:14px 18px;
overflow-x:auto; margin:1em 0; border:1px solid var(--border); }
#content pre code { background:none; padding:0; font-size:.85em; line-height:1.6; }
#content table { width:100%; border-collapse:collapse; margin:1em 0; font-size:.93em; }
#content th { background:var(--bg2); font-weight:600; padding:8px 12px;
border:1px solid var(--border); text-align:left; }
#content td { padding:6px 12px; border:1px solid var(--border); }
#content blockquote { border-left:3px solid var(--brand); margin:.8em 0;
padding:6px 16px; background:var(--brand-light); border-radius:0 6px 6px 0; }
#content img { max-width:100%; border-radius:6px; }
#content ul, #content ol { padding-left:1.5em; margin:.5em 0; }
#content li { margin:.2em 0; }
#content input[type="checkbox"] { margin-right:.4em; }
/* Frontmatter display */
.frontmatter { background:var(--bg2); border:1px solid var(--border); border-radius:8px;
padding:12px 16px; margin-bottom:20px; font-size:13px; }
.frontmatter table { margin:0; }
.frontmatter td:first-child { font-weight:600; white-space:nowrap; padding-right:16px; }
/* Welcome */
#welcome { display:flex; flex-direction:column; align-items:center;
justify-content:center; height:100%; text-align:center; padding:40px; }
#welcome h1 { font-size:2.5rem; background:linear-gradient(135deg,#2563eb,#06b6d4);
-webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }
#welcome p { color:var(--text2); font-size:1.1rem; max-width:500px; margin:12px 0; }
#welcome .stats { display:flex; gap:24px; margin-top:24px; }
#welcome .stat { text-align:center; }
#welcome .stat .num { font-size:2rem; font-weight:700; color:var(--brand); }
#welcome .stat .label { font-size:.85rem; color:var(--text2); }
/* Mobile */
@media (max-width:768px) {
#sidebar { display:none; }
#content { padding:16px; }
}
</style>
</head>
<body>
<div id="sidebar">
<div id="sb-header">
<h2>📚 我的知识库</h2>
<input id="sb-search" type="text" placeholder="搜索..." onfocus="openSearch()" autocomplete="off">
</div>
<div id="sb-tree"></div>
</div>
<div id="main">
<div id="topbar">
<div class="tabs" id="tabs"></div>
<div id="top-actions">
<button title="搜索 (Ctrl+K)" onclick="openSearch()">🔍</button>
<button title="切换主题" onclick="toggleDark()" id="dark-btn">🌙</button>
</div>
</div>
<div id="content"><div id="welcome">
<h1>📚 我的知识库</h1>
<p>DPDK 学习笔记 · uNOS 设计 · 家庭网关产品 · 开发日志</p>
<div class="stats">
<div class="stat"><div class="num" id="stat-files">...</div><div class="label">Markdown 文档</div></div>
<div class="stat"><div class="num" id="stat-dirs">...</div><div class="label">目录</div></div>
</div>
<p style="margin-top:20px;font-size:.9em;opacity:.6">
Ctrl+K 全局搜索 · 点击左侧目录浏览 · 所有 .md 文件自动发现
</p>
</div></div>
</div>
<div id="search-overlay" onclick="if(event.target===this)closeSearch()">
<div id="search-box">
<input id="search-input" type="text" placeholder="搜索所有 Markdown 文档..." autofocus
oninput="doSearch(this.value)" onkeydown="if(event.key==='Escape')closeSearch()">
<div id="search-results"></div>
</div>
</div>
<script>
const API = '/api';
let treeData = {};
let activeFile = null;
let activeRoot = null;
let tabHistory = [];
// Init
(async function init() {
if (localStorage.getItem('kb-dark') === '1') document.body.classList.add('dark');
updateDarkBtn();
try {
const r = await fetch(`${API}/tree`);
treeData = await r.json();
const roots = Object.keys(treeData);
renderTabs(roots);
renderTree(treeData);
if (roots.length > 0) selectRoot(roots[0]);
updateStats();
} catch(e) { console.error(e); }
document.addEventListener('keydown', e => {
if ((e.ctrlKey||e.metaKey) && e.key==='k') { e.preventDefault(); openSearch(); }
});
})();
function updateStats() {
let files = 0, dirs = new Set();
for (const [root, nodes] of Object.entries(treeData)) {
walkNodes(nodes, n => {
if (n.type === 'file') { files++; dirs.add(n.dir); }
});
}
document.getElementById('stat-files').textContent = files;
document.getElementById('stat-dirs').textContent = dirs.size;
}
function walkNodes(nodes, fn) {
for (const n of nodes) {
fn(n);
if (n.children) walkNodes(n.children, fn);
}
}
function renderTabs(roots) {
const tabs = document.getElementById('tabs');
tabs.innerHTML = roots.map(r => `<span class="tab" data-root="${r}" onclick="selectRoot('${r}')">${r}</span>`).join('');
}
function selectRoot(label) {
activeRoot = label;
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.root === label));
renderTree(treeData);
loadContent(null);
document.getElementById('content').innerHTML = document.getElementById('welcome').outerHTML;
updateStats();
}
function renderTree(data) {
const tree = document.getElementById('sb-tree');
if (!activeRoot || !data[activeRoot]) { tree.innerHTML = ''; return; }
tree.innerHTML = renderNodes(data[activeRoot]);
// Re-bind events
tree.querySelectorAll('.tree-dir').forEach(el => {
el.onclick = function() {
this.classList.toggle('open');
this.nextElementSibling?.classList.toggle('open');
};
// Open first level by default
el.classList.add('open');
if (el.nextElementSibling) el.nextElementSibling.classList.add('open');
});
tree.querySelectorAll('.tree-file').forEach(el => {
el.onclick = function() { loadContent({label:activeRoot,relative:this.dataset.rel}); };
});
}
function renderNodes(nodes) {
return nodes.map(n => {
if (n.type === 'dir') {
const label = n.name.replace(/^(\d+-)/,'').replace(/-/g,' ');
return `<div class="tree-node">
<div class="tree-dir"><span class="arrow">▶</span> ${label}</div>
<div class="tree-children">${renderNodes(n.children||[])}</div>
</div>`;
} else {
const icon = '📄';
return `<div class="tree-file" data-rel="${n.relative}" title="${n.relative}">
<span class="file-icon">${icon}</span>${n.name.replace(/-/g,' ')}
</div>`;
}
}).join('');
}
async function loadContent(file) {
if (!file) {
document.getElementById('content').innerHTML = document.getElementById('welcome').outerHTML;
updateStats();
return;
}
document.querySelectorAll('.tree-file').forEach(f => f.classList.remove('active'));
document.querySelector(`.tree-file[data-rel="${file.relative}"]`)?.classList.add('active');
activeFile = file;
try {
const r = await fetch(`${API}/file?label=${encodeURIComponent(file.label)}&rel=${encodeURIComponent(file.relative)}`);
if (!r.ok) throw new Error('not found');
const data = await r.json();
let html = `<h1>${data.name.replace(/-/g,' ')}</h1>`;
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
html += '<div class="frontmatter"><table>';
for (const [k,v] of Object.entries(data.frontmatter)) {
html += `<tr><td>${k}</td><td>${v}</td></tr>`;
}
html += '</table></div>';
}
html += data.html;
document.getElementById('content').innerHTML = html;
} catch(e) {
document.getElementById('content').innerHTML = `<h1>加载失败</h1><p>${e.message}</p>`;
}
}
// Search
function openSearch() {
document.getElementById('search-overlay').classList.add('show');
document.getElementById('search-input').value = '';
document.getElementById('search-results').innerHTML = '';
setTimeout(() => document.getElementById('search-input').focus(), 100);
}
function closeSearch() { document.getElementById('search-overlay').classList.remove('show'); }
async function doSearch(q) {
const results = document.getElementById('search-results');
if (q.length < 2) { results.innerHTML = ''; return; }
try {
const r = await fetch(`${API}/search?q=${encodeURIComponent(q)}`);
const data = await r.json();
results.innerHTML = data.map(f => `
<div class="search-item" onclick="loadContent({label:'${f.label}',relative:'${f.relative}'});closeSearch();selectRoot('${f.label}')">
<div class="s-name">📄 ${f.name.replace(/-/g,' ')}</div>
<div class="s-dir">${f.label} / ${f.dir}</div>
<div class="s-snippet">${f.snippet}</div>
</div>`).join('');
} catch(e) {}
}
function toggleDark() {
document.body.classList.toggle('dark');
localStorage.setItem('kb-dark', document.body.classList.contains('dark') ? '1' : '0');
updateDarkBtn();
}
function updateDarkBtn() {
document.getElementById('dark-btn').textContent = document.body.classList.contains('dark') ? '☀️' : '🌙';
}
</script>
</body>
</html>