Files
breakpilot-compliance/admin-compliance/app/sdk/wiki/page.tsx
Benjamin Admin 1c59996f32 feat(wiki): Enrich wiki with DACH court decisions and 18 new articles
- Update all 10 existing articles with real source URLs (EuGH, BAG, DSK, BfDI)
- Add 18 new articles covering:
  - EuGH C-184/20 (wide interpretation Art. 9)
  - EuGH C-667/21 (cumulative legal basis)
  - EuGH C-34/21 (§26 BDSG unconstitutional)
  - EuGH C-634/21 (SCHUFA scoring)
  - EuGH C-582/14 (IP addresses as personal data)
  - Biometric data, indirect Art. 9 data in daily practice
  - Retention periods overview
  - Video surveillance and GPS tracking at workplace
  - Communication data (email/chat, Fernmeldegeheimnis)
  - Financial data, PCI DSS, SEPA
  - Minors (Art. 8 DSGVO)
  - Austria DSG specifics, Switzerland revDSG
  - AI training data and GDPR/AI Act
  - "Forced" special categories
- Add 3 new categories (EuGH-Leiturteile, Aufbewahrungsfristen, DACH-Besonderheiten)
- Add code block rendering to markdown renderer
- Add Clock, Globe, Gavel icons to icon map
- Total: 11 categories, 28 articles, all with verified source URLs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:43:23 +01:00

461 lines
18 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import {
Search, BookOpen, AlertTriangle, Shield, Scale, Handshake,
Briefcase, MessageCircle, Building2, Database, ChevronRight,
ArrowLeft, ExternalLink, Tag, Clock, Globe, Gavel,
} from 'lucide-react'
import type { WikiCategory, WikiArticle, WikiSearchResult } from '@/lib/sdk/types'
// =============================================================================
// SIMPLE MARKDOWN RENDERER
// =============================================================================
function renderMarkdown(md: string): string {
let html = md
// Escape HTML
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Code blocks (``` ... ```)
html = html.replace(
/^```[\w]*\n([\s\S]*?)^```$/gm,
(_match, code: string) => `<pre class="bg-gray-50 border rounded p-3 my-3 text-xs font-mono overflow-x-auto whitespace-pre">${code.trimEnd()}</pre>`
)
// Tables (must be before other block elements)
html = html.replace(
/^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)*)/gm,
(_match, header: string, _sep: string, body: string) => {
const ths = header.split('|').filter((c: string) => c.trim()).map((c: string) => `<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase border-b">${c.trim()}</th>`).join('')
const rows = body.trim().split('\n').map((row: string) => {
const tds = row.split('|').filter((c: string) => c.trim()).map((c: string) => `<td class="px-3 py-2 text-sm text-gray-700 border-b border-gray-100">${c.trim()}</td>`).join('')
return `<tr>${tds}</tr>`
}).join('')
return `<table class="w-full border-collapse my-3 text-sm"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`
}
)
// Headers
html = html.replace(/^### (.+)$/gm, '<h4 class="text-sm font-semibold text-gray-800 mt-4 mb-2">$1</h4>')
html = html.replace(/^## (.+)$/gm, '<h3 class="text-base font-semibold text-gray-900 mt-5 mb-2">$1</h3>')
// Bold
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Unordered lists
html = html.replace(/^- (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-disc">$1</li>')
html = html.replace(/((?:<li[^>]*>.*<\/li>\n?)+)/g, '<ul class="my-2 space-y-1">$1</ul>')
// Paragraphs (lines that aren't already HTML)
html = html.replace(/^(?!<[hultdp]|$)(.+)$/gm, '<p class="text-sm text-gray-700 my-2">$1</p>')
return html
}
// =============================================================================
// ICON MAP
// =============================================================================
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Database,
Shield,
AlertTriangle,
Scale,
Handshake,
Briefcase,
MessageCircle,
Building2,
Clock,
Globe,
Gavel,
}
function CategoryIcon({ icon, className }: { icon: string; className?: string }) {
const Icon = ICON_MAP[icon] || BookOpen
return <Icon className={className} />
}
// =============================================================================
// RELEVANCE BADGE
// =============================================================================
function RelevanceBadge({ relevance }: { relevance: string }) {
const config = {
critical: { bg: 'bg-red-100 text-red-800', label: 'Kritisch' },
important: { bg: 'bg-amber-100 text-amber-800', label: 'Wichtig' },
info: { bg: 'bg-blue-100 text-blue-800', label: 'Info' },
}[relevance] || { bg: 'bg-gray-100 text-gray-600', label: relevance }
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>
{config.label}
</span>
)
}
// =============================================================================
// WIKI PAGE
// =============================================================================
export default function WikiPage() {
const [categories, setCategories] = useState<WikiCategory[]>([])
const [articles, setArticles] = useState<WikiArticle[]>([])
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [selectedArticle, setSelectedArticle] = useState<WikiArticle | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<WikiSearchResult[] | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Load categories on mount
useEffect(() => {
async function load() {
try {
const res = await fetch('/api/sdk/v1/wiki?endpoint=categories')
if (!res.ok) throw new Error('Failed to load categories')
const data = await res.json()
const cats: WikiCategory[] = (data.categories || []).map((c: Record<string, unknown>) => ({
id: c.id,
name: c.name,
description: c.description || '',
icon: c.icon || '',
sortOrder: c.sort_order ?? 0,
articleCount: c.article_count ?? 0,
}))
setCategories(cats)
// Load all articles
const artRes = await fetch('/api/sdk/v1/wiki?endpoint=articles')
if (artRes.ok) {
const artData = await artRes.json()
setArticles((artData.articles || []).map((a: Record<string, unknown>) => ({
id: a.id,
categoryId: a.category_id,
categoryName: a.category_name,
title: a.title,
summary: a.summary,
content: a.content,
legalRefs: a.legal_refs || [],
tags: a.tags || [],
relevance: a.relevance || 'info',
sourceUrls: a.source_urls || [],
version: a.version || 1,
updatedAt: a.updated_at || '',
})))
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}
load()
}, [])
// Search handler
const handleSearch = useCallback(async (query: string) => {
setSearchQuery(query)
if (query.length < 2) {
setSearchResults(null)
return
}
try {
const res = await fetch(`/api/sdk/v1/wiki?endpoint=search&q=${encodeURIComponent(query)}`)
if (res.ok) {
const data = await res.json()
setSearchResults((data.results || []).map((r: Record<string, unknown>) => ({
id: r.id,
title: r.title,
summary: r.summary,
categoryName: r.category_name,
relevance: r.relevance || 'info',
highlight: r.highlight || '',
})))
}
} catch {
// silently fail search
}
}, [])
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
if (searchQuery.length >= 2) handleSearch(searchQuery)
}, 300)
return () => clearTimeout(timer)
}, [searchQuery, handleSearch])
// Filtered articles for selected category
const filteredArticles = useMemo(() => {
if (!selectedCategory) return articles
return articles.filter(a => a.categoryId === selectedCategory)
}, [articles, selectedCategory])
// Select article from search result
const selectFromSearch = (id: string) => {
const article = articles.find(a => a.id === id)
if (article) {
setSelectedArticle(article)
setSelectedCategory(article.categoryId)
setSearchResults(null)
setSearchQuery('')
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-purple-600 border-t-transparent" />
</div>
)
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-800 text-sm">
{error}
</div>
</div>
)
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b border-gray-200 bg-white px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<BookOpen className="w-6 h-6 text-purple-600" />
<div>
<h1 className="text-lg font-semibold text-gray-900">Compliance Wiki</h1>
<p className="text-xs text-gray-500">Interne Wissensbasis {articles.length} Artikel in {categories.length} Kategorien</p>
</div>
</div>
{/* Search */}
<div className="relative w-80">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Wiki durchsuchen..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
{/* Search results dropdown */}
{searchResults && searchResults.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-80 overflow-y-auto">
{searchResults.map(r => (
<button
key={r.id}
onClick={() => selectFromSearch(r.id)}
className="w-full text-left px-4 py-3 hover:bg-gray-50 border-b border-gray-100 last:border-0"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-gray-900">{r.title}</span>
<RelevanceBadge relevance={r.relevance} />
</div>
<p className="text-xs text-gray-500">{r.categoryName}</p>
{r.highlight && (
<p className="text-xs text-gray-600 mt-1 line-clamp-2">{r.highlight.replace(/\*\*/g, '')}</p>
)}
</button>
))}
</div>
)}
{searchResults && searchResults.length === 0 && searchQuery.length >= 2 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white rounded-lg shadow-lg border border-gray-200 z-50 px-4 py-3 text-sm text-gray-500">
Keine Ergebnisse fuer &quot;{searchQuery}&quot;
</div>
)}
</div>
</div>
</div>
{/* Content: Two columns */}
<div className="flex flex-1 overflow-hidden">
{/* Left: Categories */}
<div className="w-72 border-r border-gray-200 bg-gray-50 overflow-y-auto flex-shrink-0">
<div className="p-3">
<button
onClick={() => { setSelectedCategory(null); setSelectedArticle(null) }}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${
!selectedCategory ? 'bg-purple-100 text-purple-900 font-medium' : 'text-gray-700 hover:bg-gray-100'
}`}
>
<BookOpen className="w-4 h-4" />
<span>Alle Artikel</span>
<span className="ml-auto text-xs text-gray-400">{articles.length}</span>
</button>
<div className="mt-2 space-y-0.5">
{categories.map(cat => (
<button
key={cat.id}
onClick={() => { setSelectedCategory(cat.id); setSelectedArticle(null) }}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
selectedCategory === cat.id ? 'bg-purple-100 text-purple-900 font-medium' : 'text-gray-700 hover:bg-gray-100'
}`}
>
<CategoryIcon icon={cat.icon} className="w-4 h-4 flex-shrink-0" />
<span className="truncate text-left">{cat.name}</span>
<span className="ml-auto text-xs text-gray-400 flex-shrink-0">{cat.articleCount}</span>
</button>
))}
</div>
</div>
</div>
{/* Right: Article list or detail */}
<div className="flex-1 overflow-y-auto">
{selectedArticle ? (
/* Article detail view */
<div className="max-w-3xl mx-auto p-6">
<button
onClick={() => setSelectedArticle(null)}
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4"
>
<ArrowLeft className="w-4 h-4" />
Zurueck zur Uebersicht
</button>
<div className="flex items-start gap-3 mb-4">
<RelevanceBadge relevance={selectedArticle.relevance} />
<span className="text-xs text-gray-400">
{selectedArticle.categoryName} &middot; v{selectedArticle.version}
</span>
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">
{selectedArticle.title}
</h2>
<p className="text-sm text-gray-600 mb-6">{selectedArticle.summary}</p>
{/* Content (rendered markdown) */}
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: renderMarkdown(selectedArticle.content) }}
/>
{/* Legal References */}
{selectedArticle.legalRefs.length > 0 && (
<div className="mt-6 pt-4 border-t border-gray-200">
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Rechtsreferenzen</h4>
<div className="flex flex-wrap gap-1.5">
{selectedArticle.legalRefs.map(ref => (
<span key={ref} className="inline-flex items-center gap-1 px-2 py-1 bg-purple-50 text-purple-700 rounded text-xs">
<Scale className="w-3 h-3" />
{ref}
</span>
))}
</div>
</div>
)}
{/* Tags */}
{selectedArticle.tags.length > 0 && (
<div className="mt-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Tags</h4>
<div className="flex flex-wrap gap-1.5">
{selectedArticle.tags.map(tag => (
<span key={tag} className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">
<Tag className="w-3 h-3" />
{tag}
</span>
))}
</div>
</div>
)}
{/* Source URLs */}
{selectedArticle.sourceUrls.length > 0 && (
<div className="mt-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Quellen</h4>
<div className="space-y-1">
{selectedArticle.sourceUrls.map(url => (
<div key={url} className="flex items-center gap-1 text-xs text-blue-600">
<ExternalLink className="w-3 h-3" />
{url.startsWith('http') ? (
<a href={url} target="_blank" rel="noopener noreferrer" className="hover:underline">{url}</a>
) : (
<span>{url}</span>
)}
</div>
))}
</div>
</div>
)}
</div>
) : (
/* Article list view */
<div className="p-6">
<h2 className="text-base font-semibold text-gray-900 mb-4">
{selectedCategory
? categories.find(c => c.id === selectedCategory)?.name || 'Artikel'
: 'Alle Artikel'
}
<span className="ml-2 text-sm font-normal text-gray-400">
({filteredArticles.length})
</span>
</h2>
{selectedCategory && (
<p className="text-sm text-gray-500 mb-4">
{categories.find(c => c.id === selectedCategory)?.description}
</p>
)}
<div className="space-y-3">
{filteredArticles.map(article => (
<button
key={article.id}
onClick={() => setSelectedArticle(article)}
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:shadow-sm transition-all group"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<RelevanceBadge relevance={article.relevance} />
{!selectedCategory && (
<span className="text-xs text-gray-400">{article.categoryName}</span>
)}
</div>
<h3 className="text-sm font-medium text-gray-900 group-hover:text-purple-700">
{article.title}
</h3>
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{article.summary}</p>
{article.legalRefs.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{article.legalRefs.slice(0, 3).map(ref => (
<span key={ref} className="text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">
{ref}
</span>
))}
{article.legalRefs.length > 3 && (
<span className="text-xs text-gray-400">+{article.legalRefs.length - 3}</span>
)}
</div>
)}
</div>
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-purple-500 flex-shrink-0 mt-1" />
</div>
</button>
))}
{filteredArticles.length === 0 && (
<div className="text-center py-12 text-gray-400 text-sm">
Keine Artikel in dieser Kategorie.
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
)
}