Add admin-core frontend (Port 3008)
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>
This commit is contained in:
65
admin-core/components/common/Breadcrumbs.tsx
Normal file
65
admin-core/components/common/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { navigation, metaModules, getModuleByHref, getCategoryById, CategoryId } from '@/lib/navigation'
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const pathname = usePathname()
|
||||
|
||||
const items: Array<{ label: string; href: string }> = []
|
||||
|
||||
items.push({ label: 'Dashboard', href: '/dashboard' })
|
||||
|
||||
const pathParts = pathname.split('/').filter(Boolean)
|
||||
|
||||
if (pathParts.length > 0) {
|
||||
const categoryId = pathParts[0] as CategoryId
|
||||
const category = getCategoryById(categoryId)
|
||||
|
||||
if (category) {
|
||||
items.push({ label: category.name, href: `/${category.id}` })
|
||||
|
||||
if (pathParts.length > 1) {
|
||||
const moduleHref = `/${pathParts[0]}/${pathParts[1]}`
|
||||
const result = getModuleByHref(moduleHref)
|
||||
if (result) {
|
||||
items.push({ label: result.module.name, href: moduleHref })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const metaModule = metaModules.find(m => m.href === `/${pathParts[0]}`)
|
||||
if (metaModule && metaModule.href !== '/dashboard') {
|
||||
items.push({ label: metaModule.name, href: metaModule.href })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-2 text-sm text-slate-500 mb-4">
|
||||
{items.map((item, index) => (
|
||||
<span key={`${index}-${item.href}`} className="flex items-center gap-2">
|
||||
{index > 0 && (
|
||||
<svg className="w-4 h-4 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
{index === items.length - 1 ? (
|
||||
<span className="text-slate-900 font-medium">{item.label}</span>
|
||||
) : (
|
||||
<Link
|
||||
href={item.href}
|
||||
className="hover:text-primary-600 transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
169
admin-core/components/common/PagePurpose.tsx
Normal file
169
admin-core/components/common/PagePurpose.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export interface PagePurposeProps {
|
||||
title: string
|
||||
purpose: string
|
||||
audience: string[]
|
||||
gdprArticles?: string[]
|
||||
architecture?: {
|
||||
services: string[]
|
||||
databases: string[]
|
||||
diagram?: string
|
||||
}
|
||||
relatedPages?: Array<{
|
||||
name: string
|
||||
href: string
|
||||
description: string
|
||||
}>
|
||||
collapsible?: boolean
|
||||
defaultCollapsed?: boolean
|
||||
}
|
||||
|
||||
export function PagePurpose({
|
||||
title,
|
||||
purpose,
|
||||
audience,
|
||||
gdprArticles,
|
||||
architecture,
|
||||
relatedPages,
|
||||
collapsible = true,
|
||||
defaultCollapsed = false,
|
||||
}: PagePurposeProps) {
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed)
|
||||
const [showArchitecture, setShowArchitecture] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-slate-50 to-slate-100 rounded-xl border border-slate-200 mb-6 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`flex items-center justify-between px-4 py-3 ${
|
||||
collapsible ? 'cursor-pointer hover:bg-slate-100' : ''
|
||||
}`}
|
||||
onClick={collapsible ? () => setCollapsed(!collapsed) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🎯</span>
|
||||
<span className="font-semibold text-slate-700">Warum gibt es diese Seite?</span>
|
||||
</div>
|
||||
{collapsible && (
|
||||
<svg
|
||||
className={`w-5 h-5 text-slate-400 transition-transform ${collapsed ? '' : '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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{!collapsed && (
|
||||
<div className="px-4 pb-4 space-y-4">
|
||||
{/* Purpose */}
|
||||
<p className="text-slate-600">{purpose}</p>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
{/* Audience */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-400">👥</span>
|
||||
<span className="text-slate-500">Zielgruppe:</span>
|
||||
<span className="text-slate-700">{audience.join(', ')}</span>
|
||||
</div>
|
||||
|
||||
{/* GDPR Articles */}
|
||||
{gdprArticles && gdprArticles.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-400">📋</span>
|
||||
<span className="text-slate-500">DSGVO-Bezug:</span>
|
||||
<span className="text-slate-700">{gdprArticles.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Architecture (expandable) */}
|
||||
{architecture && (
|
||||
<div className="border-t border-slate-200 pt-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowArchitecture(!showArchitecture)
|
||||
}}
|
||||
className="flex items-center gap-2 text-sm text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
<span>🏗️</span>
|
||||
<span>Architektur</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${showArchitecture ? '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>
|
||||
|
||||
{showArchitecture && (
|
||||
<div className="mt-2 p-3 bg-white rounded-lg border border-slate-200 text-sm">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="font-medium text-slate-600">Services:</span>
|
||||
<ul className="mt-1 space-y-1 text-slate-500">
|
||||
{architecture.services.map((service) => (
|
||||
<li key={service} className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-primary-400 rounded-full" />
|
||||
{service}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-slate-600">Datenbanken:</span>
|
||||
<ul className="mt-1 space-y-1 text-slate-500">
|
||||
{architecture.databases.map((db) => (
|
||||
<li key={db} className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-green-400 rounded-full" />
|
||||
{db}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Pages */}
|
||||
{relatedPages && relatedPages.length > 0 && (
|
||||
<div className="border-t border-slate-200 pt-3">
|
||||
<span className="text-sm text-slate-500 flex items-center gap-2 mb-2">
|
||||
<span>🔗</span>
|
||||
<span>Verwandte Seiten</span>
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{relatedPages.map((page) => (
|
||||
<Link
|
||||
key={page.href}
|
||||
href={page.href}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-white border border-slate-200 rounded-lg text-sm text-slate-600 hover:border-primary-300 hover:text-primary-600 transition-colors"
|
||||
title={page.description}
|
||||
>
|
||||
<span>{page.name}</span>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
533
admin-core/components/infrastructure/DevOpsPipelineSidebar.tsx
Normal file
533
admin-core/components/infrastructure/DevOpsPipelineSidebar.tsx
Normal file
@@ -0,0 +1,533 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DevOps Pipeline Sidebar
|
||||
*
|
||||
* Kompakte Sidebar-Komponente fuer Cross-Navigation zwischen DevOps-Modulen.
|
||||
* Zeigt Pipeline-Flow und ermoeglicht schnelle Navigation.
|
||||
*
|
||||
* Features:
|
||||
* - Desktop: Fixierte Sidebar rechts
|
||||
* - Mobile: Floating Action Button mit Slide-In Drawer
|
||||
* - Live Pipeline-Status Badge
|
||||
* - Backlog-Count Badge
|
||||
* - Security-Findings-Count Badge
|
||||
* - Quick-Action: Pipeline triggern
|
||||
*
|
||||
* Datenfluss: CI/CD -> Tests -> SBOM -> Security
|
||||
*/
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import type {
|
||||
DevOpsToolId,
|
||||
DevOpsPipelineSidebarProps,
|
||||
DevOpsPipelineSidebarResponsiveProps,
|
||||
PipelineLiveStatus,
|
||||
} from '@/types/infrastructure-modules'
|
||||
import { DEVOPS_PIPELINE_MODULES } from '@/types/infrastructure-modules'
|
||||
|
||||
// =============================================================================
|
||||
// Icons
|
||||
// =============================================================================
|
||||
|
||||
const ToolIcon = ({ id }: { id: DevOpsToolId }) => {
|
||||
switch (id) {
|
||||
case 'ci-cd':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
)
|
||||
case 'tests':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
case 'sbom':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
)
|
||||
case 'security':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Server/Pipeline Icon fuer Header
|
||||
const ServerIcon = () => (
|
||||
<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>
|
||||
)
|
||||
|
||||
// Play Icon fuer Quick Action
|
||||
const PlayIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Live Status Hook (optional - fetches status from API)
|
||||
// =============================================================================
|
||||
|
||||
function usePipelineLiveStatus(): PipelineLiveStatus | null {
|
||||
const [status, setStatus] = useState<PipelineLiveStatus | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Optional: Fetch live status from API
|
||||
// For now, return null and display static content
|
||||
// Uncomment below to enable live status fetching
|
||||
/*
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/infrastructure/woodpecker/status')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setStatus(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch pipeline status:', error)
|
||||
}
|
||||
}
|
||||
fetchStatus()
|
||||
const interval = setInterval(fetchStatus, 30000) // Poll every 30s
|
||||
return () => clearInterval(interval)
|
||||
*/
|
||||
}, [])
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Status Badge Component
|
||||
// =============================================================================
|
||||
|
||||
interface StatusBadgeProps {
|
||||
count: number
|
||||
type: 'backlog' | 'security' | 'running'
|
||||
}
|
||||
|
||||
function StatusBadge({ count, type }: StatusBadgeProps) {
|
||||
if (count === 0) return null
|
||||
|
||||
const colors = {
|
||||
backlog: 'bg-amber-500',
|
||||
security: 'bg-red-500',
|
||||
running: 'bg-green-500 animate-pulse',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`${colors[type]} text-white text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center`}>
|
||||
{count}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Sidebar Component
|
||||
// =============================================================================
|
||||
|
||||
export function DevOpsPipelineSidebar({
|
||||
currentTool,
|
||||
compact = false,
|
||||
className = '',
|
||||
}: DevOpsPipelineSidebarProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(!compact)
|
||||
const liveStatus = usePipelineLiveStatus()
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-slate-200 dark:border-gray-700 overflow-hidden ${className}`}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-4 py-3 bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-900/20 dark:to-amber-900/20 border-b border-slate-200 dark:border-gray-700 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-orange-600 dark:text-orange-400">
|
||||
<ServerIcon />
|
||||
</span>
|
||||
<span className="font-semibold text-slate-700 dark:text-slate-200 text-sm">
|
||||
DevOps Pipeline
|
||||
</span>
|
||||
{/* Live status indicator */}
|
||||
{liveStatus?.isRunning && (
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" title="Pipeline laeuft" />
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? '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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Tool Links */}
|
||||
<div className="space-y-1">
|
||||
{DEVOPS_PIPELINE_MODULES.map((tool) => (
|
||||
<Link
|
||||
key={tool.id}
|
||||
href={tool.href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentTool === tool.id
|
||||
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 font-medium'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<ToolIcon id={tool.id} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{tool.name}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-500 truncate">
|
||||
{tool.description}
|
||||
</div>
|
||||
</div>
|
||||
{/* Status badges */}
|
||||
{tool.id === 'tests' && liveStatus && (
|
||||
<StatusBadge count={liveStatus.backlogCount} type="backlog" />
|
||||
)}
|
||||
{tool.id === 'security' && liveStatus && (
|
||||
<StatusBadge count={liveStatus.securityFindingsCount} type="security" />
|
||||
)}
|
||||
{currentTool === tool.id && (
|
||||
<span className="flex-shrink-0 w-2 h-2 bg-orange-500 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pipeline Flow Visualization */}
|
||||
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2 px-1">
|
||||
Pipeline Flow
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-2 py-2 bg-slate-50 dark:bg-gray-900 rounded-lg">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span title="Code" className={currentTool === 'ci-cd' ? 'opacity-100' : 'opacity-50'}>📝</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span title="Build" className={currentTool === 'ci-cd' ? 'opacity-100' : 'opacity-50'}>🏗️</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span title="Test" className={currentTool === 'tests' ? 'opacity-100' : 'opacity-50'}>✅</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span title="SBOM" className={currentTool === 'sbom' ? 'opacity-100' : 'opacity-50'}>📦</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span title="Security" className={currentTool === 'security' ? 'opacity-100' : 'opacity-50'}>🛡️</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span title="Deploy" className="opacity-50">🚀</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Info zum aktuellen Tool */}
|
||||
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||||
{currentTool === 'ci-cd' && (
|
||||
<span>Verwalten Sie Woodpecker Pipelines und Deployments</span>
|
||||
)}
|
||||
{currentTool === 'tests' && (
|
||||
<span>Ueberwachen Sie 280+ Tests ueber alle Services</span>
|
||||
)}
|
||||
{currentTool === 'sbom' && (
|
||||
<span>Pruefen Sie Abhaengigkeiten und Lizenzen</span>
|
||||
)}
|
||||
{currentTool === 'security' && (
|
||||
<span>Analysieren Sie Vulnerabilities und Security-Scans</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Action: Pipeline triggern */}
|
||||
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement pipeline trigger
|
||||
alert('Pipeline wird getriggert...')
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20 hover:bg-orange-100 dark:hover:bg-orange-900/30 rounded-lg transition-colors"
|
||||
>
|
||||
<PlayIcon />
|
||||
<span>Pipeline triggern</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Responsive Version with Mobile FAB + Drawer
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Responsive DevOps Sidebar mit Mobile FAB + Drawer
|
||||
*
|
||||
* Desktop (xl+): Fixierte Sidebar rechts
|
||||
* Mobile/Tablet: Floating Action Button unten rechts, oeffnet Drawer
|
||||
*/
|
||||
export function DevOpsPipelineSidebarResponsive({
|
||||
currentTool,
|
||||
compact = false,
|
||||
className = '',
|
||||
fabPosition = 'bottom-right',
|
||||
}: DevOpsPipelineSidebarResponsiveProps) {
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false)
|
||||
const liveStatus = usePipelineLiveStatus()
|
||||
|
||||
// Close drawer on escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setIsMobileOpen(false)
|
||||
}
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [])
|
||||
|
||||
// Prevent body scroll when drawer is open
|
||||
useEffect(() => {
|
||||
if (isMobileOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [isMobileOpen])
|
||||
|
||||
const fabPositionClasses = fabPosition === 'bottom-right'
|
||||
? 'right-4 bottom-20'
|
||||
: 'left-4 bottom-20'
|
||||
|
||||
// Calculate total badge count for FAB
|
||||
const totalBadgeCount = liveStatus
|
||||
? liveStatus.backlogCount + liveStatus.securityFindingsCount
|
||||
: 0
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: Fixed Sidebar */}
|
||||
<div className={`hidden xl:block fixed right-6 top-24 w-64 z-10 ${className}`}>
|
||||
<DevOpsPipelineSidebar currentTool={currentTool} compact={compact} />
|
||||
</div>
|
||||
|
||||
{/* Mobile/Tablet: FAB */}
|
||||
<button
|
||||
onClick={() => setIsMobileOpen(true)}
|
||||
className={`xl:hidden fixed ${fabPositionClasses} z-40 w-14 h-14 bg-gradient-to-r from-orange-500 to-amber-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center group`}
|
||||
aria-label="DevOps Pipeline Navigation oeffnen"
|
||||
>
|
||||
<ServerIcon />
|
||||
{/* Badge indicator */}
|
||||
{totalBadgeCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">
|
||||
{totalBadgeCount > 9 ? '9+' : totalBadgeCount}
|
||||
</span>
|
||||
)}
|
||||
{/* Pulse indicator when pipeline is running */}
|
||||
{liveStatus?.isRunning && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-400 rounded-full animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Mobile/Tablet: Drawer Overlay */}
|
||||
{isMobileOpen && (
|
||||
<div className="xl:hidden fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-900 shadow-2xl transform transition-transform animate-slide-in-right">
|
||||
{/* Drawer Header */}
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-slate-200 dark:border-gray-700 bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-900/20 dark:to-amber-900/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-orange-600 dark:text-orange-400">
|
||||
<ServerIcon />
|
||||
</span>
|
||||
<span className="font-semibold text-slate-700 dark:text-slate-200">
|
||||
DevOps Pipeline
|
||||
</span>
|
||||
{liveStatus?.isRunning && (
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="Schliessen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drawer Content */}
|
||||
<div className="p-4 space-y-4 overflow-y-auto max-h-[calc(100vh-80px)]">
|
||||
{/* Tool Links */}
|
||||
<div className="space-y-2">
|
||||
{DEVOPS_PIPELINE_MODULES.map((tool) => (
|
||||
<Link
|
||||
key={tool.id}
|
||||
href={tool.href}
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${
|
||||
currentTool === tool.id
|
||||
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 font-medium shadow-sm'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<ToolIcon id={tool.id} />
|
||||
<div className="flex-1">
|
||||
<div className="text-base font-medium">{tool.name}</div>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-500">
|
||||
{tool.description}
|
||||
</div>
|
||||
</div>
|
||||
{/* Status badges */}
|
||||
{tool.id === 'tests' && liveStatus && (
|
||||
<StatusBadge count={liveStatus.backlogCount} type="backlog" />
|
||||
)}
|
||||
{tool.id === 'security' && liveStatus && (
|
||||
<StatusBadge count={liveStatus.securityFindingsCount} type="security" />
|
||||
)}
|
||||
{currentTool === tool.id && (
|
||||
<span className="flex-shrink-0 w-2.5 h-2.5 bg-orange-500 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pipeline Flow Visualization */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
|
||||
Pipeline Flow
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 p-4 bg-slate-50 dark:bg-gray-800 rounded-xl">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">📝</span>
|
||||
<span className="text-xs text-slate-500 mt-1">Code</span>
|
||||
</div>
|
||||
<span className="text-slate-400">→</span>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">🏗️</span>
|
||||
<span className="text-xs text-slate-500 mt-1">Build</span>
|
||||
</div>
|
||||
<span className="text-slate-400">→</span>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">✅</span>
|
||||
<span className="text-xs text-slate-500 mt-1">Test</span>
|
||||
</div>
|
||||
<span className="text-slate-400">→</span>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">🚀</span>
|
||||
<span className="text-xs text-slate-500 mt-1">Deploy</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Info */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-sm text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-xl">
|
||||
{currentTool === 'ci-cd' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Woodpecker Pipelines und Deployments verwalten
|
||||
</>
|
||||
)}
|
||||
{currentTool === 'tests' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> 280+ Tests ueber alle Services ueberwachen
|
||||
</>
|
||||
)}
|
||||
{currentTool === 'sbom' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Abhaengigkeiten und Lizenzen pruefen
|
||||
</>
|
||||
)}
|
||||
{currentTool === 'security' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Vulnerabilities und Security-Scans analysieren
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Action: Pipeline triggern */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement pipeline trigger
|
||||
alert('Pipeline wird getriggert...')
|
||||
setIsMobileOpen(false)
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-base text-white bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 rounded-xl transition-colors font-medium"
|
||||
>
|
||||
<PlayIcon />
|
||||
<span>Pipeline triggern</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Link to Infrastructure Overview */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<Link
|
||||
href="/infrastructure"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
<span>Zur Infrastructure-Uebersicht</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS for slide-in animation */}
|
||||
<style jsx>{`
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.2s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DevOpsPipelineSidebar
|
||||
70
admin-core/components/layout/Header.tsx
Normal file
70
admin-core/components/layout/Header.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { navigation, metaModules, getModuleByHref } from '@/lib/navigation'
|
||||
|
||||
interface HeaderProps {
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function Header({ title, description }: HeaderProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
let pageTitle = title
|
||||
let pageDescription = description
|
||||
|
||||
if (!pageTitle) {
|
||||
const metaModule = metaModules.find(m => pathname === m.href || pathname.startsWith(m.href + '/'))
|
||||
if (metaModule) {
|
||||
pageTitle = metaModule.name
|
||||
pageDescription = metaModule.description
|
||||
} else {
|
||||
const result = getModuleByHref(pathname)
|
||||
if (result) {
|
||||
pageTitle = result.module.name
|
||||
pageDescription = result.module.description
|
||||
} else {
|
||||
const category = navigation.find(cat => pathname === `/${cat.id}`)
|
||||
if (category) {
|
||||
pageTitle = category.name
|
||||
pageDescription = category.description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-white border-b border-slate-200 flex items-center px-6 sticky top-0 z-10">
|
||||
<div className="flex-1">
|
||||
{pageTitle && <h1 className="text-xl font-semibold text-slate-900">{pageTitle}</h1>}
|
||||
{pageDescription && <p className="text-sm text-slate-500">{pageDescription}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen... (Ctrl+K)"
|
||||
className="w-64 pl-10 pr-4 py-2 bg-slate-100 border border-transparent rounded-lg text-sm focus:bg-white focus:border-primary-300 focus:outline-none transition-colors"
|
||||
/>
|
||||
<svg
|
||||
className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-slate-500">Core Admin</span>
|
||||
<div className="w-8 h-8 rounded-full bg-primary-600 flex items-center justify-center text-white text-sm font-medium">
|
||||
C
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
94
admin-core/components/layout/RoleIndicator.tsx
Normal file
94
admin-core/components/layout/RoleIndicator.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { roles, getRoleById, getStoredRole, storeRole, RoleId } from '@/lib/roles'
|
||||
|
||||
interface RoleIndicatorProps {
|
||||
collapsed?: boolean
|
||||
onRoleChange?: () => void
|
||||
}
|
||||
|
||||
export function RoleIndicator({ collapsed, onRoleChange }: RoleIndicatorProps) {
|
||||
const router = useRouter()
|
||||
const [currentRole, setCurrentRole] = useState<RoleId | null>(null)
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const role = getStoredRole()
|
||||
setCurrentRole(role)
|
||||
}, [])
|
||||
|
||||
const handleRoleChange = (roleId: RoleId) => {
|
||||
storeRole(roleId)
|
||||
setCurrentRole(roleId)
|
||||
setShowDropdown(false)
|
||||
onRoleChange?.()
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const role = currentRole ? getRoleById(currentRole) : null
|
||||
|
||||
if (!role) {
|
||||
return null
|
||||
}
|
||||
|
||||
const roleIcons: Record<RoleId, React.ReactNode> = {
|
||||
developer: (
|
||||
<svg className="w-4 h-4" 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>
|
||||
),
|
||||
ops: (
|
||||
<svg className="w-4 h-4" 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>
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-slate-300 hover:bg-slate-800 transition-colors ${
|
||||
collapsed ? 'justify-center' : ''
|
||||
}`}
|
||||
title={collapsed ? `Rolle: ${role.name}` : undefined}
|
||||
>
|
||||
{currentRole && roleIcons[currentRole]}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left">Rolle: {role.name}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${showDropdown ? '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>
|
||||
|
||||
{showDropdown && (
|
||||
<div className={`absolute ${collapsed ? 'left-full ml-2' : 'left-0 right-0'} bottom-full mb-2 bg-slate-800 rounded-lg shadow-lg border border-slate-700 overflow-hidden`}>
|
||||
{roles.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => handleRoleChange(r.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors ${
|
||||
r.id === currentRole
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'text-slate-300 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{roleIcons[r.id]}
|
||||
<span>{r.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
256
admin-core/components/layout/Sidebar.tsx
Normal file
256
admin-core/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user