Files
sysadmin-chronicles/sage/app.js
T
44r0n7 0265afa054 chore: bootstrap lean sysadmin-chronicles repo
Import the runnable game code, content, docs, scripts, and repo guidance while leaving local agent state, dependency installs, build output, and backup copies out of the published tree.
2026-05-02 11:49:07 -04:00

318 lines
10 KiB
JavaScript

/* Sage — knowledge base app */
let allArticles = [];
let navIndex = null;
let currentArticleId = null;
async function loadData() {
const [indexRes, ...articleRes] = await Promise.all([
fetch('/sage/api/_index.json'),
...ARTICLE_IDS.map(id => fetch(`/sage/api/${id}.json`))
]);
navIndex = await indexRes.json();
allArticles = await Promise.all(articleRes.map(r => r.json()));
}
const ARTICLE_IDS = [
'ssh-keys', 'ssh-access-controls', 'nginx-config',
'disk-logs', 'file-permissions', 'cron-jobs',
'time-sync', 'package-management'
];
const CATEGORY_LABELS = {
access: 'Access & Authentication',
web: 'Web Services',
storage: 'Storage & Logs',
sysadmin: 'System Administration',
packages: 'Package Management'
};
// ── Sidebar ───────────────────────────────────────────────────────────────────
function buildNav() {
const sidebar = document.getElementById('sidebar');
sidebar.innerHTML = '';
// Home link
const homeWrap = document.createElement('div');
homeWrap.className = 'nav-section';
const homeLink = document.createElement('a');
homeLink.className = 'nav-link';
homeLink.textContent = '⌂ Home';
homeLink.dataset.home = '1';
homeLink.onclick = (e) => { e.preventDefault(); showHome(); };
homeWrap.appendChild(homeLink);
sidebar.appendChild(homeWrap);
navIndex.categories.forEach(cat => {
const section = document.createElement('div');
section.className = 'nav-section';
const label = document.createElement('div');
label.className = 'nav-category';
label.textContent = cat.label;
section.appendChild(label);
cat.articles.forEach(id => {
const article = allArticles.find(a => a.id === id);
if (!article) return;
const link = document.createElement('a');
link.className = 'nav-link';
link.textContent = article.title;
link.dataset.articleId = id;
link.onclick = (e) => { e.preventDefault(); showArticle(id); };
section.appendChild(link);
});
sidebar.appendChild(section);
});
}
function setActiveNav(articleId) {
document.querySelectorAll('.nav-link').forEach(el => {
el.classList.toggle('active',
articleId ? el.dataset.articleId === articleId : !!el.dataset.home
);
});
}
// ── Home ──────────────────────────────────────────────────────────────────────
function showHome() {
currentArticleId = null;
clearSearch();
setActiveNav(null);
const main = document.getElementById('main');
main.classList.remove('hidden');
document.getElementById('search-results').classList.remove('visible');
main.innerHTML = '';
const home = document.createElement('div');
home.id = 'home-page';
const h1 = document.createElement('h1');
h1.textContent = 'Sage — Internal Knowledge Base';
const subtitle = document.createElement('p');
subtitle.className = 'home-subtitle';
subtitle.textContent = 'Runbooks, reference guides, and procedures for Axiom Works infrastructure.';
home.appendChild(h1);
home.appendChild(subtitle);
const grid = document.createElement('div');
grid.className = 'home-grid';
navIndex.categories.forEach(cat => {
const catLabel = document.createElement('div');
catLabel.className = 'home-category-label';
catLabel.textContent = cat.label;
home.appendChild(catLabel);
const catGrid = document.createElement('div');
catGrid.className = 'home-grid';
cat.articles.forEach(id => {
const article = allArticles.find(a => a.id === id);
if (!article) return;
const card = document.createElement('div');
card.className = 'home-card';
card.innerHTML = `
<div class="home-card-title">${esc(article.title)}</div>
<div class="home-card-summary">${esc(article.summary)}</div>
`;
card.onclick = () => showArticle(id);
catGrid.appendChild(card);
});
home.appendChild(catGrid);
});
main.appendChild(home);
main.scrollTop = 0;
}
// ── Article ───────────────────────────────────────────────────────────────────
function showArticle(id) {
const article = allArticles.find(a => a.id === id);
if (!article) return;
currentArticleId = id;
clearSearch();
setActiveNav(id);
const main = document.getElementById('main');
main.classList.remove('hidden');
document.getElementById('search-results').classList.remove('visible');
const catLabel = CATEGORY_LABELS[article.category] ?? article.category;
let html = `
<h1 class="article-title">${esc(article.title)}</h1>
<div class="article-meta">
<span class="article-category-badge">${esc(catLabel)}</span>
<span class="article-updated">${esc(article.updated)}</span>
</div>
<p class="article-summary">${esc(article.summary)}</p>
`;
article.sections.forEach(section => {
html += `<div class="section">`;
if (section.heading) {
html += `<h2>${esc(section.heading)}</h2>`;
}
if (section.body) {
html += `<div class="section-body">${section.body}</div>`;
}
if (section.code) {
html += `<pre><code>${esc(section.code)}</code></pre>`;
}
html += `</div>`;
});
if (article.tags?.length) {
html += `<div class="article-tags">${article.tags.map(t => `<span class="tag">${esc(t)}</span>`).join('')}</div>`;
}
main.innerHTML = html;
main.scrollTop = 0;
}
// ── Search ────────────────────────────────────────────────────────────────────
let searchTimer = null;
function onSearchInput(e) {
const q = e.target.value.trim();
clearTimeout(searchTimer);
if (!q) { clearSearch(); return; }
searchTimer = setTimeout(() => runSearch(q), 120);
}
function clearSearch() {
document.getElementById('search').value = '';
document.getElementById('search-results').classList.remove('visible');
const main = document.getElementById('main');
main.classList.remove('hidden');
}
function runSearch(q) {
const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
const results = [];
for (const article of allArticles) {
const haystack = [
article.title,
article.summary,
...(article.tags ?? []),
...article.sections.flatMap(s => [s.heading ?? '', textFromHtml(s.body ?? ''), s.code ?? ''])
].join(' ').toLowerCase();
const score = terms.filter(t => haystack.includes(t)).length;
if (score === 0) continue;
// Find a snippet with the first matching term
let snippet = article.summary;
const firstTerm = terms[0];
for (const section of article.sections) {
const text = textFromHtml(section.body ?? '') + ' ' + (section.code ?? '');
const idx = text.toLowerCase().indexOf(firstTerm);
if (idx !== -1) {
const start = Math.max(0, idx - 60);
const end = Math.min(text.length, idx + 120);
snippet = (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '');
break;
}
}
results.push({ article, score, snippet });
}
results.sort((a, b) => b.score - a.score);
const container = document.getElementById('search-results');
container.classList.add('visible');
document.getElementById('main').classList.add('hidden');
if (results.length === 0) {
container.innerHTML = `<div class="no-results">No articles matched "<strong>${esc(q)}</strong>".</div>`;
return;
}
container.innerHTML = results.map(({ article, snippet }) => {
const highlighted = highlightTerms(esc(snippet), terms);
return `
<div class="search-result" data-id="${esc(article.id)}">
<div class="search-result-title">${esc(article.title)}</div>
<div class="search-result-snippet">${highlighted}</div>
</div>
`;
}).join('');
container.querySelectorAll('.search-result').forEach(el => {
el.onclick = () => showArticle(el.dataset.id);
});
}
function highlightTerms(text, terms) {
let out = text;
terms.forEach(term => {
const re = new RegExp(`(${escapeRegex(term)})`, 'gi');
out = out.replace(re, '<mark>$1</mark>');
});
return out;
}
// ── Utilities ─────────────────────────────────────────────────────────────────
function esc(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function textFromHtml(html) {
const div = document.createElement('div');
div.innerHTML = html;
return div.textContent ?? '';
}
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ── Init ──────────────────────────────────────────────────────────────────────
async function init() {
const main = document.getElementById('main');
main.innerHTML = '<p style="color:var(--text-muted);padding:20px">Loading…</p>';
try {
await loadData();
} catch (err) {
main.innerHTML = `<p style="color:var(--text-muted);padding:20px">Failed to load knowledge base: ${esc(err.message)}</p>`;
return;
}
buildNav();
showHome();
document.getElementById('search').addEventListener('input', onSearchInput);
// Keyboard shortcut: / to focus search
document.addEventListener('keydown', e => {
if (e.key === '/' && document.activeElement !== document.getElementById('search')) {
e.preventDefault();
document.getElementById('search').focus();
}
if (e.key === 'Escape') {
clearSearch();
if (currentArticleId) setActiveNav(currentArticleId);
else showHome();
}
});
}
document.addEventListener('DOMContentLoaded', init);