Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
675 lines
24 KiB
TypeScript
675 lines
24 KiB
TypeScript
'use client'
|
||
|
||
/**
|
||
* DSFA Document Manager
|
||
*
|
||
* Manages DSFA-related sources and documents for the RAG pipeline.
|
||
* Features:
|
||
* - View all registered DSFA sources with license info
|
||
* - Upload new documents
|
||
* - Trigger re-indexing
|
||
* - View corpus statistics
|
||
*/
|
||
|
||
import { useState, useEffect } from 'react'
|
||
import Link from 'next/link'
|
||
import {
|
||
ArrowLeft,
|
||
RefreshCw,
|
||
Upload,
|
||
FileText,
|
||
Database,
|
||
Scale,
|
||
ExternalLink,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Search,
|
||
Filter,
|
||
CheckCircle,
|
||
Clock,
|
||
AlertCircle,
|
||
BookOpen
|
||
} from 'lucide-react'
|
||
import {
|
||
DSFASource,
|
||
DSFACorpusStats,
|
||
DSFASourceStats,
|
||
DSFALicenseCode,
|
||
DSFA_LICENSE_LABELS,
|
||
DSFA_DOCUMENT_TYPE_LABELS
|
||
} from '@/lib/sdk/types'
|
||
|
||
// ============================================================================
|
||
// TYPES
|
||
// ============================================================================
|
||
|
||
interface APIError {
|
||
message: string
|
||
status?: number
|
||
}
|
||
|
||
// ============================================================================
|
||
// API FUNCTIONS
|
||
// ============================================================================
|
||
|
||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||
|
||
async function fetchSources(): Promise<DSFASource[]> {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources`)
|
||
if (!response.ok) throw new Error('Failed to fetch sources')
|
||
return await response.json()
|
||
} catch {
|
||
// Return mock data for demo
|
||
return MOCK_SOURCES
|
||
}
|
||
}
|
||
|
||
async function fetchStats(): Promise<DSFACorpusStats> {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/stats`)
|
||
if (!response.ok) throw new Error('Failed to fetch stats')
|
||
return await response.json()
|
||
} catch {
|
||
return MOCK_STATS
|
||
}
|
||
}
|
||
|
||
async function initializeCorpus(): Promise<{ sources_registered: number }> {
|
||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/init`, {
|
||
method: 'POST',
|
||
})
|
||
if (!response.ok) throw new Error('Failed to initialize corpus')
|
||
return await response.json()
|
||
}
|
||
|
||
async function triggerIngestion(sourceCode: string): Promise<void> {
|
||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources/${sourceCode}/ingest`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({}),
|
||
})
|
||
if (!response.ok) throw new Error('Failed to trigger ingestion')
|
||
}
|
||
|
||
// ============================================================================
|
||
// MOCK DATA
|
||
// ============================================================================
|
||
|
||
const MOCK_SOURCES: DSFASource[] = [
|
||
{
|
||
id: '1',
|
||
sourceCode: 'WP248',
|
||
name: 'WP248 rev.01 - Leitlinien zur DSFA',
|
||
fullName: 'Leitlinien zur Datenschutz-Folgenabschaetzung',
|
||
organization: 'Artikel-29-Datenschutzgruppe / EDPB',
|
||
sourceUrl: 'https://ec.europa.eu/newsroom/article29/items/611236/en',
|
||
licenseCode: 'EDPB-LICENSE',
|
||
licenseName: 'EDPB Document License',
|
||
attributionRequired: true,
|
||
attributionText: 'Quelle: WP248 rev.01, Artikel-29-Datenschutzgruppe (2017)',
|
||
documentType: 'guideline',
|
||
language: 'de',
|
||
},
|
||
{
|
||
id: '2',
|
||
sourceCode: 'DSK_KP5',
|
||
name: 'Kurzpapier Nr. 5 - DSFA nach Art. 35 DS-GVO',
|
||
organization: 'Datenschutzkonferenz (DSK)',
|
||
sourceUrl: 'https://www.datenschutzkonferenz-online.de/media/kp/dsk_kpnr_5.pdf',
|
||
licenseCode: 'DL-DE-BY-2.0',
|
||
licenseName: 'Datenlizenz DE – Namensnennung 2.0',
|
||
licenseUrl: 'https://www.govdata.de/dl-de/by-2-0',
|
||
attributionRequired: true,
|
||
attributionText: 'Quelle: DSK Kurzpapier Nr. 5 (Stand: 2018)',
|
||
documentType: 'guideline',
|
||
language: 'de',
|
||
},
|
||
{
|
||
id: '3',
|
||
sourceCode: 'BFDI_MUSS_PUBLIC',
|
||
name: 'BfDI DSFA-Liste (oeffentlicher Bereich)',
|
||
organization: 'BfDI',
|
||
sourceUrl: 'https://www.bfdi.bund.de',
|
||
licenseCode: 'DL-DE-ZERO-2.0',
|
||
licenseName: 'Datenlizenz DE – Zero 2.0',
|
||
attributionRequired: false,
|
||
attributionText: 'Quelle: BfDI, Liste gem. Art. 35 Abs. 4 DSGVO',
|
||
documentType: 'checklist',
|
||
language: 'de',
|
||
},
|
||
{
|
||
id: '4',
|
||
sourceCode: 'NI_MUSS_PRIVATE',
|
||
name: 'LfD NI DSFA-Liste (nicht-oeffentlich)',
|
||
organization: 'LfD Niedersachsen',
|
||
sourceUrl: 'https://www.lfd.niedersachsen.de/download/131098',
|
||
licenseCode: 'DL-DE-BY-2.0',
|
||
licenseName: 'Datenlizenz DE – Namensnennung 2.0',
|
||
attributionRequired: true,
|
||
attributionText: 'Quelle: LfD Niedersachsen, DSFA-Muss-Liste',
|
||
documentType: 'checklist',
|
||
language: 'de',
|
||
},
|
||
]
|
||
|
||
const MOCK_STATS: DSFACorpusStats = {
|
||
sources: [
|
||
{
|
||
sourceId: '1',
|
||
sourceCode: 'WP248',
|
||
name: 'WP248 rev.01',
|
||
organization: 'EDPB',
|
||
licenseCode: 'EDPB-LICENSE',
|
||
documentType: 'guideline',
|
||
documentCount: 1,
|
||
chunkCount: 50,
|
||
lastIndexedAt: '2026-02-09T10:00:00Z',
|
||
},
|
||
{
|
||
sourceId: '2',
|
||
sourceCode: 'DSK_KP5',
|
||
name: 'DSK Kurzpapier Nr. 5',
|
||
organization: 'DSK',
|
||
licenseCode: 'DL-DE-BY-2.0',
|
||
documentType: 'guideline',
|
||
documentCount: 1,
|
||
chunkCount: 35,
|
||
lastIndexedAt: '2026-02-09T10:00:00Z',
|
||
},
|
||
],
|
||
totalSources: 45,
|
||
totalDocuments: 45,
|
||
totalChunks: 850,
|
||
qdrantCollection: 'bp_dsfa_corpus',
|
||
qdrantPointsCount: 850,
|
||
qdrantStatus: 'green',
|
||
}
|
||
|
||
// ============================================================================
|
||
// COMPONENTS
|
||
// ============================================================================
|
||
|
||
function LicenseBadge({ licenseCode }: { licenseCode: DSFALicenseCode }) {
|
||
const colorMap: Record<DSFALicenseCode, string> = {
|
||
'DL-DE-BY-2.0': 'bg-blue-100 text-blue-700 border-blue-200',
|
||
'DL-DE-ZERO-2.0': 'bg-gray-100 text-gray-700 border-gray-200',
|
||
'CC-BY-4.0': 'bg-green-100 text-green-700 border-green-200',
|
||
'EDPB-LICENSE': 'bg-purple-100 text-purple-700 border-purple-200',
|
||
'PUBLIC_DOMAIN': 'bg-gray-100 text-gray-600 border-gray-200',
|
||
'PROPRIETARY': 'bg-amber-100 text-amber-700 border-amber-200',
|
||
}
|
||
|
||
return (
|
||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorMap[licenseCode] || 'bg-gray-100 text-gray-700 border-gray-200'}`}>
|
||
<Scale className="w-3 h-3" />
|
||
{DSFA_LICENSE_LABELS[licenseCode] || licenseCode}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function DocumentTypeBadge({ type }: { type?: string }) {
|
||
if (!type) return null
|
||
|
||
const colorMap: Record<string, string> = {
|
||
guideline: 'bg-indigo-100 text-indigo-700',
|
||
checklist: 'bg-emerald-100 text-emerald-700',
|
||
regulation: 'bg-red-100 text-red-700',
|
||
template: 'bg-orange-100 text-orange-700',
|
||
}
|
||
|
||
return (
|
||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs ${colorMap[type] || 'bg-gray-100 text-gray-700'}`}>
|
||
{DSFA_DOCUMENT_TYPE_LABELS[type as keyof typeof DSFA_DOCUMENT_TYPE_LABELS] || type}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function StatusIndicator({ status }: { status: string }) {
|
||
const statusConfig: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
|
||
green: { color: 'text-green-500', icon: <CheckCircle className="w-4 h-4" />, label: 'Aktiv' },
|
||
yellow: { color: 'text-yellow-500', icon: <Clock className="w-4 h-4" />, label: 'Ausstehend' },
|
||
red: { color: 'text-red-500', icon: <AlertCircle className="w-4 h-4" />, label: 'Fehler' },
|
||
}
|
||
|
||
const config = statusConfig[status] || statusConfig.yellow
|
||
|
||
return (
|
||
<span className={`inline-flex items-center gap-1 ${config.color}`}>
|
||
{config.icon}
|
||
<span className="text-sm">{config.label}</span>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function SourceCard({
|
||
source,
|
||
stats,
|
||
onIngest,
|
||
isIngesting
|
||
}: {
|
||
source: DSFASource
|
||
stats?: DSFASourceStats
|
||
onIngest: () => void
|
||
isIngesting: boolean
|
||
}) {
|
||
const [isExpanded, setIsExpanded] = useState(false)
|
||
|
||
return (
|
||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||
<div className="p-4">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<span className="font-mono text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||
{source.sourceCode}
|
||
</span>
|
||
<DocumentTypeBadge type={source.documentType} />
|
||
</div>
|
||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
|
||
{source.name}
|
||
</h3>
|
||
{source.organization && (
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||
{source.organization}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={() => setIsExpanded(!isExpanded)}
|
||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||
>
|
||
{isExpanded ? (
|
||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||
) : (
|
||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-4 mt-3">
|
||
<LicenseBadge licenseCode={source.licenseCode} />
|
||
{stats && (
|
||
<>
|
||
<span className="text-sm text-gray-500">
|
||
{stats.documentCount} Dok.
|
||
</span>
|
||
<span className="text-sm text-gray-500">
|
||
{stats.chunkCount} Chunks
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{source.attributionRequired && (
|
||
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 rounded text-xs text-amber-700 dark:text-amber-300">
|
||
<strong>Attribution:</strong> {source.attributionText}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{isExpanded && (
|
||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
|
||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||
{source.sourceUrl && (
|
||
<>
|
||
<dt className="text-gray-500">Quelle:</dt>
|
||
<dd>
|
||
<a
|
||
href={source.sourceUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-blue-600 hover:underline flex items-center gap-1"
|
||
>
|
||
Link <ExternalLink className="w-3 h-3" />
|
||
</a>
|
||
</dd>
|
||
</>
|
||
)}
|
||
{source.licenseUrl && (
|
||
<>
|
||
<dt className="text-gray-500">Lizenz-URL:</dt>
|
||
<dd>
|
||
<a
|
||
href={source.licenseUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-blue-600 hover:underline flex items-center gap-1"
|
||
>
|
||
{source.licenseName} <ExternalLink className="w-3 h-3" />
|
||
</a>
|
||
</dd>
|
||
</>
|
||
)}
|
||
<dt className="text-gray-500">Sprache:</dt>
|
||
<dd className="uppercase">{source.language}</dd>
|
||
{stats?.lastIndexedAt && (
|
||
<>
|
||
<dt className="text-gray-500">Zuletzt indexiert:</dt>
|
||
<dd>{new Date(stats.lastIndexedAt).toLocaleString('de-DE')}</dd>
|
||
</>
|
||
)}
|
||
</dl>
|
||
|
||
<div className="mt-4 flex gap-2">
|
||
<button
|
||
onClick={onIngest}
|
||
disabled={isIngesting}
|
||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
|
||
>
|
||
{isIngesting ? (
|
||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<RefreshCw className="w-4 h-4" />
|
||
)}
|
||
Neu indexieren
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StatsOverview({ stats }: { stats: DSFACorpusStats }) {
|
||
return (
|
||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||
<Database className="w-5 h-5" />
|
||
Corpus-Statistik
|
||
</h2>
|
||
<StatusIndicator status={stats.qdrantStatus} />
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div className="text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||
{stats.totalSources}
|
||
</p>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400">Quellen</p>
|
||
</div>
|
||
<div className="text-center p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg">
|
||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
|
||
{stats.totalDocuments}
|
||
</p>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400">Dokumente</p>
|
||
</div>
|
||
<div className="text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||
{stats.totalChunks.toLocaleString()}
|
||
</p>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400">Chunks</p>
|
||
</div>
|
||
<div className="text-center p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||
<p className="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||
{stats.qdrantPointsCount.toLocaleString()}
|
||
</p>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400">Vektoren</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||
<strong>Collection:</strong>{' '}
|
||
<code className="font-mono bg-gray-200 dark:bg-gray-700 px-1 rounded">
|
||
{stats.qdrantCollection}
|
||
</code>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ============================================================================
|
||
// MAIN PAGE
|
||
// ============================================================================
|
||
|
||
export default function DSFADocumentManagerPage() {
|
||
const [sources, setSources] = useState<DSFASource[]>([])
|
||
const [stats, setStats] = useState<DSFACorpusStats | null>(null)
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [filterType, setFilterType] = useState<string>('all')
|
||
const [ingestingSource, setIngestingSource] = useState<string | null>(null)
|
||
const [isInitializing, setIsInitializing] = useState(false)
|
||
|
||
useEffect(() => {
|
||
async function loadData() {
|
||
setIsLoading(true)
|
||
try {
|
||
const [sourcesData, statsData] = await Promise.all([
|
||
fetchSources(),
|
||
fetchStats(),
|
||
])
|
||
setSources(sourcesData)
|
||
setStats(statsData)
|
||
setError(null)
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to load data')
|
||
setSources(MOCK_SOURCES)
|
||
setStats(MOCK_STATS)
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
loadData()
|
||
}, [])
|
||
|
||
const handleInitialize = async () => {
|
||
setIsInitializing(true)
|
||
try {
|
||
await initializeCorpus()
|
||
// Reload data
|
||
const [sourcesData, statsData] = await Promise.all([
|
||
fetchSources(),
|
||
fetchStats(),
|
||
])
|
||
setSources(sourcesData)
|
||
setStats(statsData)
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to initialize')
|
||
} finally {
|
||
setIsInitializing(false)
|
||
}
|
||
}
|
||
|
||
const handleIngest = async (sourceCode: string) => {
|
||
setIngestingSource(sourceCode)
|
||
try {
|
||
await triggerIngestion(sourceCode)
|
||
// Reload stats
|
||
const statsData = await fetchStats()
|
||
setStats(statsData)
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to ingest')
|
||
} finally {
|
||
setIngestingSource(null)
|
||
}
|
||
}
|
||
|
||
// Filter sources
|
||
const filteredSources = sources.filter(source => {
|
||
const matchesSearch = searchQuery === '' ||
|
||
source.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
source.sourceCode.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
source.organization?.toLowerCase().includes(searchQuery.toLowerCase())
|
||
|
||
const matchesType = filterType === 'all' || source.documentType === filterType
|
||
|
||
return matchesSearch && matchesType
|
||
})
|
||
|
||
// Get stats by source code
|
||
const getStatsForSource = (sourceCode: string): DSFASourceStats | undefined => {
|
||
return stats?.sources.find(s => s.sourceCode === sourceCode)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-8">
|
||
<div className="max-w-6xl mx-auto">
|
||
{/* Header */}
|
||
<div className="mb-8">
|
||
<Link
|
||
href="/ai/rag-pipeline"
|
||
className="inline-flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mb-4"
|
||
>
|
||
<ArrowLeft className="w-4 h-4" />
|
||
Zurueck zur RAG-Pipeline
|
||
</Link>
|
||
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||
<BookOpen className="w-8 h-8 text-blue-600" />
|
||
DSFA-Quellen Manager
|
||
</h1>
|
||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||
Verwalten Sie DSFA-Guidance Dokumente mit vollstaendiger Lizenzattribution
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={handleInitialize}
|
||
disabled={isInitializing}
|
||
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
|
||
>
|
||
{isInitializing ? (
|
||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<Database className="w-4 h-4" />
|
||
)}
|
||
Initialisieren
|
||
</button>
|
||
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2">
|
||
<Upload className="w-4 h-4" />
|
||
Dokument hochladen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Error Banner */}
|
||
{error && (
|
||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded-xl">
|
||
<div className="flex items-center gap-3">
|
||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||
<span className="text-red-800 dark:text-red-200">{error}</span>
|
||
<button
|
||
onClick={() => setError(null)}
|
||
className="ml-auto text-red-600 hover:text-red-800"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Stats Overview */}
|
||
{stats && <StatsOverview stats={stats} />}
|
||
|
||
{/* Search & Filter */}
|
||
<div className="mt-6 flex gap-4">
|
||
<div className="flex-1 relative">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||
<input
|
||
type="text"
|
||
placeholder="Quellen durchsuchen..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800"
|
||
/>
|
||
</div>
|
||
<div className="relative">
|
||
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||
<select
|
||
value={filterType}
|
||
onChange={(e) => setFilterType(e.target.value)}
|
||
className="pl-9 pr-8 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 appearance-none"
|
||
>
|
||
<option value="all">Alle Typen</option>
|
||
<option value="guideline">Leitlinien</option>
|
||
<option value="checklist">Prueflisten</option>
|
||
<option value="regulation">Verordnungen</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sources List */}
|
||
<div className="mt-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||
Registrierte Quellen ({filteredSources.length})
|
||
</h2>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="flex items-center justify-center py-20">
|
||
<div className="text-center">
|
||
<div className="w-12 h-12 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin mx-auto mb-4" />
|
||
<p className="text-gray-500 dark:text-gray-400">Lade Quellen...</p>
|
||
</div>
|
||
</div>
|
||
) : filteredSources.length === 0 ? (
|
||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||
<FileText className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||
<p className="text-gray-500 dark:text-gray-400">
|
||
{searchQuery || filterType !== 'all'
|
||
? 'Keine Quellen gefunden'
|
||
: 'Noch keine Quellen registriert'}
|
||
</p>
|
||
{!searchQuery && filterType === 'all' && (
|
||
<button
|
||
onClick={handleInitialize}
|
||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||
>
|
||
Quellen initialisieren
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-4">
|
||
{filteredSources.map(source => (
|
||
<SourceCard
|
||
key={source.id}
|
||
source={source}
|
||
stats={getStatsForSource(source.sourceCode)}
|
||
onIngest={() => handleIngest(source.sourceCode)}
|
||
isIngesting={ingestingSource === source.sourceCode}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Info Box */}
|
||
<div className="mt-8 p-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl">
|
||
<h3 className="font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||
Ueber die Lizenzattribution
|
||
</h3>
|
||
<p className="text-sm text-blue-800 dark:text-blue-200 mb-4">
|
||
Alle DSFA-Quellen werden mit vollstaendiger Lizenzinformation gespeichert.
|
||
Bei der Nutzung der RAG-Suche werden automatisch die korrekten Attributionen angezeigt.
|
||
</p>
|
||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
|
||
<div className="flex items-center gap-2">
|
||
<LicenseBadge licenseCode="DL-DE-BY-2.0" />
|
||
<span className="text-blue-700 dark:text-blue-300">Namensnennung</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<LicenseBadge licenseCode="DL-DE-ZERO-2.0" />
|
||
<span className="text-blue-700 dark:text-blue-300">Keine Attribution</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<LicenseBadge licenseCode="CC-BY-4.0" />
|
||
<span className="text-blue-700 dark:text-blue-300">CC Attribution</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|