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 { 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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue