/* ============================================================ TERMINAL PORTFOLIO — JS Tree navigation + Markdown page viewer ============================================================ */ 'use strict'; // ── Constants ────────────────────────────────────────────── const ITEM_H = 28; // px, must match --item-h CSS var const SCROLL_STEP = 80; // px per arrow key in page view const BOOT_LINES = [ { text: 'GAUVAIN BOICHÉ PORTFOLIO TERMINAL v1.0', cls: 'boot-ok' }, { text: '─────────────────────────────────────────', cls: 'boot-dim' }, { text: 'Initializing display subsystem... OK', cls: '' }, { text: 'Loading content tree... OK', cls: '' }, { text: 'Mounting virtual filesystem... OK', cls: '' }, { text: '─────────────────────────────────────────', cls: 'boot-dim' }, { text: 'System ready. Use ↑↓ + ENTER to navigate.', cls: 'boot-ok' }, ]; const BOOT_MS = 60; // delay between boot lines (ms) const BOOT_HOLD = 800; // ms after last line before switching to tree // ── State ────────────────────────────────────────────────── const state = { mode: 'boot', // 'boot' | 'tree' | 'page' dirStack: [], // [{node, selectedIdx, scrollOffset}, …] }; let manifest = null; // ── Utility ───────────────────────────────────────────────── function esc(str) { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function el(id) { return document.getElementById(id); } // ── Path helpers ──────────────────────────────────────────── function buildPath() { const parts = state.dirStack.map(d => d.node.name); return parts.join('/').replace(/^~\//, '~/') || '~'; } function currentDirState() { return state.dirStack[state.dirStack.length - 1]; } function getDirItems(dirNode, isRoot) { const items = isRoot ? [] : [{ name: '..', type: 'parent' }]; if (dirNode.children) { const sorted = [...dirNode.children].sort((a, b) => { if (a.type === 'dir' && b.type !== 'dir') return -1; if (a.type !== 'dir' && b.type === 'dir') return 1; return a.name.localeCompare(b.name); }); items.push(...sorted); } return items; } // ── Render dispatcher ──────────────────────────────────────── function render() { if (state.mode === 'tree') renderTree(); else if (state.mode === 'page') renderPage(); } // ── Tree view ──────────────────────────────────────────────── function renderTree() { const ds = currentDirState(); const isRoot = state.dirStack.length === 1; const items = getDirItems(ds.node, isRoot); const path = buildPath(); el('current-path').textContent = path; // How many items fit in the viewport const bodyH = el('terminal-body').clientHeight; // Rough overhead: prompt(22) + sep(10) + sep(10) + scrollinfo(22) + padding(16) const overhead = 80; const visible = Math.max(1, Math.floor((bodyH - overhead) / ITEM_H)); // Clamp scroll so selected item stays visible let scroll = ds.scrollOffset || 0; if (ds.selectedIdx < scroll) scroll = ds.selectedIdx; if (ds.selectedIdx >= scroll + visible) scroll = ds.selectedIdx - visible + 1; scroll = Math.max(0, Math.min(scroll, Math.max(0, items.length - visible))); ds.scrollOffset = scroll; const slice = items.slice(scroll, scroll + visible); const above = scroll; const below = Math.max(0, items.length - scroll - visible); // Prompt line const prompt = `gauvain@portfolio:${path}$ ls`; // Build HTML let html = `
${esc(prompt)}
`; html += `
`; html += `
`; slice.forEach((item, vi) => { const realIdx = scroll + vi; const sel = realIdx === ds.selectedIdx; const isDir = item.type === 'dir'; const isPar = item.type === 'parent'; const icon = isPar ? '../' : (isDir ? `${item.name}/` : item.name); const cls = [ 'tree-item', sel ? 'selected' : '', isDir ? 'item-dir' : '', isPar ? 'item-parent' : '', ].filter(Boolean).join(' '); html += `
${esc(icon)}
`; }); html += `
`; html += `
`; const scrollInfo = [ above > 0 ? `↑ ${above} above` : '', below > 0 ? `↓ ${below} below` : '', ].filter(Boolean).join(' '); html += `
${scrollInfo}
`; el('tree-view').innerHTML = html; el('tree-view').classList.remove('hidden'); el('page-view').classList.add('hidden'); // Update status bar el('status-left').textContent = `${items.length} item(s)`; el('status-right').innerHTML = `↑↓ SELECT  ` + `ENTER OPEN  ` + `ESC BACK`; // Event delegation on items const inner = el('tree-items-inner'); if (inner) { inner.addEventListener('click', onTreeItemClick); inner.addEventListener('dblclick', onTreeItemDblClick); } } function onTreeItemClick(e) { const itemEl = e.target.closest('.tree-item'); if (!itemEl) return; const idx = parseInt(itemEl.dataset.idx, 10); currentDirState().selectedIdx = idx; renderTree(); } function onTreeItemDblClick(e) { const itemEl = e.target.closest('.tree-item'); if (!itemEl) return; const idx = parseInt(itemEl.dataset.idx, 10); currentDirState().selectedIdx = idx; openSelected(); } // ── Open selected item ──────────────────────────────────────── function openSelected() { const ds = currentDirState(); const isRoot = state.dirStack.length === 1; const items = getDirItems(ds.node, isRoot); const item = items[ds.selectedIdx]; if (!item) return; if (item.type === 'parent') { navigateBack(); return; } if (item.type === 'dir') { state.dirStack.push({ node: item, selectedIdx: 0, scrollOffset: 0 }); renderTree(); return; } // It's a file — load it loadPage(item.path, item.name); } // ── Navigate back ───────────────────────────────────────────── function navigateBack() { if (state.mode === 'page') { state.mode = 'tree'; render(); return; } if (state.dirStack.length > 1) { state.dirStack.pop(); renderTree(); } } // ── Page view ───────────────────────────────────────────────── async function loadPage(path, name) { state.mode = 'page'; el('tree-view').classList.add('hidden'); el('page-view').classList.remove('hidden'); el('page-header').textContent = `gauvain@portfolio:${buildPath()}$ cat ${name}`; el('page-content').innerHTML = `

Loading…

`; el('current-path').textContent = buildPath() + '/' + name; updatePageStatus(); try { const res = await fetch(path); if (!res.ok) throw new Error(`HTTP ${res.status}`); const md = await res.text(); el('page-content').innerHTML = marked.parse(md); el('page-scroll-area').scrollTop = 0; } catch (err) { el('page-content').innerHTML = `

ERROR: Could not load ${esc(path)}
${esc(err.message)}

`; } updatePageStatus(); } function renderPage() { el('tree-view').classList.add('hidden'); el('page-view').classList.remove('hidden'); updatePageStatus(); } function updatePageStatus() { el('status-left').textContent = state.mode === 'page' ? 'READING' : ''; el('status-right').innerHTML = ` SCROLL UP  ` + ` SCROLL DOWN  ` + `Q QUIT`; const sa = el('page-scroll-area'); const btnUp = el('btn-up'); const btnDn = el('btn-dn'); const btnQ = el('btn-q'); if (btnUp) btnUp.onclick = () => { sa.scrollBy({ top: -SCROLL_STEP, behavior: 'smooth' }); }; if (btnDn) btnDn.onclick = () => { sa.scrollBy({ top: SCROLL_STEP, behavior: 'smooth' }); }; if (btnQ) btnQ.onclick = navigateBack; } // ── Keyboard events ─────────────────────────────────────────── function setupKeyboard() { document.addEventListener('keydown', (e) => { if (state.mode === 'boot') return; if (state.mode === 'tree') { handleTreeKey(e); } else if (state.mode === 'page') { handlePageKey(e); } }); } function handleTreeKey(e) { const ds = currentDirState(); const isRoot = state.dirStack.length === 1; const items = getDirItems(ds.node, isRoot); switch (e.key) { case 'ArrowUp': e.preventDefault(); ds.selectedIdx = Math.max(0, ds.selectedIdx - 1); renderTree(); break; case 'ArrowDown': e.preventDefault(); ds.selectedIdx = Math.min(items.length - 1, ds.selectedIdx + 1); renderTree(); break; case 'Enter': e.preventDefault(); openSelected(); break; case 'Escape': case 'ArrowLeft': e.preventDefault(); navigateBack(); break; case 'ArrowRight': e.preventDefault(); openSelected(); break; } } function handlePageKey(e) { const sa = el('page-scroll-area'); switch (e.key) { case 'ArrowUp': case 'k': e.preventDefault(); sa.scrollBy({ top: -SCROLL_STEP, behavior: 'smooth' }); break; case 'ArrowDown': case 'j': e.preventDefault(); sa.scrollBy({ top: SCROLL_STEP, behavior: 'smooth' }); break; case 'Home': e.preventDefault(); sa.scrollTo({ top: 0, behavior: 'smooth' }); break; case 'End': e.preventDefault(); sa.scrollTo({ top: sa.scrollHeight, behavior: 'smooth' }); break; case 'PageUp': e.preventDefault(); sa.scrollBy({ top: -(sa.clientHeight * 0.85), behavior: 'smooth' }); break; case 'PageDown': e.preventDefault(); sa.scrollBy({ top: (sa.clientHeight * 0.85), behavior: 'smooth' }); break; case 'q': case 'Q': case 'Escape': e.preventDefault(); navigateBack(); break; } } // ── Cursor block (click simulation) ────────────────────────── function setupCursor() { const cursor = el('cursor-block'); let hideTimer = null; document.addEventListener('click', (e) => { // Skip clicks on status-bar keys (they have their own handlers) if (e.target.classList.contains('key')) return; cursor.style.left = e.clientX + 'px'; cursor.style.top = e.clientY + 'px'; cursor.classList.add('active'); cursor.style.opacity = '1'; clearTimeout(hideTimer); hideTimer = setTimeout(() => { cursor.classList.remove('active'); cursor.style.opacity = '0'; }, 2800); }); } // ── Boot sequence ───────────────────────────────────────────── async function runBoot() { // Inject a temporary boot screen inside the terminal body const bootDiv = document.createElement('div'); bootDiv.id = 'boot-screen'; // Append to tree-view placeholder (it's empty at this point) el('tree-view').appendChild(bootDiv); el('tree-view').classList.remove('hidden'); for (let i = 0; i < BOOT_LINES.length; i++) { await sleep(BOOT_MS); const { text, cls } = BOOT_LINES[i]; const line = document.createElement('div'); line.className = 'boot-line ' + (cls || ''); line.textContent = text; bootDiv.appendChild(line); } await sleep(BOOT_HOLD); bootDiv.remove(); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // ── Init ────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', async () => { try { // Fetch manifest first (fail fast) const res = await fetch('content/manifest.json'); if (!res.ok) throw new Error(`Manifest HTTP ${res.status}`); manifest = await res.json(); } catch (err) { showFatal('Failed to load content/manifest.json — ' + err.message); return; } state.dirStack = [{ node: manifest, selectedIdx: 0, scrollOffset: 0 }]; // Boot screen then tree state.mode = 'boot'; await runBoot(); state.mode = 'tree'; renderTree(); setupKeyboard(); setupCursor(); }); function showFatal(msg) { const treeView = el('tree-view'); treeView.classList.remove('hidden'); treeView.innerHTML = `
SYSTEM ERROR
${esc(msg)}
Make sure content/manifest.json exists and the page is served over HTTP.
`; }