Next.js admin frontend for Core with 3 categories (Communication, Infrastructure, Development), 13 modules, 2 roles (developer, ops), and 11 API proxy routes. Includes docker-compose service and nginx SSL config. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
257 lines
9.8 KiB
TypeScript
257 lines
9.8 KiB
TypeScript
'use client'
|
|
|
|
import Link from 'next/link'
|
|
import { usePathname } from 'next/navigation'
|
|
import { useState, useEffect } from 'react'
|
|
import { navigation, metaModules, CategoryId } from '@/lib/navigation'
|
|
import { RoleId, getStoredRole, isCategoryVisibleForRole } from '@/lib/roles'
|
|
import { RoleIndicator } from './RoleIndicator'
|
|
|
|
// Icons mapping for Core categories
|
|
const categoryIcons: Record<string, React.ReactNode> = {
|
|
'message-circle': (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
|
</svg>
|
|
),
|
|
server: (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
|
</svg>
|
|
),
|
|
code: (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
|
</svg>
|
|
),
|
|
}
|
|
|
|
const metaIcons: Record<string, React.ReactNode> = {
|
|
dashboard: (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
|
</svg>
|
|
),
|
|
}
|
|
|
|
interface SidebarProps {
|
|
onRoleChange?: () => void
|
|
}
|
|
|
|
export function Sidebar({ onRoleChange }: SidebarProps) {
|
|
const pathname = usePathname()
|
|
const [collapsed, setCollapsed] = useState(false)
|
|
const [expandedCategories, setExpandedCategories] = useState<Set<CategoryId>>(new Set())
|
|
const [currentRole, setCurrentRole] = useState<RoleId | null>(null)
|
|
|
|
useEffect(() => {
|
|
const role = getStoredRole()
|
|
setCurrentRole(role)
|
|
if (role) {
|
|
const category = navigation.find(cat =>
|
|
cat.modules.some(m => pathname.startsWith(m.href.split('/')[1] ? `/${m.href.split('/')[1]}` : m.href))
|
|
)
|
|
if (category) {
|
|
setExpandedCategories(new Set([category.id]))
|
|
}
|
|
}
|
|
}, [pathname])
|
|
|
|
const toggleCategory = (categoryId: CategoryId) => {
|
|
setExpandedCategories(prev => {
|
|
const newSet = new Set(prev)
|
|
if (newSet.has(categoryId)) {
|
|
newSet.delete(categoryId)
|
|
} else {
|
|
newSet.add(categoryId)
|
|
}
|
|
return newSet
|
|
})
|
|
}
|
|
|
|
const isModuleActive = (href: string) => {
|
|
if (href === '/dashboard') {
|
|
return pathname === '/dashboard'
|
|
}
|
|
return pathname.startsWith(href)
|
|
}
|
|
|
|
const visibleCategories = currentRole
|
|
? navigation.filter(cat => isCategoryVisibleForRole(cat.id, currentRole))
|
|
: navigation
|
|
|
|
return (
|
|
<aside
|
|
className={`${
|
|
collapsed ? 'w-16' : 'w-64'
|
|
} bg-slate-900 text-white flex flex-col transition-all duration-300 fixed h-full z-20`}
|
|
>
|
|
{/* Logo/Header */}
|
|
<div className="h-16 flex items-center justify-between px-4 border-b border-slate-700">
|
|
{!collapsed && (
|
|
<Link href="/dashboard" className="font-bold text-lg">
|
|
Core Admin
|
|
</Link>
|
|
)}
|
|
<button
|
|
onClick={() => setCollapsed(!collapsed)}
|
|
className="p-2 rounded-lg hover:bg-slate-800 transition-colors"
|
|
title={collapsed ? 'Sidebar erweitern' : 'Sidebar einklappen'}
|
|
>
|
|
<svg
|
|
className={`w-5 h-5 transition-transform ${collapsed ? 'rotate-180' : ''}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<nav className="flex-1 py-4 overflow-y-auto">
|
|
{/* Meta Modules */}
|
|
<div className="px-2 mb-4">
|
|
{metaModules.map((module) => (
|
|
<Link
|
|
key={module.id}
|
|
href={module.href}
|
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${
|
|
isModuleActive(module.href)
|
|
? 'bg-primary-600 text-white'
|
|
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
|
|
}`}
|
|
title={collapsed ? module.name : undefined}
|
|
>
|
|
<span className="flex-shrink-0">{metaIcons[module.id]}</span>
|
|
{!collapsed && <span className="truncate">{module.name}</span>}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div className="mx-4 border-t border-slate-700 my-2" />
|
|
|
|
{/* Categories */}
|
|
<div className="px-2 space-y-1">
|
|
{visibleCategories.map((category) => {
|
|
const categoryHref = `/${category.id}`
|
|
const isCategoryActive = pathname.startsWith(categoryHref)
|
|
|
|
return (
|
|
<div key={category.id}>
|
|
<div
|
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${
|
|
isCategoryActive
|
|
? 'bg-slate-800 text-white'
|
|
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
|
|
}`}
|
|
>
|
|
<Link
|
|
href={categoryHref}
|
|
className="flex items-center gap-3 flex-1 min-w-0"
|
|
title={collapsed ? category.name : undefined}
|
|
>
|
|
<span
|
|
className="flex-shrink-0"
|
|
style={{ color: category.color }}
|
|
>
|
|
{categoryIcons[category.icon]}
|
|
</span>
|
|
{!collapsed && (
|
|
<span className="flex-1 text-left truncate">{category.name}</span>
|
|
)}
|
|
</Link>
|
|
{!collapsed && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
toggleCategory(category.id)
|
|
}}
|
|
className="p-1 rounded hover:bg-slate-700 transition-colors"
|
|
title={expandedCategories.has(category.id) ? 'Einklappen' : 'Aufklappen'}
|
|
>
|
|
<svg
|
|
className={`w-4 h-4 transition-transform ${
|
|
expandedCategories.has(category.id) ? 'rotate-180' : ''
|
|
}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Category Modules */}
|
|
{!collapsed && expandedCategories.has(category.id) && (
|
|
<div className="ml-4 mt-1 space-y-1">
|
|
{(() => {
|
|
const subgroups = new Map<string | undefined, typeof category.modules>()
|
|
category.modules.forEach((module) => {
|
|
const key = module.subgroup
|
|
if (!subgroups.has(key)) {
|
|
subgroups.set(key, [])
|
|
}
|
|
subgroups.get(key)!.push(module)
|
|
})
|
|
|
|
return Array.from(subgroups.entries()).map(([subgroupName, modules]) => (
|
|
<div key={subgroupName || 'default'}>
|
|
{subgroupName && (
|
|
<div className="px-3 py-1.5 text-xs font-medium text-slate-500 uppercase tracking-wider mt-2 first:mt-0">
|
|
{subgroupName}
|
|
</div>
|
|
)}
|
|
{modules.map((module) => (
|
|
<Link
|
|
key={module.id}
|
|
href={module.href}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
|
isModuleActive(module.href)
|
|
? 'bg-primary-600 text-white'
|
|
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
|
}`}
|
|
>
|
|
<span
|
|
className="w-1.5 h-1.5 rounded-full"
|
|
style={{ backgroundColor: category.color }}
|
|
/>
|
|
<span className="truncate">{module.name}</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
))
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</nav>
|
|
|
|
{/* Footer with Role Indicator */}
|
|
<div className="p-4 border-t border-slate-700">
|
|
<RoleIndicator collapsed={collapsed} onRoleChange={onRoleChange} />
|
|
|
|
<a
|
|
href="https://macmini:3002"
|
|
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors mt-2 ${
|
|
collapsed ? 'justify-center' : ''
|
|
}`}
|
|
title="Admin v2 (Port 3002)"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
</svg>
|
|
{!collapsed && <span>Admin v2</span>}
|
|
</a>
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|