Make the sidebar collapsible and let the knowledge graph go full screen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-29 10:14:34 +07:00
parent bcd7964e0b
commit 278c66897a
5 changed files with 104 additions and 11 deletions

View File

@@ -128,6 +128,12 @@ function OrgSwitcher() {
) )
} }
// Remembered per browser; storage can throw in private windows, so it falls back to expanded.
const COLLAPSED_KEY = 'sidebar-collapsed'
function readCollapsed() {
try { return localStorage.getItem(COLLAPSED_KEY) === '1' } catch { return false }
}
export function AppLayout() { export function AppLayout() {
useLang() useLang()
const [help, setHelp] = useState(false) const [help, setHelp] = useState(false)
@@ -138,16 +144,22 @@ export function AppLayout() {
// Nav groups stay closed unless they hold the current page; a click on the label overrides that until the next route. // Nav groups stay closed unless they hold the current page; a click on the label overrides that until the next route.
const [toggled, setToggled] = useState<Partial<Record<Key, boolean>>>({}) const [toggled, setToggled] = useState<Partial<Record<Key, boolean>>>({})
useEffect(() => setToggled({}), [pathname]) useEffect(() => setToggled({}), [pathname])
const [collapsed, setCollapsed] = useState(readCollapsed)
const toggleCollapsed = () => setCollapsed(c => {
try { localStorage.setItem(COLLAPSED_KEY, c ? '0' : '1') } catch { /* keep it for this visit only */ }
return !c
})
const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-' const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-'
return ( return (
<div className="app"> <div className={collapsed ? 'app sidebar-collapsed' : 'app'}>
<aside className="sidebar"> <aside className="sidebar">
<NavLink to="/production"><Brand /></NavLink> <NavLink to="/production"><Brand /></NavLink>
<OrgSwitcher /> <OrgSwitcher />
<nav aria-label={t('layout.mainNav')}> <nav aria-label={t('layout.mainNav')}>
{NAV_GROUPS.map(g => { {NAV_GROUPS.map(g => {
const open = toggled[g.label] ?? g.items.some(n => pathname.startsWith(n.to)) // The icon rail has no group labels, so every item shows.
const open = collapsed || (toggled[g.label] ?? g.items.some(n => pathname.startsWith(n.to)))
return [ return [
<button key={g.label} type="button" className="nav-label" aria-expanded={open} aria-controls={`nav-${g.label}`} <button key={g.label} type="button" className="nav-label" aria-expanded={open} aria-controls={`nav-${g.label}`}
onClick={() => setToggled(s => ({ ...s, [g.label]: !open }))}> onClick={() => setToggled(s => ({ ...s, [g.label]: !open }))}>
@@ -155,28 +167,36 @@ export function AppLayout() {
</button>, </button>,
<div key={`${g.label}-items`} id={`nav-${g.label}`} className={open ? 'nav-items' : 'nav-items closed'}> <div key={`${g.label}-items`} id={`nav-${g.label}`} className={open ? 'nav-items' : 'nav-items closed'}>
{g.items.map(n => ( {g.items.map(n => (
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}> <NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}
<Icon name={n.icon} />{t(n.label)} title={collapsed ? t(n.label) : undefined}>
<Icon name={n.icon} /><span className="nav-text">{t(n.label)}</span>
</NavLink> </NavLink>
))} ))}
</div>, </div>,
] ]
})} })}
</nav> </nav>
<div className="sidebar-help"> {collapsed ? (
<button type="button" className="icon-btn sidebar-help-icon" title={t('help.open')} aria-label={t('help.open')}
onClick={() => setHelp(true)}><Icon name="info" /></button>
) : <div className="sidebar-help">
<Icon name="info" /> <Icon name="info" />
<b>{t('help.boxTitle')}</b> <b>{t('help.boxTitle')}</b>
<p>{t('help.boxBody')}</p> <p>{t('help.boxBody')}</p>
<button type="button" onClick={() => setHelp(true)}>{t('help.open')} <Icon name="arrow-right" /></button> <button type="button" onClick={() => setHelp(true)}>{t('help.open')} <Icon name="arrow-right" /></button>
</div> </div>}
<div className="profile"> <div className="profile">
<span className="avatar">{name.charAt(0).toUpperCase()}</span> <span className="avatar" title={collapsed ? name : undefined}>{name.charAt(0).toUpperCase()}</span>
<div><b>{name}</b><small>{user?.primaryEmailAddress?.emailAddress}</small></div> <div><b>{name}</b><small>{user?.primaryEmailAddress?.emailAddress}</small></div>
<button className="icon-btn" aria-label={t('layout.signOut')} onClick={() => signOut({ redirectUrl: '/' })}><Icon name="logout" /></button> <button className="icon-btn" aria-label={t('layout.signOut')} onClick={() => signOut({ redirectUrl: '/' })}><Icon name="logout" /></button>
</div> </div>
</aside> </aside>
<div className="workspace-main"> <div className="workspace-main">
<header className="topbar"> <header className="topbar">
<button type="button" className="icon-btn sidebar-toggle" aria-pressed={collapsed}
aria-label={t(collapsed ? 'layout.expandSidebar' : 'layout.collapseSidebar')}
title={t(collapsed ? 'layout.expandSidebar' : 'layout.collapseSidebar')}
onClick={toggleCollapsed}><Icon name="sidebar" /></button>
<div className="breadcrumb">{t('layout.breadcrumb')} <span>/</span> <b>{current ? t(current.label) : ''}</b></div> <div className="breadcrumb">{t('layout.breadcrumb')} <span>/</span> <b>{current ? t(current.label) : ''}</b></div>
<div className="topbar-right"> <div className="topbar-right">
<span className="demo-indicator"><i></i> {t('layout.engine')}</span> <span className="demo-indicator"><i></i> {t('layout.engine')}</span>

View File

@@ -18,6 +18,9 @@ const paths: Record<string, string> = {
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7v.2"/>', info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7v.2"/>',
check: '<path d="m5 12 4 4L19 6"/>', check: '<path d="m5 12 4 4L19 6"/>',
'chevron-down': '<path d="m6 9 6 6 6-6"/>', 'chevron-down': '<path d="m6 9 6 6 6-6"/>',
expand: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
shrink: '<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/>',
sidebar: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/>',
'chevron-updown': '<path d="m8 9 4-4 4 4M8 15l4 4 4-4"/>', 'chevron-updown': '<path d="m8 9 4-4 4 4M8 15l4 4 4-4"/>',
logout:'<path d="M10 4H4v16h6M10 12h11m-4-4 4 4-4 4"/>', logout:'<path d="M10 4H4v16h6M10 12h11m-4-4 4 4-4 4"/>',
plus: '<path d="M12 4v16M4 12h16"/>', plus: '<path d="M12 4v16M4 12h16"/>',

View File

@@ -23,6 +23,8 @@ const en = {
'layout.toStudio': 'Switch to Learning Lab', 'layout.toStudio': 'Switch to Learning Lab',
'layout.footer': 'A workspace for knowledge that keeps growing.', 'layout.footer': 'A workspace for knowledge that keeps growing.',
'layout.mainNav': 'Main navigation', 'layout.mainNav': 'Main navigation',
'layout.collapseSidebar': 'Collapse sidebar',
'layout.expandSidebar': 'Expand sidebar',
'layout.language': 'Language', 'layout.language': 'Language',
// Auth // Auth
@@ -71,6 +73,8 @@ const en = {
'prod.newContent': 'Create new content', 'prod.newContent': 'Create new content',
'prod.graphTitle': 'Knowledge graph', 'prod.graphTitle': 'Knowledge graph',
'prod.clickNode': 'Click a node', 'prod.clickNode': 'Click a node',
'prod.fullscreen': 'Full screen',
'prod.exitFullscreen': 'Exit full screen',
'prod.loadingGraph': 'Drawing the knowledge graph…', 'prod.loadingGraph': 'Drawing the knowledge graph…',
'prod.graphLabel': 'Knowledge graph of {nodes} nodes', 'prod.graphLabel': 'Knowledge graph of {nodes} nodes',
'prod.hub.sources': 'Sources', 'prod.hub.sources': 'Sources',
@@ -262,11 +266,11 @@ const en = {
'runErr.agent_not_active': 'The schedule is paused or deleted.', 'runErr.agent_not_active': 'The schedule is paused or deleted.',
'runErr.run_access_revoked': 'You no longer have permission to run this schedule.', 'runErr.run_access_revoked': 'You no longer have permission to run this schedule.',
'runErr.required_tool_unavailable': 'A tool this schedule needs is turned off.', 'runErr.required_tool_unavailable': 'A tool this schedule needs is turned off.',
'sch.noNewSources': 'No new sources found. A more specific topic may help.',
'sch.blockedBy': 'Blocking material:', 'sch.blockedBy': 'Blocking material:',
'sch.blockedNone': 'All material is ready now. Try again.', 'sch.blockedNone': 'All material is ready now. Try again.',
'sch.fixInStudio': 'Open in Learning Studio', 'sch.fixInStudio': 'Open in Learning Studio',
'issue.document_processing': 'still processing', 'issue.document_processing': 'still processing',
'sch.noNewSources': 'No new sources found. A more specific topic may help.',
'issue.document_failed': 'failed to process; reprocess or delete it', 'issue.document_failed': 'failed to process; reprocess or delete it',
'issue.graph_building': 'knowledge graph still building', 'issue.graph_building': 'knowledge graph still building',
'issue.graph_not_ready': 'knowledge graph not prepared yet', 'issue.graph_not_ready': 'knowledge graph not prepared yet',
@@ -529,6 +533,8 @@ const id: Record<Key, string> = {
'prod.newContent': 'Buat konten baru', 'prod.newContent': 'Buat konten baru',
'prod.graphTitle': 'Knowledge graph', 'prod.graphTitle': 'Knowledge graph',
'prod.clickNode': 'Klik sebuah node', 'prod.clickNode': 'Klik sebuah node',
'prod.fullscreen': 'Layar penuh',
'prod.exitFullscreen': 'Keluar layar penuh',
'prod.loadingGraph': 'Menggambar knowledge graph…', 'prod.loadingGraph': 'Menggambar knowledge graph…',
'prod.graphLabel': 'Knowledge graph berisi {nodes} node', 'prod.graphLabel': 'Knowledge graph berisi {nodes} node',
'prod.hub.sources': 'Sumber', 'prod.hub.sources': 'Sumber',
@@ -562,6 +568,8 @@ const id: Record<Key, string> = {
'layout.toStudio': 'Beralih ke Learning Lab', 'layout.toStudio': 'Beralih ke Learning Lab',
'layout.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.', 'layout.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.',
'layout.mainNav': 'Navigasi utama', 'layout.mainNav': 'Navigasi utama',
'layout.collapseSidebar': 'Ciutkan sidebar',
'layout.expandSidebar': 'Lebarkan sidebar',
'layout.language': 'Bahasa', 'layout.language': 'Bahasa',
'auth.loading': 'Memuat…', 'auth.loading': 'Memuat…',
@@ -752,6 +760,7 @@ const id: Record<Key, string> = {
'runErr.agent_not_active': 'Jadwal sedang dijeda atau sudah dihapus.', 'runErr.agent_not_active': 'Jadwal sedang dijeda atau sudah dihapus.',
'runErr.run_access_revoked': 'Kamu tidak lagi punya izin menjalankan jadwal ini.', 'runErr.run_access_revoked': 'Kamu tidak lagi punya izin menjalankan jadwal ini.',
'runErr.required_tool_unavailable': 'Alat yang dibutuhkan jadwal ini sedang nonaktif.', 'runErr.required_tool_unavailable': 'Alat yang dibutuhkan jadwal ini sedang nonaktif.',
'sch.noNewSources': 'Tidak ada source baru yang ditemukan. Topik yang lebih spesifik bisa membantu.',
'sch.blockedBy': 'Materi yang menahan:', 'sch.blockedBy': 'Materi yang menahan:',
'sch.blockedNone': 'Semua materi sudah siap. Coba lagi.', 'sch.blockedNone': 'Semua materi sudah siap. Coba lagi.',
'sch.fixInStudio': 'Buka di Learning Studio', 'sch.fixInStudio': 'Buka di Learning Studio',
@@ -760,7 +769,6 @@ const id: Record<Key, string> = {
'issue.graph_building': 'knowledge graph masih dibangun', 'issue.graph_building': 'knowledge graph masih dibangun',
'issue.graph_not_ready': 'knowledge graph belum disiapkan', 'issue.graph_not_ready': 'knowledge graph belum disiapkan',
'issue.legacy_set_needs_conversion': 'set Compose lama; perlu dikonversi', 'issue.legacy_set_needs_conversion': 'set Compose lama; perlu dikonversi',
'sch.noNewSources': 'Tidak ada source baru yang ditemukan. Topik yang lebih spesifik bisa membantu.',
'issue.no_knowledge_sources': 'set Compose tidak punya sumber', 'issue.no_knowledge_sources': 'set Compose tidak punya sumber',
'issue.source_not_accessible': 'set Compose memakai dokumen yang tidak bisa kamu akses', 'issue.source_not_accessible': 'set Compose memakai dokumen yang tidak bisa kamu akses',
'issue.source_graph_not_ready': 'salah satu dokumen sumber belum siap', 'issue.source_graph_not_ready': 'salah satu dokumen sumber belum siap',

View File

@@ -1,4 +1,4 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useUser } from '@clerk/react' import { useUser } from '@clerk/react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
@@ -16,6 +16,32 @@ const HUB_LABEL: Record<Cluster, Key> = {
sources: 'prod.hub.sources', made: 'prod.hub.made', working: 'prod.hub.working', research: 'prod.hub.research', schedules: 'prod.hub.schedules', sources: 'prod.hub.sources', made: 'prod.hub.made', working: 'prod.hub.working', research: 'prod.hub.research', schedules: 'prod.hub.schedules',
} }
// Uses the Fullscreen API on the whole stage (graph + panel); where it is missing (iPhone Safari)
// the stage is pinned over the page instead, and Escape leaves it.
function useFullscreen() {
const ref = useRef<HTMLElement>(null)
const [pinned, setPinned] = useState(false)
const [native, setNative] = useState(false)
useEffect(() => {
const sync = () => setNative(document.fullscreenElement != null && document.fullscreenElement === ref.current)
document.addEventListener('fullscreenchange', sync)
return () => document.removeEventListener('fullscreenchange', sync)
}, [])
useEffect(() => {
if (!pinned) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPinned(false) }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [pinned])
const toggle = () => {
if (native) void document.exitFullscreen()
else if (pinned) setPinned(false)
else if (ref.current?.requestFullscreen && document.fullscreenEnabled) ref.current.requestFullscreen().catch(() => setPinned(true))
else setPinned(true)
}
return { ref, full: native || pinned, pinned, toggle }
}
const when = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso)) const when = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
// The dashboard: what is in the Brain, what the AI made from it, what is still being made, and the // The dashboard: what is in the Brain, what the AI made from it, what is still being made, and the
@@ -33,6 +59,8 @@ export function ProductionPage() {
}) })
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 }) const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
const fullscreen = useFullscreen()
const materials = results.data?.materials ?? [] const materials = results.data?.materials ?? []
const working = materials.filter(isWorking) const working = materials.filter(isWorking)
const made = materials.filter(m => !isWorking(m) && m.status !== 'failed') const made = materials.filter(m => !isWorking(m) && m.status !== 'failed')
@@ -57,7 +85,11 @@ export function ProductionPage() {
<Link className="btn" to="/upload"><Icon name="upload" />{t('nav.brain')}</Link> <Link className="btn" to="/upload"><Icon name="upload" />{t('nav.brain')}</Link>
<Link className="btn primary" to="/brain"><Icon name="spark" />{t('prod.newContent')}</Link> <Link className="btn primary" to="/brain"><Icon name="spark" />{t('prod.newContent')}</Link>
</>} /> </>} />
<section className="graph-stage" aria-label={t('prod.graphTitle')}> <section ref={fullscreen.ref} className={`graph-stage${fullscreen.full ? ' full' : ''}${fullscreen.pinned ? ' pinned' : ''}`} aria-label={t('prod.graphTitle')}>
<button type="button" className="graph-fullscreen" onClick={fullscreen.toggle} aria-pressed={fullscreen.full}
aria-label={t(fullscreen.full ? 'prod.exitFullscreen' : 'prod.fullscreen')} title={t(fullscreen.full ? 'prod.exitFullscreen' : 'prod.fullscreen')}>
<Icon name={fullscreen.full ? 'shrink' : 'expand'} />
</button>
{graph {graph
? <KnowledgeGraph graph={graph} /> ? <KnowledgeGraph graph={graph} />
: <div className="graph-loading" role="status">{docs.isError || results.isError ? t('brain.engineDown') : t('prod.loadingGraph')}</div>} : <div className="graph-loading" role="status">{docs.isError || results.isError ? t('brain.engineDown') : t('prod.loadingGraph')}</div>}

View File

@@ -377,8 +377,15 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.graph-empty a,.graph-more{color:#84caff} .graph-empty a,.graph-more{color:#84caff}
.graph-more{display:block;font-size:12px;margin:8px 4px 0} .graph-more{display:block;font-size:12px;margin:8px 4px 0}
.graph-fullscreen{position:absolute;top:16px;right:316px;z-index:3;display:grid;place-items:center;width:34px;height:34px;padding:0;border:1px solid rgba(255,255,255,.14);border-radius:10px;background:rgba(10,22,40,.6);backdrop-filter:blur(8px);color:#d0d5dd;cursor:pointer;transition:background .15s,color .15s}
.graph-fullscreen:hover{background:rgba(46,144,250,.25);color:#fff}
.graph-fullscreen svg{width:16px;height:16px}
.graph-stage.full{border-radius:0;min-height:0;height:100vh;height:100dvh;box-shadow:none}
.graph-stage.pinned{position:fixed;inset:0;z-index:50}
@media(max-width:900px){ @media(max-width:900px){
.graph-stage{min-height:0;display:flex;flex-direction:column} .graph-stage{min-height:0;display:flex;flex-direction:column}
.graph-stage.full{overflow:auto}
.graph-fullscreen{right:16px}
.graph-svg{position:relative;width:100%;height:auto;aspect-ratio:1000/640} .graph-svg{position:relative;width:100%;height:auto;aspect-ratio:1000/640}
.graph-loading{position:relative;inset:auto;min-height:280px} .graph-loading{position:relative;inset:auto;min-height:280px}
.graph-note{position:relative;top:auto;left:auto;margin:16px 16px 0;max-width:none;align-self:flex-start} .graph-note{position:relative;top:auto;left:auto;margin:16px 16px 0;max-width:none;align-self:flex-start}
@@ -395,3 +402,26 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.activity-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px} .activity-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}
.activity-empty{padding:28px 0;text-align:center} .activity-empty{padding:28px 0;text-align:center}
@media(max-width:1000px){.activity-grid{grid-template-columns:1fr}} @media(max-width:1000px){.activity-grid{grid-template-columns:1fr}}
/* Collapsible sidebar: the toggle in the topbar shrinks it to an icon rail (desktop only) */
.sidebar,.workspace-main{transition:width .2s,margin-left .2s}
.sidebar-toggle{flex-shrink:0;margin-right:12px}
.topbar{justify-content:flex-start}.topbar-right{margin-left:auto}
.sidebar-help-icon{align-self:center;margin:auto 0 12px}
@media(min-width:701px){
.sidebar-collapsed .sidebar{width:76px;padding-left:12px;padding-right:12px}
.sidebar-collapsed .workspace-main{margin-left:76px;width:calc(100% - 76px)}
.sidebar-collapsed .sidebar>a>.brand{margin:0 0 24px;justify-content:center}
.sidebar-collapsed .sidebar .brand>span:last-child,
.sidebar-collapsed .workspace>div,.sidebar-collapsed .workspace>svg,
.sidebar-collapsed .sidebar nav button.nav-label,.sidebar-collapsed .nav-text,
.sidebar-collapsed .profile>div{display:none}
.sidebar-collapsed button.workspace{justify-content:center;padding:8px}
.sidebar-collapsed .workspace-menu{right:auto;width:240px}
.sidebar-collapsed .sidebar nav :is(button,a){justify-content:center;padding:11px 0}
.sidebar-collapsed .sidebar nav .nav-items~.nav-items>:first-child{margin-top:12px}
.sidebar-collapsed .profile{flex-direction:column;padding-left:0;padding-right:0}
.sidebar-collapsed .profile>.icon-btn{margin-left:0}
}
@media(max-width:700px){.sidebar-toggle,.sidebar-help-icon{display:none}}
@media(prefers-reduced-motion:reduce){.sidebar,.workspace-main{transition:none}}