[split-required] Split final 43 files (500-668 LOC) to complete refactoring
klausur-service (11 files): - cv_gutter_repair, ocr_pipeline_regression, upload_api - ocr_pipeline_sessions, smart_spell, nru_worksheet_generator - ocr_pipeline_overlays, mail/aggregator, zeugnis_api - cv_syllable_detect, self_rag backend-lehrer (17 files): - classroom_engine/suggestions, generators/quiz_generator - worksheets_api, llm_gateway/comparison, state_engine_api - classroom/models (→ 4 submodules), services/file_processor - alerts_agent/api/wizard+digests+routes, content_generators/pdf - classroom/routes/sessions, llm_gateway/inference - classroom_engine/analytics, auth/keycloak_auth - alerts_agent/processing/rule_engine, ai_processor/print_versions agent-core (5 files): - brain/memory_store, brain/knowledge_graph, brain/context_manager - orchestrator/supervisor, sessions/session_manager admin-lehrer (5 components): - GridOverlay, StepGridReview, DevOpsPipelineSidebar - DataFlowDiagram, sbom/wizard/page website (2 files): - DependencyMap, lehrer/abitur-archiv Other: nibis_ingestion, grid_detection_service, export-doclayout-onnx Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* SBOM Wizard Sub-Components
|
||||
*
|
||||
* WizardStepper, EducationCard, CategoryDemo - extracted from page.tsx.
|
||||
*/
|
||||
|
||||
import type { WizardStep } from './wizard-content'
|
||||
import { EDUCATION_CONTENT } from './wizard-content'
|
||||
|
||||
export function WizardStepper({
|
||||
steps,
|
||||
currentStep,
|
||||
onStepClick
|
||||
}: {
|
||||
steps: WizardStep[]
|
||||
currentStep: number
|
||||
onStepClick: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<button
|
||||
onClick={() => onStepClick(index)}
|
||||
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
|
||||
index === currentStep
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: step.status === 'completed'
|
||||
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
|
||||
: 'text-slate-400 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl mb-1">{step.icon}</span>
|
||||
<span className="text-xs font-medium text-center">{step.name}</span>
|
||||
{step.status === 'completed' && <span className="text-xs text-green-600">✓</span>}
|
||||
</button>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`h-0.5 w-8 mx-1 ${
|
||||
index < currentStep ? 'bg-green-400' : 'bg-slate-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EducationCard({ stepId }: { stepId: string }) {
|
||||
const content = EDUCATION_CONTENT[stepId]
|
||||
if (!content) return null
|
||||
|
||||
return (
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-orange-800 mb-4 flex items-center">
|
||||
<span className="mr-2">📖</span>
|
||||
{content.title}
|
||||
</h3>
|
||||
<div className="space-y-2 text-orange-900">
|
||||
{content.content.map((line, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className={`${line.startsWith('•') ? 'ml-4' : ''} ${line.startsWith('**') ? 'font-semibold mt-3' : ''}`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: line
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/→/g, '<span class="text-orange-600">→</span>')
|
||||
.replace(/← NEU!/g, '<span class="bg-amber-200 text-amber-800 px-1 rounded text-sm font-bold">← NEU!</span>')
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{content.tips && content.tips.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-orange-200">
|
||||
<p className="text-sm font-semibold text-orange-700 mb-2">💡 Tipps:</p>
|
||||
{content.tips.map((tip, index) => (
|
||||
<p key={index} className="text-sm text-orange-700 ml-4">• {tip}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CategoryDemo({ stepId }: { stepId: string }) {
|
||||
if (stepId === 'categories') {
|
||||
const categories = [
|
||||
{ name: 'infrastructure', color: 'blue', count: 45 },
|
||||
{ name: 'security-tools', color: 'red', count: 12 },
|
||||
{ name: 'python', color: 'yellow', count: 35 },
|
||||
{ name: 'go', color: 'cyan', count: 18 },
|
||||
{ name: 'nodejs', color: 'green', count: 55 },
|
||||
{ name: 'unity', color: 'amber', count: 7, isNew: true },
|
||||
{ name: 'csharp', color: 'fuchsia', count: 3, isNew: true },
|
||||
{ name: 'game', color: 'rose', count: 1, isNew: true },
|
||||
]
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Live-Vorschau: Kategorien</h4>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{categories.map((cat) => (
|
||||
<div key={cat.name} className={`bg-${cat.color}-100 text-${cat.color}-800 px-3 py-2 rounded-lg text-center text-sm relative`}>
|
||||
<p className="font-medium">{cat.name}</p>
|
||||
<p className="text-xs opacity-70">{cat.count} Komponenten</p>
|
||||
{'isNew' in cat && cat.isNew && (
|
||||
<span className="absolute -top-1 -right-1 bg-amber-500 text-white text-xs px-1 rounded">NEU</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (stepId === 'unity-game') {
|
||||
const unityComponents = [
|
||||
{ name: 'Unity Engine', version: '6000.0', license: 'Unity EULA' },
|
||||
{ name: 'URP', version: '17.x', license: 'Unity Companion' },
|
||||
{ name: 'TextMeshPro', version: '3.2', license: 'Unity Companion' },
|
||||
{ name: 'Mathematics', version: '1.3', license: 'Unity Companion' },
|
||||
{ name: 'Newtonsoft.Json', version: '3.2', license: 'MIT' },
|
||||
]
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Unity Packages (Breakpilot Drive)</h4>
|
||||
<div className="space-y-2">
|
||||
{unityComponents.map((comp) => (
|
||||
<div key={comp.name} className="flex items-center justify-between py-2 border-b border-slate-100 last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-amber-100 text-amber-800 text-xs px-2 py-0.5 rounded">unity</span>
|
||||
<span className="font-medium">{comp.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||
<span>{comp.version}</span>
|
||||
<span className="bg-slate-100 px-2 py-0.5 rounded text-xs">{comp.license}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (stepId === 'licenses') {
|
||||
const licenses = [
|
||||
{ name: 'MIT', count: 85, color: 'green', risk: 'Niedrig' },
|
||||
{ name: 'Apache 2.0', count: 45, color: 'green', risk: 'Niedrig' },
|
||||
{ name: 'BSD', count: 12, color: 'green', risk: 'Niedrig' },
|
||||
{ name: 'Unity EULA', count: 1, color: 'yellow', risk: 'Mittel' },
|
||||
{ name: 'Unity Companion', count: 6, color: 'yellow', risk: 'Mittel' },
|
||||
{ name: 'AGPL', count: 2, color: 'orange', risk: 'Hoch' },
|
||||
]
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Lizenz-Uebersicht</h4>
|
||||
<div className="space-y-2">
|
||||
{licenses.map((lic) => (
|
||||
<div key={lic.name} className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{lic.name}</span>
|
||||
<span className="text-sm text-slate-500">({lic.count})</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
lic.risk === 'Niedrig' ? 'bg-green-100 text-green-700' :
|
||||
lic.risk === 'Mittel' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-orange-100 text-orange-700'
|
||||
}`}>
|
||||
Risiko: {lic.risk}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* SBOM Wizard - Education Content & Constants
|
||||
*
|
||||
* Extracted from wizard/page.tsx.
|
||||
*/
|
||||
|
||||
export type StepStatus = 'pending' | 'active' | 'completed'
|
||||
|
||||
export interface WizardStep {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
status: StepStatus
|
||||
}
|
||||
|
||||
export const STEPS: WizardStep[] = [
|
||||
{ id: 'welcome', name: 'Willkommen', icon: '📋', status: 'pending' },
|
||||
{ id: 'what-is-sbom', name: 'Was ist SBOM?', icon: '❓', status: 'pending' },
|
||||
{ id: 'why-important', name: 'Warum wichtig?', icon: '⚠️', status: 'pending' },
|
||||
{ id: 'categories', name: 'Kategorien', icon: '📁', status: 'pending' },
|
||||
{ id: 'infrastructure', name: 'Infrastruktur', icon: '🏗️', status: 'pending' },
|
||||
{ id: 'unity-game', name: 'Unity & Game', icon: '🎮', status: 'pending' },
|
||||
{ id: 'licenses', name: 'Lizenzen', icon: '📜', status: 'pending' },
|
||||
{ id: 'summary', name: 'Zusammenfassung', icon: '✅', status: 'pending' },
|
||||
]
|
||||
|
||||
export const EDUCATION_CONTENT: Record<string, { title: string; content: string[]; tips?: string[] }> = {
|
||||
'welcome': {
|
||||
title: 'Willkommen zum SBOM-Wizard!',
|
||||
content: [
|
||||
'Eine **Software Bill of Materials (SBOM)** ist wie ein Zutaten-Etikett fuer Software.',
|
||||
'Sie listet alle Komponenten auf, aus denen eine Anwendung besteht:',
|
||||
'• Open-Source-Bibliotheken',
|
||||
'• Frameworks und Engines',
|
||||
'• Infrastruktur-Dienste',
|
||||
'• Entwicklungs-Tools',
|
||||
'In diesem Wizard lernst du, warum SBOMs wichtig sind und welche Komponenten BreakPilot verwendet - inklusive der neuen **Breakpilot Drive** (Unity) Komponenten.',
|
||||
],
|
||||
tips: [
|
||||
'SBOMs sind seit 2021 fuer US-Regierungsauftraege Pflicht',
|
||||
'Die EU plant aehnliche Vorschriften im Cyber Resilience Act',
|
||||
],
|
||||
},
|
||||
'what-is-sbom': {
|
||||
title: 'Was ist eine SBOM?',
|
||||
content: [
|
||||
'**SBOM = Software Bill of Materials**',
|
||||
'Eine SBOM ist eine vollstaendige Liste aller Software-Komponenten:',
|
||||
'**Enthaltene Informationen:**',
|
||||
'• Name der Komponente',
|
||||
'• Version',
|
||||
'• Lizenz (MIT, Apache, GPL, etc.)',
|
||||
'• Herkunft (Source URL)',
|
||||
'• Typ (Library, Service, Tool)',
|
||||
'**Formate:**',
|
||||
'• SPDX (Linux Foundation Standard)',
|
||||
'• CycloneDX (OWASP Standard)',
|
||||
'• SWID Tags (ISO Standard)',
|
||||
'BreakPilot verwendet eine eigene Darstellung im Admin-Panel, die alle relevanten Infos zeigt.',
|
||||
],
|
||||
tips: [
|
||||
'Eine SBOM ist wie ein Beipackzettel fuer Medikamente',
|
||||
'Sie ermoeglicht schnelle Reaktion bei Sicherheitsluecken',
|
||||
],
|
||||
},
|
||||
'why-important': {
|
||||
title: 'Warum sind SBOMs wichtig?',
|
||||
content: [
|
||||
'**1. Sicherheit (Security)**',
|
||||
'Wenn eine Sicherheitsluecke in einer Bibliothek entdeckt wird (z.B. Log4j), kannst du sofort pruefen ob du betroffen bist.',
|
||||
'**2. Compliance (Lizenz-Einhaltung)**',
|
||||
'Verschiedene Lizenzen haben verschiedene Anforderungen:',
|
||||
'• MIT: Fast keine Einschraenkungen',
|
||||
'• GPL: Copyleft - abgeleitete Werke muessen auch GPL sein',
|
||||
'• Proprietary: Kommerzielle Nutzung eingeschraenkt',
|
||||
'**3. Supply Chain Security**',
|
||||
'Moderne Software besteht aus hunderten Abhaengigkeiten. Eine SBOM macht diese Kette transparent.',
|
||||
'**4. Regulatorische Anforderungen**',
|
||||
'US Executive Order 14028 verlangt SBOMs fuer Regierungssoftware.',
|
||||
],
|
||||
tips: [
|
||||
'Log4Shell (2021) betraf Millionen von Systemen',
|
||||
'Mit SBOM: Betroffenheit in Minuten geprueft',
|
||||
],
|
||||
},
|
||||
'categories': {
|
||||
title: 'SBOM-Kategorien in BreakPilot',
|
||||
content: [
|
||||
'Die BreakPilot SBOM ist in Kategorien unterteilt:',
|
||||
'**infrastructure** (Blau)',
|
||||
'→ Kern-Infrastruktur: PostgreSQL, Valkey, Keycloak, Docker',
|
||||
'**security-tools** (Rot)',
|
||||
'→ Sicherheits-Tools: Trivy, Gitleaks, Semgrep',
|
||||
'**python** (Gelb)',
|
||||
'→ Python-Backend: FastAPI, Pydantic, httpx',
|
||||
'**go** (Cyan)',
|
||||
'→ Go-Services: Gin, GORM, JWT',
|
||||
'**nodejs** (Gruen)',
|
||||
'→ Frontend: Next.js, React, Tailwind',
|
||||
'**unity** (Amber) ← NEU!',
|
||||
'→ Game Engine: Unity 6, URP, TextMeshPro',
|
||||
'**csharp** (Fuchsia) ← NEU!',
|
||||
'→ C#/.NET: .NET Standard, UnityWebRequest',
|
||||
'**game** (Rose) ← NEU!',
|
||||
'→ Breakpilot Drive Service',
|
||||
],
|
||||
tips: [
|
||||
'Klicke auf eine Kategorie um zu filtern',
|
||||
'Die neuen Unity/Game-Kategorien wurden fuer Breakpilot Drive hinzugefuegt',
|
||||
],
|
||||
},
|
||||
'infrastructure': {
|
||||
title: 'Infrastruktur-Komponenten',
|
||||
content: [
|
||||
'BreakPilot basiert auf robuster Infrastruktur:',
|
||||
'**Datenbanken:**',
|
||||
'• PostgreSQL 16 - Relationale Datenbank',
|
||||
'• Valkey 8 - In-Memory Cache (Redis-Fork)',
|
||||
'• ChromaDB - Vector Store fuer RAG',
|
||||
'**Auth & Security:**',
|
||||
'• Keycloak 23 - Identity & Access Management',
|
||||
'• HashiCorp Vault - Secrets Management',
|
||||
'**Container & Orchestrierung:**',
|
||||
'• Docker - Container Runtime',
|
||||
'• Traefik - Reverse Proxy',
|
||||
'**Kommunikation:**',
|
||||
'• Matrix Synapse - Chat/Messaging',
|
||||
'• Jitsi Meet - Video-Konferenzen',
|
||||
],
|
||||
tips: [
|
||||
'Alle Services laufen in Docker-Containern',
|
||||
'Ports sind in docker-compose.yml definiert',
|
||||
],
|
||||
},
|
||||
'unity-game': {
|
||||
title: 'Unity & Breakpilot Drive',
|
||||
content: [
|
||||
'**Neu hinzugefuegt fuer Breakpilot Drive:**',
|
||||
'**Unity Engine (6000.0)**',
|
||||
'→ Die Game Engine fuer das Lernspiel',
|
||||
'→ Lizenz: Unity EULA (kostenlos bis 100k Revenue)',
|
||||
'**Universal Render Pipeline (17.x)**',
|
||||
'→ Optimierte Grafik-Pipeline fuer WebGL',
|
||||
'→ Lizenz: Unity Companion License',
|
||||
'**TextMeshPro (3.2)**',
|
||||
'→ Fortgeschrittenes Text-Rendering',
|
||||
'**Unity Mathematics (1.3)**',
|
||||
'→ SIMD-optimierte Mathe-Bibliothek',
|
||||
'**Newtonsoft.Json (3.2)**',
|
||||
'→ JSON-Serialisierung fuer API-Kommunikation',
|
||||
'**C# Abhaengigkeiten:**',
|
||||
'• .NET Standard 2.1',
|
||||
'• UnityWebRequest (HTTP Client)',
|
||||
'• System.Text.Json',
|
||||
],
|
||||
tips: [
|
||||
'Unity 6 ist die neueste LTS-Version',
|
||||
'WebGL-Builds sind ~30-50 MB gross',
|
||||
],
|
||||
},
|
||||
'licenses': {
|
||||
title: 'Lizenz-Compliance',
|
||||
content: [
|
||||
'**Lizenz-Typen in BreakPilot:**',
|
||||
'**Permissive (Unkompliziert):**',
|
||||
'• MIT - Die meisten JS/Python Libs',
|
||||
'• Apache 2.0 - FastAPI, Keycloak',
|
||||
'• BSD - PostgreSQL',
|
||||
'**Copyleft (Vorsicht bei Aenderungen):**',
|
||||
'• GPL - Wenige Komponenten',
|
||||
'• AGPL - Jitsi (Server-Side OK)',
|
||||
'**Proprietary:**',
|
||||
'• Unity EULA - Kostenlos bis 100k Revenue',
|
||||
'• Unity Companion - Packages an Engine gebunden',
|
||||
'**Wichtig:**',
|
||||
'Alle verwendeten Lizenzen sind mit kommerziellem Einsatz kompatibel. Bei Fragen: Rechtsabteilung konsultieren.',
|
||||
],
|
||||
tips: [
|
||||
'MIT und Apache 2.0 sind am unproblematischsten',
|
||||
'AGPL erfordert Source-Code-Freigabe bei Modifikation',
|
||||
],
|
||||
},
|
||||
'summary': {
|
||||
title: 'Zusammenfassung',
|
||||
content: [
|
||||
'Du hast die SBOM von BreakPilot kennengelernt:',
|
||||
'✅ Was eine SBOM ist und warum sie wichtig ist',
|
||||
'✅ Die verschiedenen Kategorien (8 Stueck)',
|
||||
'✅ Infrastruktur-Komponenten',
|
||||
'✅ Die neuen Unity/Game-Komponenten fuer Breakpilot Drive',
|
||||
'✅ Lizenz-Typen und Compliance',
|
||||
'**Im SBOM-Dashboard kannst du:**',
|
||||
'• Nach Kategorie filtern',
|
||||
'• Nach Namen suchen',
|
||||
'• Lizenzen pruefen',
|
||||
'• Komponenten-Details ansehen',
|
||||
'**180+ Komponenten** sind dokumentiert und nachverfolgbar.',
|
||||
],
|
||||
tips: [
|
||||
'Pruefe regelmaessig auf veraltete Komponenten',
|
||||
'Bei neuen Abhaengigkeiten: SBOM aktualisieren',
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -3,408 +3,13 @@
|
||||
/**
|
||||
* SBOM (Software Bill of Materials) - Lern-Wizard
|
||||
*
|
||||
* Migriert von /admin/sbom/wizard (website) nach /infrastructure/sbom/wizard (admin-v2)
|
||||
*
|
||||
* Interaktiver Wizard zum Verstehen der SBOM:
|
||||
* - Was ist eine SBOM?
|
||||
* - Warum ist sie wichtig?
|
||||
* - Kategorien erklaert
|
||||
* - Breakpilot Drive (Unity/C#/Game) Komponenten
|
||||
* - Lizenzen und Compliance
|
||||
* Interaktiver Wizard zum Verstehen der SBOM.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
// ==============================================
|
||||
// Types
|
||||
// ==============================================
|
||||
|
||||
type StepStatus = 'pending' | 'active' | 'completed'
|
||||
|
||||
interface WizardStep {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
status: StepStatus
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Constants
|
||||
// ==============================================
|
||||
|
||||
const STEPS: WizardStep[] = [
|
||||
{ id: 'welcome', name: 'Willkommen', icon: '📋', status: 'pending' },
|
||||
{ id: 'what-is-sbom', name: 'Was ist SBOM?', icon: '❓', status: 'pending' },
|
||||
{ id: 'why-important', name: 'Warum wichtig?', icon: '⚠️', status: 'pending' },
|
||||
{ id: 'categories', name: 'Kategorien', icon: '📁', status: 'pending' },
|
||||
{ id: 'infrastructure', name: 'Infrastruktur', icon: '🏗️', status: 'pending' },
|
||||
{ id: 'unity-game', name: 'Unity & Game', icon: '🎮', status: 'pending' },
|
||||
{ id: 'licenses', name: 'Lizenzen', icon: '📜', status: 'pending' },
|
||||
{ id: 'summary', name: 'Zusammenfassung', icon: '✅', status: 'pending' },
|
||||
]
|
||||
|
||||
const EDUCATION_CONTENT: Record<string, { title: string; content: string[]; tips?: string[] }> = {
|
||||
'welcome': {
|
||||
title: 'Willkommen zum SBOM-Wizard!',
|
||||
content: [
|
||||
'Eine **Software Bill of Materials (SBOM)** ist wie ein Zutaten-Etikett fuer Software.',
|
||||
'Sie listet alle Komponenten auf, aus denen eine Anwendung besteht:',
|
||||
'• Open-Source-Bibliotheken',
|
||||
'• Frameworks und Engines',
|
||||
'• Infrastruktur-Dienste',
|
||||
'• Entwicklungs-Tools',
|
||||
'In diesem Wizard lernst du, warum SBOMs wichtig sind und welche Komponenten BreakPilot verwendet - inklusive der neuen **Breakpilot Drive** (Unity) Komponenten.',
|
||||
],
|
||||
tips: [
|
||||
'SBOMs sind seit 2021 fuer US-Regierungsauftraege Pflicht',
|
||||
'Die EU plant aehnliche Vorschriften im Cyber Resilience Act',
|
||||
],
|
||||
},
|
||||
'what-is-sbom': {
|
||||
title: 'Was ist eine SBOM?',
|
||||
content: [
|
||||
'**SBOM = Software Bill of Materials**',
|
||||
'Eine SBOM ist eine vollstaendige Liste aller Software-Komponenten:',
|
||||
'**Enthaltene Informationen:**',
|
||||
'• Name der Komponente',
|
||||
'• Version',
|
||||
'• Lizenz (MIT, Apache, GPL, etc.)',
|
||||
'• Herkunft (Source URL)',
|
||||
'• Typ (Library, Service, Tool)',
|
||||
'**Formate:**',
|
||||
'• SPDX (Linux Foundation Standard)',
|
||||
'• CycloneDX (OWASP Standard)',
|
||||
'• SWID Tags (ISO Standard)',
|
||||
'BreakPilot verwendet eine eigene Darstellung im Admin-Panel, die alle relevanten Infos zeigt.',
|
||||
],
|
||||
tips: [
|
||||
'Eine SBOM ist wie ein Beipackzettel fuer Medikamente',
|
||||
'Sie ermoeglicht schnelle Reaktion bei Sicherheitsluecken',
|
||||
],
|
||||
},
|
||||
'why-important': {
|
||||
title: 'Warum sind SBOMs wichtig?',
|
||||
content: [
|
||||
'**1. Sicherheit (Security)**',
|
||||
'Wenn eine Sicherheitsluecke in einer Bibliothek entdeckt wird (z.B. Log4j), kannst du sofort pruefen ob du betroffen bist.',
|
||||
'**2. Compliance (Lizenz-Einhaltung)**',
|
||||
'Verschiedene Lizenzen haben verschiedene Anforderungen:',
|
||||
'• MIT: Fast keine Einschraenkungen',
|
||||
'• GPL: Copyleft - abgeleitete Werke muessen auch GPL sein',
|
||||
'• Proprietary: Kommerzielle Nutzung eingeschraenkt',
|
||||
'**3. Supply Chain Security**',
|
||||
'Moderne Software besteht aus hunderten Abhaengigkeiten. Eine SBOM macht diese Kette transparent.',
|
||||
'**4. Regulatorische Anforderungen**',
|
||||
'US Executive Order 14028 verlangt SBOMs fuer Regierungssoftware.',
|
||||
],
|
||||
tips: [
|
||||
'Log4Shell (2021) betraf Millionen von Systemen',
|
||||
'Mit SBOM: Betroffenheit in Minuten geprueft',
|
||||
],
|
||||
},
|
||||
'categories': {
|
||||
title: 'SBOM-Kategorien in BreakPilot',
|
||||
content: [
|
||||
'Die BreakPilot SBOM ist in Kategorien unterteilt:',
|
||||
'**infrastructure** (Blau)',
|
||||
'→ Kern-Infrastruktur: PostgreSQL, Valkey, Keycloak, Docker',
|
||||
'**security-tools** (Rot)',
|
||||
'→ Sicherheits-Tools: Trivy, Gitleaks, Semgrep',
|
||||
'**python** (Gelb)',
|
||||
'→ Python-Backend: FastAPI, Pydantic, httpx',
|
||||
'**go** (Cyan)',
|
||||
'→ Go-Services: Gin, GORM, JWT',
|
||||
'**nodejs** (Gruen)',
|
||||
'→ Frontend: Next.js, React, Tailwind',
|
||||
'**unity** (Amber) ← NEU!',
|
||||
'→ Game Engine: Unity 6, URP, TextMeshPro',
|
||||
'**csharp** (Fuchsia) ← NEU!',
|
||||
'→ C#/.NET: .NET Standard, UnityWebRequest',
|
||||
'**game** (Rose) ← NEU!',
|
||||
'→ Breakpilot Drive Service',
|
||||
],
|
||||
tips: [
|
||||
'Klicke auf eine Kategorie um zu filtern',
|
||||
'Die neuen Unity/Game-Kategorien wurden fuer Breakpilot Drive hinzugefuegt',
|
||||
],
|
||||
},
|
||||
'infrastructure': {
|
||||
title: 'Infrastruktur-Komponenten',
|
||||
content: [
|
||||
'BreakPilot basiert auf robuster Infrastruktur:',
|
||||
'**Datenbanken:**',
|
||||
'• PostgreSQL 16 - Relationale Datenbank',
|
||||
'• Valkey 8 - In-Memory Cache (Redis-Fork)',
|
||||
'• ChromaDB - Vector Store fuer RAG',
|
||||
'**Auth & Security:**',
|
||||
'• Keycloak 23 - Identity & Access Management',
|
||||
'• HashiCorp Vault - Secrets Management',
|
||||
'**Container & Orchestrierung:**',
|
||||
'• Docker - Container Runtime',
|
||||
'• Traefik - Reverse Proxy',
|
||||
'**Kommunikation:**',
|
||||
'• Matrix Synapse - Chat/Messaging',
|
||||
'• Jitsi Meet - Video-Konferenzen',
|
||||
],
|
||||
tips: [
|
||||
'Alle Services laufen in Docker-Containern',
|
||||
'Ports sind in docker-compose.yml definiert',
|
||||
],
|
||||
},
|
||||
'unity-game': {
|
||||
title: 'Unity & Breakpilot Drive',
|
||||
content: [
|
||||
'**Neu hinzugefuegt fuer Breakpilot Drive:**',
|
||||
'**Unity Engine (6000.0)**',
|
||||
'→ Die Game Engine fuer das Lernspiel',
|
||||
'→ Lizenz: Unity EULA (kostenlos bis 100k Revenue)',
|
||||
'**Universal Render Pipeline (17.x)**',
|
||||
'→ Optimierte Grafik-Pipeline fuer WebGL',
|
||||
'→ Lizenz: Unity Companion License',
|
||||
'**TextMeshPro (3.2)**',
|
||||
'→ Fortgeschrittenes Text-Rendering',
|
||||
'**Unity Mathematics (1.3)**',
|
||||
'→ SIMD-optimierte Mathe-Bibliothek',
|
||||
'**Newtonsoft.Json (3.2)**',
|
||||
'→ JSON-Serialisierung fuer API-Kommunikation',
|
||||
'**C# Abhaengigkeiten:**',
|
||||
'• .NET Standard 2.1',
|
||||
'• UnityWebRequest (HTTP Client)',
|
||||
'• System.Text.Json',
|
||||
],
|
||||
tips: [
|
||||
'Unity 6 ist die neueste LTS-Version',
|
||||
'WebGL-Builds sind ~30-50 MB gross',
|
||||
],
|
||||
},
|
||||
'licenses': {
|
||||
title: 'Lizenz-Compliance',
|
||||
content: [
|
||||
'**Lizenz-Typen in BreakPilot:**',
|
||||
'**Permissive (Unkompliziert):**',
|
||||
'• MIT - Die meisten JS/Python Libs',
|
||||
'• Apache 2.0 - FastAPI, Keycloak',
|
||||
'• BSD - PostgreSQL',
|
||||
'**Copyleft (Vorsicht bei Aenderungen):**',
|
||||
'• GPL - Wenige Komponenten',
|
||||
'• AGPL - Jitsi (Server-Side OK)',
|
||||
'**Proprietary:**',
|
||||
'• Unity EULA - Kostenlos bis 100k Revenue',
|
||||
'• Unity Companion - Packages an Engine gebunden',
|
||||
'**Wichtig:**',
|
||||
'Alle verwendeten Lizenzen sind mit kommerziellem Einsatz kompatibel. Bei Fragen: Rechtsabteilung konsultieren.',
|
||||
],
|
||||
tips: [
|
||||
'MIT und Apache 2.0 sind am unproblematischsten',
|
||||
'AGPL erfordert Source-Code-Freigabe bei Modifikation',
|
||||
],
|
||||
},
|
||||
'summary': {
|
||||
title: 'Zusammenfassung',
|
||||
content: [
|
||||
'Du hast die SBOM von BreakPilot kennengelernt:',
|
||||
'✅ Was eine SBOM ist und warum sie wichtig ist',
|
||||
'✅ Die verschiedenen Kategorien (8 Stueck)',
|
||||
'✅ Infrastruktur-Komponenten',
|
||||
'✅ Die neuen Unity/Game-Komponenten fuer Breakpilot Drive',
|
||||
'✅ Lizenz-Typen und Compliance',
|
||||
'**Im SBOM-Dashboard kannst du:**',
|
||||
'• Nach Kategorie filtern',
|
||||
'• Nach Namen suchen',
|
||||
'• Lizenzen pruefen',
|
||||
'• Komponenten-Details ansehen',
|
||||
'**180+ Komponenten** sind dokumentiert und nachverfolgbar.',
|
||||
],
|
||||
tips: [
|
||||
'Pruefe regelmaessig auf veraltete Komponenten',
|
||||
'Bei neuen Abhaengigkeiten: SBOM aktualisieren',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Components
|
||||
// ==============================================
|
||||
|
||||
function WizardStepper({
|
||||
steps,
|
||||
currentStep,
|
||||
onStepClick
|
||||
}: {
|
||||
steps: WizardStep[]
|
||||
currentStep: number
|
||||
onStepClick: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<button
|
||||
onClick={() => onStepClick(index)}
|
||||
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
|
||||
index === currentStep
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: step.status === 'completed'
|
||||
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
|
||||
: 'text-slate-400 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl mb-1">{step.icon}</span>
|
||||
<span className="text-xs font-medium text-center">{step.name}</span>
|
||||
{step.status === 'completed' && <span className="text-xs text-green-600">✓</span>}
|
||||
</button>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`h-0.5 w-8 mx-1 ${
|
||||
index < currentStep ? 'bg-green-400' : 'bg-slate-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EducationCard({ stepId }: { stepId: string }) {
|
||||
const content = EDUCATION_CONTENT[stepId]
|
||||
if (!content) return null
|
||||
|
||||
return (
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-orange-800 mb-4 flex items-center">
|
||||
<span className="mr-2">📖</span>
|
||||
{content.title}
|
||||
</h3>
|
||||
<div className="space-y-2 text-orange-900">
|
||||
{content.content.map((line, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className={`${line.startsWith('•') ? 'ml-4' : ''} ${line.startsWith('**') ? 'font-semibold mt-3' : ''}`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: line
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/→/g, '<span class="text-orange-600">→</span>')
|
||||
.replace(/← NEU!/g, '<span class="bg-amber-200 text-amber-800 px-1 rounded text-sm font-bold">← NEU!</span>')
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{content.tips && content.tips.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-orange-200">
|
||||
<p className="text-sm font-semibold text-orange-700 mb-2">💡 Tipps:</p>
|
||||
{content.tips.map((tip, index) => (
|
||||
<p key={index} className="text-sm text-orange-700 ml-4">• {tip}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryDemo({ stepId }: { stepId: string }) {
|
||||
if (stepId === 'categories') {
|
||||
const categories = [
|
||||
{ name: 'infrastructure', color: 'blue', count: 45 },
|
||||
{ name: 'security-tools', color: 'red', count: 12 },
|
||||
{ name: 'python', color: 'yellow', count: 35 },
|
||||
{ name: 'go', color: 'cyan', count: 18 },
|
||||
{ name: 'nodejs', color: 'green', count: 55 },
|
||||
{ name: 'unity', color: 'amber', count: 7, isNew: true },
|
||||
{ name: 'csharp', color: 'fuchsia', count: 3, isNew: true },
|
||||
{ name: 'game', color: 'rose', count: 1, isNew: true },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Live-Vorschau: Kategorien</h4>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{categories.map((cat) => (
|
||||
<div
|
||||
key={cat.name}
|
||||
className={`bg-${cat.color}-100 text-${cat.color}-800 px-3 py-2 rounded-lg text-center text-sm relative`}
|
||||
>
|
||||
<p className="font-medium">{cat.name}</p>
|
||||
<p className="text-xs opacity-70">{cat.count} Komponenten</p>
|
||||
{cat.isNew && (
|
||||
<span className="absolute -top-1 -right-1 bg-amber-500 text-white text-xs px-1 rounded">NEU</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (stepId === 'unity-game') {
|
||||
const unityComponents = [
|
||||
{ name: 'Unity Engine', version: '6000.0', license: 'Unity EULA' },
|
||||
{ name: 'URP', version: '17.x', license: 'Unity Companion' },
|
||||
{ name: 'TextMeshPro', version: '3.2', license: 'Unity Companion' },
|
||||
{ name: 'Mathematics', version: '1.3', license: 'Unity Companion' },
|
||||
{ name: 'Newtonsoft.Json', version: '3.2', license: 'MIT' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Unity Packages (Breakpilot Drive)</h4>
|
||||
<div className="space-y-2">
|
||||
{unityComponents.map((comp) => (
|
||||
<div key={comp.name} className="flex items-center justify-between py-2 border-b border-slate-100 last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-amber-100 text-amber-800 text-xs px-2 py-0.5 rounded">unity</span>
|
||||
<span className="font-medium">{comp.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||
<span>{comp.version}</span>
|
||||
<span className="bg-slate-100 px-2 py-0.5 rounded text-xs">{comp.license}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (stepId === 'licenses') {
|
||||
const licenses = [
|
||||
{ name: 'MIT', count: 85, color: 'green', risk: 'Niedrig' },
|
||||
{ name: 'Apache 2.0', count: 45, color: 'green', risk: 'Niedrig' },
|
||||
{ name: 'BSD', count: 12, color: 'green', risk: 'Niedrig' },
|
||||
{ name: 'Unity EULA', count: 1, color: 'yellow', risk: 'Mittel' },
|
||||
{ name: 'Unity Companion', count: 6, color: 'yellow', risk: 'Mittel' },
|
||||
{ name: 'AGPL', count: 2, color: 'orange', risk: 'Hoch' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Lizenz-Uebersicht</h4>
|
||||
<div className="space-y-2">
|
||||
{licenses.map((lic) => (
|
||||
<div key={lic.name} className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{lic.name}</span>
|
||||
<span className="text-sm text-slate-500">({lic.count})</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
lic.risk === 'Niedrig' ? 'bg-green-100 text-green-700' :
|
||||
lic.risk === 'Mittel' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-orange-100 text-orange-700'
|
||||
}`}>
|
||||
Risiko: {lic.risk}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Main Component
|
||||
// ==============================================
|
||||
import { STEPS, type WizardStep } from './_components/wizard-content'
|
||||
import { WizardStepper, EducationCard, CategoryDemo } from './_components/WizardWidgets'
|
||||
|
||||
export default function SBOMWizardPage() {
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
MODULE_REGISTRY,
|
||||
type BackendModule
|
||||
} from '@/lib/module-registry'
|
||||
import { DataFlowDiagramDetails, SERVICE_COLORS, STATUS_COLORS } from './DataFlowDiagramDetails'
|
||||
|
||||
interface NodePosition {
|
||||
x: number
|
||||
@@ -26,20 +27,6 @@ interface ServiceGroup {
|
||||
modules: BackendModule[]
|
||||
}
|
||||
|
||||
const SERVICE_COLORS: Record<string, string> = {
|
||||
'consent-service': '#8b5cf6', // purple
|
||||
'python-backend': '#f59e0b', // amber
|
||||
'klausur-service': '#10b981', // emerald
|
||||
'voice-service': '#3b82f6', // blue
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
connected: '#22c55e',
|
||||
partial: '#eab308',
|
||||
'not-connected': '#ef4444',
|
||||
deprecated: '#6b7280'
|
||||
}
|
||||
|
||||
export function DataFlowDiagram() {
|
||||
const [selectedModule, setSelectedModule] = useState<string | null>(null)
|
||||
const [hoveredModule, setHoveredModule] = useState<string | null>(null)
|
||||
@@ -471,39 +458,10 @@ export function DataFlowDiagram() {
|
||||
|
||||
{/* Selected Module Details */}
|
||||
{selectedModule && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
|
||||
<h4 className="font-medium text-purple-900 mb-2">
|
||||
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.name}
|
||||
</h4>
|
||||
<div className="text-sm text-purple-700 space-y-1">
|
||||
<p>ID: <code className="bg-purple-100 px-1 rounded">{selectedModule}</code></p>
|
||||
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.dependencies && (
|
||||
<p>
|
||||
Abhaengigkeiten:
|
||||
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.dependencies?.map(dep => (
|
||||
<button
|
||||
key={dep}
|
||||
onClick={() => setSelectedModule(dep)}
|
||||
className="ml-2 px-2 py-0.5 bg-purple-200 text-purple-800 rounded hover:bg-purple-300"
|
||||
>
|
||||
{dep}
|
||||
</button>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.frontend.adminV2Page && (
|
||||
<p>
|
||||
Frontend:
|
||||
<a
|
||||
href={MODULE_REGISTRY.find(m => m.id === selectedModule)?.frontend.adminV2Page}
|
||||
className="ml-2 text-purple-600 hover:underline"
|
||||
>
|
||||
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.frontend.adminV2Page}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DataFlowDiagramDetails
|
||||
selectedModule={selectedModule}
|
||||
onSelectModule={setSelectedModule}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
64
admin-lehrer/components/common/DataFlowDiagramDetails.tsx
Normal file
64
admin-lehrer/components/common/DataFlowDiagramDetails.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DataFlowDiagram Module Details Panel
|
||||
*
|
||||
* Extracted from DataFlowDiagram.tsx for the selected module details panel.
|
||||
*/
|
||||
|
||||
import { MODULE_REGISTRY, type BackendModule } from '@/lib/module-registry'
|
||||
|
||||
interface DataFlowDiagramDetailsProps {
|
||||
selectedModule: string
|
||||
onSelectModule: (id: string | null) => void
|
||||
}
|
||||
|
||||
export function DataFlowDiagramDetails({ selectedModule, onSelectModule }: DataFlowDiagramDetailsProps) {
|
||||
const module = MODULE_REGISTRY.find(m => m.id === selectedModule)
|
||||
if (!module) return null
|
||||
|
||||
return (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
|
||||
<h4 className="font-medium text-purple-900 mb-2">{module.name}</h4>
|
||||
<div className="text-sm text-purple-700 space-y-1">
|
||||
<p>ID: <code className="bg-purple-100 px-1 rounded">{selectedModule}</code></p>
|
||||
{module.dependencies && (
|
||||
<p>
|
||||
Abhaengigkeiten:
|
||||
{module.dependencies.map(dep => (
|
||||
<button
|
||||
key={dep}
|
||||
onClick={() => onSelectModule(dep)}
|
||||
className="ml-2 px-2 py-0.5 bg-purple-200 text-purple-800 rounded hover:bg-purple-300"
|
||||
>
|
||||
{dep}
|
||||
</button>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
{module.frontend.adminV2Page && (
|
||||
<p>
|
||||
Frontend:
|
||||
<a href={module.frontend.adminV2Page} className="ml-2 text-purple-600 hover:underline">
|
||||
{module.frontend.adminV2Page}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SERVICE_COLORS: Record<string, string> = {
|
||||
'consent-service': '#8b5cf6',
|
||||
'python-backend': '#f59e0b',
|
||||
'klausur-service': '#10b981',
|
||||
'voice-service': '#3b82f6',
|
||||
}
|
||||
|
||||
export const STATUS_COLORS = {
|
||||
connected: '#22c55e',
|
||||
partial: '#eab308',
|
||||
'not-connected': '#ef4444',
|
||||
deprecated: '#6b7280',
|
||||
}
|
||||
@@ -279,255 +279,7 @@ export function DevOpsPipelineSidebar({
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
// Re-export responsive version for backwards compatibility
|
||||
export { DevOpsPipelineSidebarResponsive } from './DevOpsPipelineSidebarResponsive'
|
||||
|
||||
export default DevOpsPipelineSidebar
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Responsive DevOps Pipeline Sidebar (Mobile FAB + Drawer)
|
||||
*
|
||||
* Extracted from DevOpsPipelineSidebar.tsx.
|
||||
* Desktop (xl+): Fixierte Sidebar rechts
|
||||
* Mobile/Tablet: Floating Action Button unten rechts, oeffnet Drawer
|
||||
*/
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import type {
|
||||
DevOpsPipelineSidebarResponsiveProps,
|
||||
PipelineLiveStatus,
|
||||
} from '@/types/infrastructure-modules'
|
||||
import { DEVOPS_PIPELINE_MODULES } from '@/types/infrastructure-modules'
|
||||
import { DevOpsPipelineSidebar } from './DevOpsPipelineSidebar'
|
||||
|
||||
// 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>
|
||||
)
|
||||
|
||||
// Inline ToolIcon (duplicated to avoid circular imports)
|
||||
const ToolIcon = ({ id }: { id: string }) => {
|
||||
const icons: Record<string, JSX.Element> = {
|
||||
'ci-cd': <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>,
|
||||
'tests': <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>,
|
||||
'sbom': <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>,
|
||||
'security': <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>,
|
||||
}
|
||||
return icons[id] || null
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function usePipelineLiveStatus(): PipelineLiveStatus | null {
|
||||
const [status, setStatus] = useState<PipelineLiveStatus | null>(null)
|
||||
useEffect(() => { /* placeholder for live status fetch */ }, [])
|
||||
return status
|
||||
}
|
||||
|
||||
export function DevOpsPipelineSidebarResponsive({
|
||||
currentTool,
|
||||
compact = false,
|
||||
className = '',
|
||||
fabPosition = 'bottom-right',
|
||||
}: DevOpsPipelineSidebarResponsiveProps) {
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false)
|
||||
const liveStatus = usePipelineLiveStatus()
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setIsMobileOpen(false)
|
||||
}
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [])
|
||||
|
||||
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'
|
||||
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 />
|
||||
{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>
|
||||
)}
|
||||
{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">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity" onClick={() => setIsMobileOpen(false)} />
|
||||
<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">
|
||||
<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>
|
||||
<div className="p-4 space-y-4 overflow-y-auto max-h-[calc(100vh-80px)]">
|
||||
<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>
|
||||
{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>
|
||||
<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">
|
||||
{[{e:'📝',l:'Code'},{e:'🏗️',l:'Build'},{e:'✅',l:'Test'},{e:'🚀',l:'Deploy'}].map((s,i,a)=>(
|
||||
<span key={s.l} className="flex items-center gap-1.5">
|
||||
<span className="flex flex-col items-center"><span className="text-2xl">{s.e}</span><span className="text-xs text-slate-500 mt-1">{s.l}</span></span>
|
||||
{i<a.length-1 && <span className="text-slate-400">→</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<button onClick={() => { 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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type { GridZone, LayoutDividers } from '@/components/grid-editor/types'
|
||||
import { GridToolbar } from '@/components/grid-editor/GridToolbar'
|
||||
import { GridTable } from '@/components/grid-editor/GridTable'
|
||||
import { ImageLayoutEditor } from '@/components/grid-editor/ImageLayoutEditor'
|
||||
import { ReviewStatsBar } from './StepGridReviewStats'
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
@@ -236,108 +237,29 @@ export function StepGridReview({ sessionId, onNext, saveRef }: StepGridReviewPro
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Review Stats Bar */}
|
||||
<div className="flex items-center gap-4 text-xs flex-wrap">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{grid.summary.total_zones} Zone(n), {grid.summary.total_columns} Spalten,{' '}
|
||||
{grid.summary.total_rows} Zeilen, {grid.summary.total_cells} Zellen
|
||||
</span>
|
||||
{grid.dictionary_detection?.is_dictionary && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
|
||||
Woerterbuch ({Math.round(grid.dictionary_detection.confidence * 100)}%)
|
||||
</span>
|
||||
)}
|
||||
{grid.page_number?.text && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-600">
|
||||
S. {grid.page_number.number ?? grid.page_number.text}
|
||||
</span>
|
||||
)}
|
||||
{lowConfCells.length > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800">
|
||||
{lowConfCells.length} niedrige Konfidenz
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
{acceptedRows.size}/{totalRows} Zeilen akzeptiert
|
||||
</span>
|
||||
{acceptedRows.size < totalRows && (
|
||||
<button
|
||||
onClick={acceptAllRows}
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-700 dark:hover:text-teal-300"
|
||||
>
|
||||
Alle akzeptieren
|
||||
</button>
|
||||
)}
|
||||
{/* OCR Quality Steps (A/B Testing) */}
|
||||
<span className="text-gray-400 dark:text-gray-500">|</span>
|
||||
<label className="flex items-center gap-1 cursor-pointer" title="Step 3: CLAHE + Bilateral-Filter Enhancement">
|
||||
<input type="checkbox" checked={ocrEnhance} onChange={(e) => setOcrEnhance(e.target.checked)} className="rounded w-3 h-3" />
|
||||
<span className="text-gray-500 dark:text-gray-400">CLAHE</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1" title="Step 2: Max Spaltenanzahl (0=unbegrenzt)">
|
||||
<span className="text-gray-500 dark:text-gray-400">MaxCol:</span>
|
||||
<select value={ocrMaxCols} onChange={(e) => setOcrMaxCols(Number(e.target.value))} className="px-1 py-0.5 text-xs rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
<option value={0}>off</option>
|
||||
<option value={2}>2</option>
|
||||
<option value={3}>3</option>
|
||||
<option value={4}>4</option>
|
||||
<option value={5}>5</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-1" title="Step 1: Min OCR Confidence (0=auto)">
|
||||
<span className="text-gray-500 dark:text-gray-400">MinConf:</span>
|
||||
<select value={ocrMinConf} onChange={(e) => setOcrMinConf(Number(e.target.value))} className="px-1 py-0.5 text-xs rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
<option value={0}>auto</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={30}>30</option>
|
||||
<option value={40}>40</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={60}>60</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span className="text-gray-400 dark:text-gray-500">|</span>
|
||||
<label className="flex items-center gap-1 cursor-pointer" title="Step 4: Vision-LLM Fusion — Qwen2.5-VL korrigiert OCR anhand des Bildes">
|
||||
<input type="checkbox" checked={visionFusion} onChange={(e) => setVisionFusion(e.target.checked)} className="rounded w-3 h-3 accent-orange-500" />
|
||||
<span className={`${visionFusion ? 'text-orange-500 dark:text-orange-400 font-medium' : 'text-gray-500 dark:text-gray-400'}`}>Vision-LLM</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1" title="Dokumenttyp fuer Vision-LLM Prompt">
|
||||
<span className="text-gray-500 dark:text-gray-400">Typ:</span>
|
||||
<select value={documentCategory} onChange={(e) => setDocumentCategory(e.target.value)} className="px-1 py-0.5 text-xs rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
<option value="vokabelseite">Vokabelseite</option>
|
||||
<option value="woerterbuch">Woerterbuch</option>
|
||||
<option value="arbeitsblatt">Arbeitsblatt</option>
|
||||
<option value="buchseite">Buchseite</option>
|
||||
<option value="sonstiges">Sonstiges</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const n = autoCorrectColumnPatterns()
|
||||
if (n === 0) alert('Keine Muster-Korrekturen gefunden.')
|
||||
else alert(`${n} Zelle(n) korrigiert (Muster-Vervollstaendigung).`)
|
||||
}}
|
||||
className="px-2.5 py-1 rounded text-xs border border-purple-200 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-900/40 transition-colors"
|
||||
title="Erkennt Muster wie p.70, p.71 und vervollstaendigt partielle Eintraege wie .65 zu p.65"
|
||||
>
|
||||
Auto-Korrektur
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowImage(!showImage)}
|
||||
className={`px-2.5 py-1 rounded text-xs border transition-colors ${
|
||||
showImage
|
||||
? 'bg-teal-50 dark:bg-teal-900/30 border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300'
|
||||
: 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{showImage ? 'Bild ausblenden' : 'Bild einblenden'}
|
||||
</button>
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
{grid.duration_seconds.toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ReviewStatsBar
|
||||
summary={grid.summary}
|
||||
dictionaryDetection={grid.dictionary_detection}
|
||||
pageNumber={grid.page_number}
|
||||
lowConfCount={lowConfCells.length}
|
||||
acceptedCount={acceptedRows.size}
|
||||
totalRows={totalRows}
|
||||
ocrEnhance={ocrEnhance}
|
||||
ocrMaxCols={ocrMaxCols}
|
||||
ocrMinConf={ocrMinConf}
|
||||
visionFusion={visionFusion}
|
||||
documentCategory={documentCategory}
|
||||
durationSeconds={grid.duration_seconds}
|
||||
showImage={showImage}
|
||||
onOcrEnhanceChange={setOcrEnhance}
|
||||
onOcrMaxColsChange={setOcrMaxCols}
|
||||
onOcrMinConfChange={setOcrMinConf}
|
||||
onVisionFusionChange={setVisionFusion}
|
||||
onDocumentCategoryChange={setDocumentCategory}
|
||||
onAcceptAll={acceptAllRows}
|
||||
onAutoCorrect={autoCorrectColumnPatterns}
|
||||
onToggleImage={() => setShowImage(!showImage)}
|
||||
/>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2">
|
||||
|
||||
180
admin-lehrer/components/ocr-pipeline/StepGridReviewStats.tsx
Normal file
180
admin-lehrer/components/ocr-pipeline/StepGridReviewStats.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* StepGridReview Stats Bar & OCR Quality Controls
|
||||
*
|
||||
* Extracted from StepGridReview.tsx to stay under 500 LOC.
|
||||
*/
|
||||
|
||||
import type { GridZone } from '@/components/grid-editor/types'
|
||||
|
||||
interface GridSummary {
|
||||
total_zones: number
|
||||
total_columns: number
|
||||
total_rows: number
|
||||
total_cells: number
|
||||
}
|
||||
|
||||
interface DictionaryDetection {
|
||||
is_dictionary: boolean
|
||||
confidence: number
|
||||
}
|
||||
|
||||
interface PageNumber {
|
||||
text?: string
|
||||
number?: number | null
|
||||
}
|
||||
|
||||
interface ReviewStatsBarProps {
|
||||
summary: GridSummary
|
||||
dictionaryDetection?: DictionaryDetection | null
|
||||
pageNumber?: PageNumber | null
|
||||
lowConfCount: number
|
||||
acceptedCount: number
|
||||
totalRows: number
|
||||
ocrEnhance: boolean
|
||||
ocrMaxCols: number
|
||||
ocrMinConf: number
|
||||
visionFusion: boolean
|
||||
documentCategory: string
|
||||
durationSeconds: number
|
||||
showImage: boolean
|
||||
onOcrEnhanceChange: (v: boolean) => void
|
||||
onOcrMaxColsChange: (v: number) => void
|
||||
onOcrMinConfChange: (v: number) => void
|
||||
onVisionFusionChange: (v: boolean) => void
|
||||
onDocumentCategoryChange: (v: string) => void
|
||||
onAcceptAll: () => void
|
||||
onAutoCorrect: () => number
|
||||
onToggleImage: () => void
|
||||
}
|
||||
|
||||
export function ReviewStatsBar({
|
||||
summary,
|
||||
dictionaryDetection,
|
||||
pageNumber,
|
||||
lowConfCount,
|
||||
acceptedCount,
|
||||
totalRows,
|
||||
ocrEnhance,
|
||||
ocrMaxCols,
|
||||
ocrMinConf,
|
||||
visionFusion,
|
||||
documentCategory,
|
||||
durationSeconds,
|
||||
showImage,
|
||||
onOcrEnhanceChange,
|
||||
onOcrMaxColsChange,
|
||||
onOcrMinConfChange,
|
||||
onVisionFusionChange,
|
||||
onDocumentCategoryChange,
|
||||
onAcceptAll,
|
||||
onAutoCorrect,
|
||||
onToggleImage,
|
||||
}: ReviewStatsBarProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 text-xs flex-wrap">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{summary.total_zones} Zone(n), {summary.total_columns} Spalten,{' '}
|
||||
{summary.total_rows} Zeilen, {summary.total_cells} Zellen
|
||||
</span>
|
||||
{dictionaryDetection?.is_dictionary && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
|
||||
Woerterbuch ({Math.round(dictionaryDetection.confidence * 100)}%)
|
||||
</span>
|
||||
)}
|
||||
{pageNumber?.text && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-600">
|
||||
S. {pageNumber.number ?? pageNumber.text}
|
||||
</span>
|
||||
)}
|
||||
{lowConfCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800">
|
||||
{lowConfCount} niedrige Konfidenz
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
{acceptedCount}/{totalRows} Zeilen akzeptiert
|
||||
</span>
|
||||
{acceptedCount < totalRows && (
|
||||
<button
|
||||
onClick={onAcceptAll}
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-700 dark:hover:text-teal-300"
|
||||
>
|
||||
Alle akzeptieren
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* OCR Quality Steps */}
|
||||
<span className="text-gray-400 dark:text-gray-500">|</span>
|
||||
<label className="flex items-center gap-1 cursor-pointer" title="Step 3: CLAHE + Bilateral-Filter Enhancement">
|
||||
<input type="checkbox" checked={ocrEnhance} onChange={(e) => onOcrEnhanceChange(e.target.checked)} className="rounded w-3 h-3" />
|
||||
<span className="text-gray-500 dark:text-gray-400">CLAHE</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1" title="Step 2: Max Spaltenanzahl (0=unbegrenzt)">
|
||||
<span className="text-gray-500 dark:text-gray-400">MaxCol:</span>
|
||||
<select value={ocrMaxCols} onChange={(e) => onOcrMaxColsChange(Number(e.target.value))} className="px-1 py-0.5 text-xs rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
<option value={0}>off</option>
|
||||
<option value={2}>2</option>
|
||||
<option value={3}>3</option>
|
||||
<option value={4}>4</option>
|
||||
<option value={5}>5</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-1" title="Step 1: Min OCR Confidence (0=auto)">
|
||||
<span className="text-gray-500 dark:text-gray-400">MinConf:</span>
|
||||
<select value={ocrMinConf} onChange={(e) => onOcrMinConfChange(Number(e.target.value))} className="px-1 py-0.5 text-xs rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
<option value={0}>auto</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={30}>30</option>
|
||||
<option value={40}>40</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={60}>60</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span className="text-gray-400 dark:text-gray-500">|</span>
|
||||
<label className="flex items-center gap-1 cursor-pointer" title="Step 4: Vision-LLM Fusion">
|
||||
<input type="checkbox" checked={visionFusion} onChange={(e) => onVisionFusionChange(e.target.checked)} className="rounded w-3 h-3 accent-orange-500" />
|
||||
<span className={`${visionFusion ? 'text-orange-500 dark:text-orange-400 font-medium' : 'text-gray-500 dark:text-gray-400'}`}>Vision-LLM</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1" title="Dokumenttyp fuer Vision-LLM Prompt">
|
||||
<span className="text-gray-500 dark:text-gray-400">Typ:</span>
|
||||
<select value={documentCategory} onChange={(e) => onDocumentCategoryChange(e.target.value)} className="px-1 py-0.5 text-xs rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
<option value="vokabelseite">Vokabelseite</option>
|
||||
<option value="woerterbuch">Woerterbuch</option>
|
||||
<option value="arbeitsblatt">Arbeitsblatt</option>
|
||||
<option value="buchseite">Buchseite</option>
|
||||
<option value="sonstiges">Sonstiges</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const n = onAutoCorrect()
|
||||
if (n === 0) alert('Keine Muster-Korrekturen gefunden.')
|
||||
else alert(`${n} Zelle(n) korrigiert (Muster-Vervollstaendigung).`)
|
||||
}}
|
||||
className="px-2.5 py-1 rounded text-xs border border-purple-200 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-900/40 transition-colors"
|
||||
title="Erkennt Muster wie p.70, p.71 und vervollstaendigt partielle Eintraege wie .65 zu p.65"
|
||||
>
|
||||
Auto-Korrektur
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleImage}
|
||||
className={`px-2.5 py-1 rounded text-xs border transition-colors ${
|
||||
showImage
|
||||
? 'bg-teal-50 dark:bg-teal-900/30 border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300'
|
||||
: 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{showImage ? 'Bild ausblenden' : 'Bild einblenden'}
|
||||
</button>
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
{durationSeconds.toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -474,76 +474,5 @@ export function GridOverlay({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GridStats Component
|
||||
*/
|
||||
interface GridStatsProps {
|
||||
stats: GridData['stats']
|
||||
deskewAngle?: number
|
||||
source?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GridStats({ stats, deskewAngle, source, className }: GridStatsProps) {
|
||||
const coveragePercent = Math.round(stats.coverage * 100)
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-3', className)}>
|
||||
<div className="px-3 py-1.5 bg-green-50 text-green-700 rounded-lg text-sm font-medium">
|
||||
Erkannt: {stats.recognized}
|
||||
</div>
|
||||
{(stats.manual ?? 0) > 0 && (
|
||||
<div className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg text-sm font-medium">
|
||||
Manuell: {stats.manual}
|
||||
</div>
|
||||
)}
|
||||
{stats.problematic > 0 && (
|
||||
<div className="px-3 py-1.5 bg-orange-50 text-orange-700 rounded-lg text-sm font-medium">
|
||||
Problematisch: {stats.problematic}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 py-1.5 bg-slate-50 text-slate-600 rounded-lg text-sm font-medium">
|
||||
Leer: {stats.empty}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 bg-indigo-50 text-indigo-700 rounded-lg text-sm font-medium">
|
||||
Abdeckung: {coveragePercent}%
|
||||
</div>
|
||||
{deskewAngle !== undefined && deskewAngle !== 0 && (
|
||||
<div className="px-3 py-1.5 bg-purple-50 text-purple-700 rounded-lg text-sm font-medium">
|
||||
Begradigt: {deskewAngle.toFixed(1)}
|
||||
</div>
|
||||
)}
|
||||
{source && (
|
||||
<div className="px-3 py-1.5 bg-cyan-50 text-cyan-700 rounded-lg text-sm font-medium">
|
||||
Quelle: {source === 'tesseract+grid_service' ? 'Tesseract' : 'Vision LLM'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Legend Component for GridOverlay
|
||||
*/
|
||||
export function GridLegend({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-4 text-sm', className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-green-500 bg-green-500/20" />
|
||||
<span className="text-slate-600">Erkannt</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-orange-500 bg-orange-500/30" />
|
||||
<span className="text-slate-600">Problematisch</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-blue-500 bg-blue-500/20" />
|
||||
<span className="text-slate-600">Manuell korrigiert</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-slate-300 bg-transparent" />
|
||||
<span className="text-slate-600">Leer</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Re-export widgets from sibling file for backwards compatibility
|
||||
export { GridStats, GridLegend } from './GridOverlayWidgets'
|
||||
|
||||
84
admin-lehrer/components/ocr/GridOverlayWidgets.tsx
Normal file
84
admin-lehrer/components/ocr/GridOverlayWidgets.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* GridOverlay Widgets - GridStats and GridLegend
|
||||
*
|
||||
* Extracted from GridOverlay.tsx to keep each file under 500 LOC.
|
||||
*/
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { GridData } from './GridOverlay'
|
||||
|
||||
/**
|
||||
* GridStats Component
|
||||
*/
|
||||
interface GridStatsProps {
|
||||
stats: GridData['stats']
|
||||
deskewAngle?: number
|
||||
source?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GridStats({ stats, deskewAngle, source, className }: GridStatsProps) {
|
||||
const coveragePercent = Math.round(stats.coverage * 100)
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-3', className)}>
|
||||
<div className="px-3 py-1.5 bg-green-50 text-green-700 rounded-lg text-sm font-medium">
|
||||
Erkannt: {stats.recognized}
|
||||
</div>
|
||||
{(stats.manual ?? 0) > 0 && (
|
||||
<div className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg text-sm font-medium">
|
||||
Manuell: {stats.manual}
|
||||
</div>
|
||||
)}
|
||||
{stats.problematic > 0 && (
|
||||
<div className="px-3 py-1.5 bg-orange-50 text-orange-700 rounded-lg text-sm font-medium">
|
||||
Problematisch: {stats.problematic}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 py-1.5 bg-slate-50 text-slate-600 rounded-lg text-sm font-medium">
|
||||
Leer: {stats.empty}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 bg-indigo-50 text-indigo-700 rounded-lg text-sm font-medium">
|
||||
Abdeckung: {coveragePercent}%
|
||||
</div>
|
||||
{deskewAngle !== undefined && deskewAngle !== 0 && (
|
||||
<div className="px-3 py-1.5 bg-purple-50 text-purple-700 rounded-lg text-sm font-medium">
|
||||
Begradigt: {deskewAngle.toFixed(1)}
|
||||
</div>
|
||||
)}
|
||||
{source && (
|
||||
<div className="px-3 py-1.5 bg-cyan-50 text-cyan-700 rounded-lg text-sm font-medium">
|
||||
Quelle: {source === 'tesseract+grid_service' ? 'Tesseract' : 'Vision LLM'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Legend Component for GridOverlay
|
||||
*/
|
||||
export function GridLegend({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-4 text-sm', className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-green-500 bg-green-500/20" />
|
||||
<span className="text-slate-600">Erkannt</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-orange-500 bg-orange-500/30" />
|
||||
<span className="text-slate-600">Problematisch</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-blue-500 bg-blue-500/20" />
|
||||
<span className="text-slate-600">Manuell korrigiert</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-slate-300 bg-transparent" />
|
||||
<span className="text-slate-600">Leer</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user