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 { useState } from 'react'
import { LayoutGrid, Users, Activity, LogOut } from 'lucide-react' import { NavLink, useNavigate, useLocation } from 'react-router-dom'
import { LayoutGrid, Users, Activity, LogOut, ChevronDown } from 'lucide-react'
import { AppIcon } from './AppIcon' import { AppIcon } from './AppIcon'
import type { User } from '../types' import type { User, App } from '../types'
interface Props { user: User; activeSlug?: string } interface Props { user: User; activeSlug?: string }
export function Sidebar({ user, activeSlug }: Props) { export function Sidebar({ user, activeSlug }: Props) {
const navigate = useNavigate() 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() { async function logout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }) await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
@ -30,39 +62,69 @@ export function Sidebar({ user, activeSlug }: Props) {
</div> </div>
<nav style={{ flex: 1, overflowY: 'auto', padding: '0.5rem 0' }}> <nav style={{ flex: 1, overflowY: 'auto', padding: '0.5rem 0' }}>
{/* Dashboard */}
<NavLink to="/" end style={({ isActive }) => navItem(isActive && !activeSlug)}> <NavLink to="/" end style={({ isActive }) => navItem(isActive && !activeSlug)}>
<LayoutGrid size={15} strokeWidth={1.75} /> <LayoutGrid size={14} strokeWidth={1.75} />
Dashboard Dashboard
</NavLink> </NavLink>
{user.apps.length > 0 && ( {/* Uncategorised apps — flat list */}
<div style={{ padding: '0.6rem 1rem 0.2rem', fontSize: '0.62rem', {uncategorised.length > 0 && (
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.1em' }}> <>
Apps <SectionLabel>Apps</SectionLabel>
</div> {uncategorised.map(app => (
<AppNavLink key={app.slug} app={app} />
))}
</>
)} )}
{user.apps.map(app => ( {/* Categorised groups */}
<NavLink key={app.slug} to={`/app/${app.slug}`} {categoryNames.map(cat => {
style={({ isActive }) => navItem(isActive)}> const isOpen = !collapsed.has(cat)
<AppIcon name={app.icon} size={15} strokeWidth={1.75} /> const isActiveCat = cat === activeCat
<span>{app.name}</span> return (
</NavLink> <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 && ( {user.is_admin && (
<> <>
<div style={{ padding: '0.6rem 1rem 0.2rem', fontSize: '0.62rem', <SectionLabel>Admin</SectionLabel>
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.1em',
marginTop: '0.5rem' }}>
Admin
</div>
<NavLink to="/admin/users" style={({ isActive }) => navItem(isActive)}> <NavLink to="/admin/users" style={({ isActive }) => navItem(isActive)}>
<Users size={15} strokeWidth={1.75} /> <Users size={14} strokeWidth={1.75} />
Users Users
</NavLink> </NavLink>
<NavLink to="/admin/monitor" style={({ isActive }) => navItem(isActive)}> <NavLink to="/admin/monitor" style={({ isActive }) => navItem(isActive)}>
<Activity size={15} strokeWidth={1.75} /> <Activity size={14} strokeWidth={1.75} />
Monitor Monitor
</NavLink> </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 { function navItem(active: boolean): React.CSSProperties {
return { return {
display: 'flex', alignItems: 'center', gap: '0.6rem', display: 'flex', alignItems: 'center', gap: '0.6rem',

View file

@ -7,6 +7,16 @@ import type { User, App } from '../types'
export function Dashboard({ user }: { user: User }) { export function Dashboard({ user }: { user: User }) {
const navigate = useNavigate() const navigate = useNavigate()
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()
const hasAny = user.apps.length > 0
return ( return (
<div style={{ display: 'flex', height: '100dvh' }}> <div style={{ display: 'flex', height: '100dvh' }}>
<Sidebar user={user} /> <Sidebar user={user} />
@ -16,22 +26,45 @@ export function Dashboard({ user }: { user: User }) {
<span style={{ color: 'var(--text-dark)', fontWeight: 600 }}>{user.name}</span> <span style={{ color: 'var(--text-dark)', fontWeight: 600 }}>{user.name}</span>
</p> </p>
{user.apps.length === 0 ? ( {!hasAny && (
<p style={{ color: 'var(--text-mid)', padding: '3rem 0', textAlign: 'center' }}> <p style={{ color: 'var(--text-mid)', padding: '3rem 0', textAlign: 'center' }}>
No apps assigned yet. Contact an administrator. No apps assigned yet. Contact an administrator.
</p> </p>
) : ( )}
{/* Uncategorised apps */}
{uncategorised.length > 0 && (
<AppGrid apps={uncategorised} onOpen={slug => navigate(`/app/${slug}`)} />
)}
{/* Categorised groups */}
{categoryNames.map(cat => (
<div key={cat} style={{ marginTop: uncategorised.length > 0 ? '2rem' : 0 }}>
<h2 style={{
fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-mid)',
textTransform: 'uppercase', letterSpacing: '0.1em',
marginBottom: '0.875rem',
}}>
{cat}
</h2>
<AppGrid apps={grouped[cat]} onOpen={slug => navigate(`/app/${slug}`)} />
</div>
))}
</main>
</div>
)
}
function AppGrid({ apps, onOpen }: { apps: App[]; onOpen: (slug: string) => void }) {
return (
<div style={{ <div style={{
display: 'grid', gap: '1rem', display: 'grid', gap: '1rem',
gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))',
}}> }}>
{user.apps.map(app => ( {apps.map(app => (
<AppTile key={app.slug} app={app} onOpen={() => navigate(`/app/${app.slug}`)} /> <AppTile key={app.slug} app={app} onOpen={() => onOpen(app.slug)} />
))} ))}
</div> </div>
)}
</main>
</div>
) )
} }

View file

@ -5,6 +5,7 @@ export interface App {
base_path: string base_path: string
icon: string icon: string
theme_color: string theme_color: string
category: string | null
} }
export interface User { export interface User {