Add category grouping to sidebar and dashboard

Sidebar now renders uncategorised apps flat and categorised apps in
collapsible accordion sections; active category stays expanded. Dashboard
groups tiles under category section headings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-01 18:26:09 +00:00
parent 15c2064483
commit affb1368ac
3 changed files with 153 additions and 33 deletions

View file

@ -1,12 +1,44 @@
import { NavLink, useNavigate } from 'react-router-dom'
import { LayoutGrid, Users, Activity, LogOut } from 'lucide-react'
import { useState } from 'react'
import { NavLink, useNavigate, useLocation } from 'react-router-dom'
import { LayoutGrid, Users, Activity, LogOut, ChevronDown } from 'lucide-react'
import { AppIcon } from './AppIcon'
import type { User } from '../types'
import type { User, App } from '../types'
interface Props { user: User; activeSlug?: string }
export function Sidebar({ user, activeSlug }: Props) {
const navigate = useNavigate()
const location = useLocation()
// Determine active app's category so its group auto-stays open
const routeSlug = location.pathname.startsWith('/app/')
? location.pathname.split('/')[2]
: null
const activeApp = user.apps.find(a => a.slug === routeSlug)
const activeCat = activeApp?.category ?? null
// Separate uncategorised from categorised
const uncategorised = user.apps.filter(a => !a.category)
const grouped = user.apps.reduce<Record<string, App[]>>((acc, app) => {
if (!app.category) return acc
if (!acc[app.category]) acc[app.category] = []
acc[app.category].push(app)
return acc
}, {})
const categoryNames = Object.keys(grouped).sort()
// All categories open by default; track collapsed ones
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
function toggleCat(cat: string) {
// Never collapse the active category
if (cat === activeCat) return
setCollapsed(prev => {
const next = new Set(prev)
next.has(cat) ? next.delete(cat) : next.add(cat)
return next
})
}
async function logout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
@ -30,39 +62,69 @@ export function Sidebar({ user, activeSlug }: Props) {
</div>
<nav style={{ flex: 1, overflowY: 'auto', padding: '0.5rem 0' }}>
{/* Dashboard */}
<NavLink to="/" end style={({ isActive }) => navItem(isActive && !activeSlug)}>
<LayoutGrid size={15} strokeWidth={1.75} />
<LayoutGrid size={14} strokeWidth={1.75} />
Dashboard
</NavLink>
{user.apps.length > 0 && (
<div style={{ padding: '0.6rem 1rem 0.2rem', fontSize: '0.62rem',
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
Apps
</div>
{/* Uncategorised apps — flat list */}
{uncategorised.length > 0 && (
<>
<SectionLabel>Apps</SectionLabel>
{uncategorised.map(app => (
<AppNavLink key={app.slug} app={app} />
))}
</>
)}
{user.apps.map(app => (
<NavLink key={app.slug} to={`/app/${app.slug}`}
style={({ isActive }) => navItem(isActive)}>
<AppIcon name={app.icon} size={15} strokeWidth={1.75} />
<span>{app.name}</span>
</NavLink>
))}
{/* Categorised groups */}
{categoryNames.map(cat => {
const isOpen = !collapsed.has(cat)
const isActiveCat = cat === activeCat
return (
<div key={cat}>
<button
onClick={() => toggleCat(cat)}
style={{
width: '100%', background: 'none', border: 'none',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.5rem 1rem 0.2rem',
color: isActiveCat ? 'var(--gold)' : 'var(--text-muted)',
fontSize: '0.62rem', fontWeight: 600,
textTransform: 'uppercase', letterSpacing: '0.1em',
cursor: isActiveCat ? 'default' : 'pointer',
}}
>
{cat}
<ChevronDown
size={11}
strokeWidth={2}
style={{
transition: 'transform 0.18s',
transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)',
opacity: isActiveCat ? 0 : 0.6,
}}
/>
</button>
{isOpen && grouped[cat].map(app => (
<AppNavLink key={app.slug} app={app} indent />
))}
</div>
)
})}
{/* Admin section */}
{user.is_admin && (
<>
<div style={{ padding: '0.6rem 1rem 0.2rem', fontSize: '0.62rem',
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.1em',
marginTop: '0.5rem' }}>
Admin
</div>
<SectionLabel>Admin</SectionLabel>
<NavLink to="/admin/users" style={({ isActive }) => navItem(isActive)}>
<Users size={15} strokeWidth={1.75} />
<Users size={14} strokeWidth={1.75} />
Users
</NavLink>
<NavLink to="/admin/monitor" style={({ isActive }) => navItem(isActive)}>
<Activity size={15} strokeWidth={1.75} />
<Activity size={14} strokeWidth={1.75} />
Monitor
</NavLink>
</>
@ -85,6 +147,30 @@ export function Sidebar({ user, activeSlug }: Props) {
)
}
function AppNavLink({ app, indent = false }: { app: App; indent?: boolean }) {
return (
<NavLink to={`/app/${app.slug}`} style={({ isActive }) => ({
...navItem(isActive),
paddingLeft: indent ? '1.5rem' : '1rem',
})}>
<AppIcon name={app.icon} size={14} strokeWidth={1.75} />
<span>{app.name}</span>
</NavLink>
)
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div style={{
padding: '0.6rem 1rem 0.2rem', fontSize: '0.62rem',
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.1em',
fontWeight: 600,
}}>
{children}
</div>
)
}
function navItem(active: boolean): React.CSSProperties {
return {
display: 'flex', alignItems: 'center', gap: '0.6rem',