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:
parent
15c2064483
commit
affb1368ac
3 changed files with 153 additions and 33 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,16 @@ import type { User, App } from '../types'
|
|||
export function Dashboard({ user }: { user: User }) {
|
||||
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 (
|
||||
<div style={{ display: 'flex', height: '100dvh' }}>
|
||||
<Sidebar user={user} />
|
||||
|
|
@ -16,25 +26,48 @@ export function Dashboard({ user }: { user: User }) {
|
|||
<span style={{ color: 'var(--text-dark)', fontWeight: 600 }}>{user.name}</span>
|
||||
</p>
|
||||
|
||||
{user.apps.length === 0 ? (
|
||||
{!hasAny && (
|
||||
<p style={{ color: 'var(--text-mid)', padding: '3rem 0', textAlign: 'center' }}>
|
||||
No apps assigned yet. Contact an administrator.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'grid', gap: '1rem',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))',
|
||||
}}>
|
||||
{user.apps.map(app => (
|
||||
<AppTile key={app.slug} app={app} onOpen={() => navigate(`/app/${app.slug}`)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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={{
|
||||
display: 'grid', gap: '1rem',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))',
|
||||
}}>
|
||||
{apps.map(app => (
|
||||
<AppTile key={app.slug} app={app} onOpen={() => onOpen(app.slug)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppTile({ app, onOpen }: { app: App; onOpen: () => void }) {
|
||||
return (
|
||||
<div style={{
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export interface App {
|
|||
base_path: string
|
||||
icon: string
|
||||
theme_color: string
|
||||
category: string | null
|
||||
}
|
||||
|
||||
export interface User {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue