- 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>
318 lines
14 KiB
HTML
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>
|