feat(guide): collapsible nav — Modules (per-app) / Platform / Technical sections

Restructure reader sidebar into 3 collapsible top-level sections: Modules at
top (each product app collapsible with its module list), then Platform and
Technical. Auto-expands the section/app of the active module; search expands all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 12:27:21 -04:00
parent 2f94d62769
commit 9608b363ac
+89 -21
View File
@@ -14,6 +14,15 @@
let activeModuleKey = ''; let activeModuleKey = '';
let activeTab = 0; let activeTab = 0;
let search = ''; let search = '';
// collapse state: 'modules' | 'platform' | 'technical' | 'app:<name>'
let open: Record<string, boolean> = { modules: true };
function toggle(key: string) { open = { ...open, [key]: !open[key] }; }
function expandFor(key: string) {
const app = key.split('.')[0];
if (app === 'platform') open = { ...open, platform: true };
else if (app === 'technical') open = { ...open, technical: true };
else open = { ...open, modules: true, ['app:' + app]: true };
}
marked.setOptions({ gfm: true, breaks: false }); marked.setOptions({ gfm: true, breaks: false });
const render = (md: string): string => marked.parse(md, { async: false }) as string; const render = (md: string): string => marked.parse(md, { async: false }) as string;
@@ -38,13 +47,21 @@
})) }))
.filter(a => a.modules.length > 0); .filter(a => a.modules.length > 0);
// group nav: product-app "Modules" section (all except platform/technical) + platform + technical
$: searching = !!search.trim();
$: productApps = filtered.filter(a => a.app !== 'platform' && a.app !== 'technical');
$: platformApp = filtered.find(a => a.app === 'platform') ?? null;
$: technicalApp = filtered.find(a => a.app === 'technical') ?? null;
const isOpen = (key: string) => searching || !!open[key];
async function loadNav() { async function loadNav() {
loadingNav = true; loadingNav = true;
try { try {
nav = await fetchNav(); nav = await fetchNav();
// auto-select the first module // auto-select the first product module (or first available)
const first = nav.find(a => a.modules.length)?.modules[0]; const first = (nav.find(a => a.app !== 'platform' && a.app !== 'technical' && a.modules.length)
if (first) selectModule(first.module); ?? nav.find(a => a.modules.length))?.modules[0];
if (first) { expandFor(first.module); selectModule(first.module); }
} catch (e: any) { } catch (e: any) {
addToast('error', 'Failed to load guides', e?.response?.data?.message ?? e?.message ?? ''); addToast('error', 'Failed to load guides', e?.response?.data?.message ?? e?.message ?? '');
} finally { } finally {
@@ -54,6 +71,7 @@
async function selectModule(key: string) { async function selectModule(key: string) {
if (key === activeModuleKey && module) return; if (key === activeModuleKey && module) return;
expandFor(key);
activeModuleKey = key; activeModuleKey = key;
loadingModule = true; loadingModule = true;
module = null; module = null;
@@ -88,21 +106,59 @@
{:else if filtered.length === 0} {:else if filtered.length === 0}
<div class="side-msg">No guides found.</div> <div class="side-msg">No guides found.</div>
{:else} {:else}
{#each filtered as app (app.app)} <!-- MODULES (product apps) -->
<div class="nav-group"> {#if productApps.length}
<div class="nav-group-label">{appLabel(app.app)}</div> <div class="nav-sec">
{#each app.modules as m (m.module)} <button class="sec-head" on:click={() => toggle('modules')}>
<button <span class="chev">{isOpen('modules') ? '▾' : '▸'}</span> Modules
class="nav-item" </button>
class:active={m.module === activeModuleKey} {#if isOpen('modules')}
on:click={() => selectModule(m.module)} {#each productApps as app (app.app)}
title={m.module} <div class="nav-app">
> <button class="app-head" on:click={() => toggle('app:' + app.app)}>
{m.title} <span class="chev">{isOpen('app:' + app.app) ? '▾' : '▸'}</span> {appLabel(app.app)}
</button> </button>
{/each} {#if isOpen('app:' + app.app)}
{#each app.modules as m (m.module)}
<button class="nav-item nested" class:active={m.module === activeModuleKey}
on:click={() => selectModule(m.module)} title={m.module}>{m.title}</button>
{/each}
{/if}
</div>
{/each}
{/if}
</div> </div>
{/each} {/if}
<!-- PLATFORM -->
{#if platformApp}
<div class="nav-sec">
<button class="sec-head" on:click={() => toggle('platform')}>
<span class="chev">{isOpen('platform') ? '▾' : '▸'}</span> Platform
</button>
{#if isOpen('platform')}
{#each platformApp.modules as m (m.module)}
<button class="nav-item" class:active={m.module === activeModuleKey}
on:click={() => selectModule(m.module)} title={m.module}>{m.title}</button>
{/each}
{/if}
</div>
{/if}
<!-- TECHNICAL -->
{#if technicalApp}
<div class="nav-sec">
<button class="sec-head" on:click={() => toggle('technical')}>
<span class="chev">{isOpen('technical') ? '▾' : '▸'}</span> Technical
</button>
{#if isOpen('technical')}
{#each technicalApp.modules as m (m.module)}
<button class="nav-item" class:active={m.module === activeModuleKey}
on:click={() => selectModule(m.module)} title={m.module}>{m.title}</button>
{/each}
{/if}
</div>
{/if}
{/if} {/if}
</nav> </nav>
@@ -177,17 +233,29 @@
.side-nav { flex: 1; overflow-y: auto; padding: 0.25rem 0 1rem; } .side-nav { flex: 1; overflow-y: auto; padding: 0.25rem 0 1rem; }
.side-msg { color: rgba(255,255,255,0.7); font-size: 0.85rem; padding: 0.75rem 1.25rem; } .side-msg { color: rgba(255,255,255,0.7); font-size: 0.85rem; padding: 0.75rem 1.25rem; }
.nav-group { margin-bottom: 0.5rem; } .nav-sec { margin-bottom: 0.1rem; }
.nav-group-label { .sec-head {
color: rgba(255,255,255,0.55); font-size: 0.7rem; font-weight: 700; display: flex; align-items: center; gap: 0.4rem; width: 100%; text-align: left;
text-transform: uppercase; letter-spacing: 0.06em; padding: 0.5rem 1.25rem 0.25rem; background: none; border: none; cursor: pointer;
color: rgba(255,255,255,0.9); font-size: 0.78rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.05em; padding: 0.6rem 1.1rem;
} }
.sec-head:hover { background: rgba(255,255,255,0.06); }
.app-head {
display: flex; align-items: center; gap: 0.35rem; width: 100%; text-align: left;
background: none; border: none; cursor: pointer;
color: rgba(255,255,255,0.72); font-size: 0.82rem; font-weight: 600;
padding: 0.35rem 1.1rem 0.35rem 1.5rem;
}
.app-head:hover { background: rgba(255,255,255,0.06); color: white; }
.chev { font-size: 0.58rem; width: 9px; display: inline-block; color: rgba(255,255,255,0.55); flex-shrink: 0; }
.nav-item { .nav-item {
display: block; width: 100%; text-align: left; display: block; width: 100%; text-align: left;
background: none; border: none; border-left: 3px solid transparent; background: none; border: none; border-left: 3px solid transparent;
color: rgba(255,255,255,0.82); font-size: 0.85rem; color: rgba(255,255,255,0.82); font-size: 0.85rem;
padding: 0.4rem 1.25rem 0.4rem 1.5rem; cursor: pointer; padding: 0.4rem 1.25rem 0.4rem 1.5rem; cursor: pointer;
} }
.nav-item.nested { padding-left: 2.5rem; }
.nav-item:hover { background: rgba(255,255,255,0.08); color: white; } .nav-item:hover { background: rgba(255,255,255,0.08); color: white; }
.nav-item.active { background: rgba(255,255,255,0.14); color: white; border-left-color: white; font-weight: 600; } .nav-item.active { background: rgba(255,255,255,0.14); color: white; border-left-color: white; font-weight: 600; }