feat: brain sub-sidebar in AppShell — folders/tags under Brain nav item

- Brain nav item expands when on /brain page
- Shows Folders/Tags toggle tabs
- Folder links: /brain?folder=Work etc
- Tag links: /brain?tag=dev etc
- Counts shown next to each
- Brain page reads filter from URL params
- Only shows folders with items + key defaults (Home, Work, Knowledge)
- Tags limited to 12 most-used

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-01 20:35:19 -05:00
parent 5f2fe8eca6
commit fd636f01fa
2 changed files with 128 additions and 0 deletions

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { onMount } from 'svelte';
import { import {
BookOpen, BookOpen,
Brain, Brain,
@@ -7,6 +8,7 @@
CircleDot, CircleDot,
Compass, Compass,
Dumbbell, Dumbbell,
FolderOpen,
Landmark, Landmark,
LibraryBig, LibraryBig,
Menu, Menu,
@@ -14,6 +16,7 @@
Search, Search,
Settings2, Settings2,
SquareCheckBig, SquareCheckBig,
Tag,
X X
} from '@lucide/svelte'; } from '@lucide/svelte';
@@ -50,6 +53,33 @@
let mobileNavOpen = $state(false); let mobileNavOpen = $state(false);
// Brain sidebar sub-items
interface BrainFolder { id: string; name: string; item_count: number; }
interface BrainTag { id: string; name: string; item_count: number; }
let brainFolders = $state<BrainFolder[]>([]);
let brainTags = $state<BrainTag[]>([]);
let brainSubView = $state<'folders' | 'tags'>('folders');
let brainExpanded = $derived(page.url.pathname.startsWith('/brain'));
async function loadBrainSidebar() {
try {
const res = await fetch('/api/brain/taxonomy/sidebar', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
brainFolders = data.folders || [];
brainTags = data.tags || [];
}
} catch { /* silent */ }
}
onMount(() => {
if (brainExpanded) loadBrainSidebar();
});
$effect(() => {
if (brainExpanded) loadBrainSidebar();
});
function isActive(href: string): boolean { function isActive(href: string): boolean {
if (href === '/') return page.url.pathname === '/'; if (href === '/') return page.url.pathname === '/';
return page.url.pathname === href || page.url.pathname.startsWith(href + '/'); return page.url.pathname === href || page.url.pathname.startsWith(href + '/');
@@ -82,6 +112,36 @@
<item.icon size={16} strokeWidth={1.8} /> <item.icon size={16} strokeWidth={1.8} />
<span>{item.label}</span> <span>{item.label}</span>
</a> </a>
{#if item.id === 'brain' && brainExpanded}
<div class="rail-sub">
<div class="rail-sub-tabs">
<button class="rail-sub-tab" class:active={brainSubView === 'folders'} onclick={() => brainSubView = 'folders'}>
<FolderOpen size={11} strokeWidth={2} /> Folders
</button>
<button class="rail-sub-tab" class:active={brainSubView === 'tags'} onclick={() => brainSubView = 'tags'}>
<Tag size={11} strokeWidth={2} /> Tags
</button>
</div>
{#if brainSubView === 'folders'}
<a href="/brain" class="rail-sub-item" class:active={page.url.pathname === '/brain' && !page.url.searchParams.get('folder')}>
<span>All</span>
</a>
{#each brainFolders.filter(f => f.item_count > 0 || f.name === 'Home' || f.name === 'Work' || f.name === 'Knowledge') as folder}
<a href="/brain?folder={folder.name}" class="rail-sub-item" class:active={page.url.searchParams.get('folder') === folder.name}>
<span>{folder.name}</span>
<span class="rail-sub-count">{folder.item_count}</span>
</a>
{/each}
{:else}
{#each brainTags.filter(t => t.item_count > 0).slice(0, 12) as tag}
<a href="/brain?tag={tag.name}" class="rail-sub-item" class:active={page.url.searchParams.get('tag') === tag.name}>
<span>{tag.name}</span>
<span class="rail-sub-count">{tag.item_count}</span>
</a>
{/each}
{/if}
</div>
{/if}
{/each} {/each}
</nav> </nav>
</div> </div>
@@ -280,6 +340,64 @@
transform: translateX(3px); transform: translateX(3px);
} }
/* Brain sub-sidebar */
.rail-sub {
padding: 4px 0 4px 18px;
display: grid;
gap: 2px;
}
.rail-sub-tabs {
display: flex;
gap: 2px;
margin-bottom: 4px;
padding: 0 6px;
}
.rail-sub-tab {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 6px;
border: none;
background: none;
font-size: 0.7rem;
font-weight: 600;
color: var(--shell-muted);
font-family: inherit;
transition: all 140ms;
}
.rail-sub-tab.active {
background: rgba(255,255,255,0.5);
color: var(--shell-ink);
}
.rail-sub-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 6px 10px;
border-radius: 8px;
color: var(--shell-muted);
font-size: 0.78rem;
transition: all 140ms;
text-decoration: none;
}
.rail-sub-item:hover {
background: rgba(255,255,255,0.4);
color: var(--shell-ink);
}
.rail-sub-item.active {
background: rgba(255,255,255,0.6);
color: var(--shell-ink);
font-weight: 600;
}
.rail-sub-count {
font-size: 0.65rem;
font-family: var(--mono, monospace);
color: var(--shell-muted);
opacity: 0.7;
}
.rail-bottom { .rail-bottom {
display: grid; display: grid;
gap: 10px; gap: 10px;

View File

@@ -307,6 +307,16 @@
}); });
onMount(async () => { onMount(async () => {
// Read filters from URL params (set by AppShell sidebar links)
const urlFolder = new URL(window.location.href).searchParams.get('folder');
const urlTag = new URL(window.location.href).searchParams.get('tag');
if (urlFolder) {
activeFolder = urlFolder;
}
if (urlTag) {
activeTag = urlTag;
}
await loadSidebar(); await loadSidebar();
await loadItems(); await loadItems();
}); });