Files
breakpilot-lehrer/website/app/admin/edu-search/page.tsx
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
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>
2026-02-11 23:47:26 +01:00

959 lines
41 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
/**
* Education Search Admin Page
*
* Manages seed URLs, crawl settings, and index statistics for the
* edu-search-service (Tavily alternative for German education content)
*/
import { useState, useEffect, useCallback } from 'react'
import AdminLayout from '@/components/admin/AdminLayout'
// All API calls go through Next.js API proxy at /api/admin/edu-search
// This avoids CORS issues since browser calls same-origin API routes
// Types
interface SeedURL {
id: string
url: string
category: string
category_id?: string
name: string
description: string
trustBoost: number
enabled: boolean
lastCrawled?: string
documentCount?: number
source_type?: string
scope?: string
state?: string
crawl_depth?: number
crawl_frequency?: string
}
interface CrawlStats {
totalDocuments: number
totalSeeds: number
lastCrawlTime?: string
crawlStatus: 'idle' | 'running' | 'error'
documentsPerCategory: Record<string, number>
documentsPerDocType: Record<string, number>
avgTrustScore: number
}
interface Category {
id: string
name: string
display_name?: string
description: string
icon: string
sort_order?: number
is_active?: boolean
}
interface ApiSeed {
id: string
url: string
name: string
description: string | null
category: string | null // Backend returns 'category' not 'category_name'
category_display_name: string | null
source_type: string
scope: string
state: string | null
trust_boost: number
enabled: boolean
crawl_depth: number
crawl_frequency: string
last_crawled_at: string | null
last_crawl_status: string | null
last_crawl_docs: number
total_documents: number
created_at: string
updated_at: string
}
// Default categories (fallback if API fails)
const DEFAULT_CATEGORIES: Category[] = [
{ id: 'federal', name: 'federal', display_name: 'Bundesebene', description: 'KMK, BMBF, Bildungsserver', icon: '🏛️' },
{ id: 'states', name: 'states', display_name: 'Bundesländer', description: 'Ministerien, Landesbildungsserver', icon: '🗺️' },
{ id: 'science', name: 'science', display_name: 'Wissenschaft', description: 'Bertelsmann, PISA, IGLU, TIMSS', icon: '🔬' },
{ id: 'universities', name: 'universities', display_name: 'Hochschulen', description: 'Universitäten, Fachhochschulen, Pädagogische Hochschulen', icon: '🎓' },
{ id: 'legal', name: 'legal', display_name: 'Recht & Schulgesetze', description: 'Schulgesetze, Erlasse, Verordnungen, Datenschutzrecht', icon: '⚖️' },
{ id: 'portals', name: 'portals', display_name: 'Bildungsportale', description: 'Lehrer-Online, 4teachers, ZUM', icon: '📚' },
{ id: 'authorities', name: 'authorities', display_name: 'Schulbehörden', description: 'Regierungspräsidien, Schulämter', icon: '📋' },
]
// Convert API seed to frontend format
function apiSeedToFrontend(seed: ApiSeed): SeedURL {
return {
id: seed.id,
url: seed.url,
name: seed.name,
description: seed.description || '',
category: seed.category || 'federal', // Backend uses 'category' field
category_id: undefined,
trustBoost: seed.trust_boost,
enabled: seed.enabled,
lastCrawled: seed.last_crawled_at || undefined,
documentCount: seed.total_documents,
source_type: seed.source_type,
scope: seed.scope,
state: seed.state || undefined,
crawl_depth: seed.crawl_depth,
crawl_frequency: seed.crawl_frequency,
}
}
// Default empty stats (loaded from API)
const DEFAULT_STATS: CrawlStats = {
totalDocuments: 0,
totalSeeds: 0,
lastCrawlTime: undefined,
crawlStatus: 'idle',
documentsPerCategory: {},
documentsPerDocType: {},
avgTrustScore: 0,
}
export default function EduSearchAdminPage() {
const [activeTab, setActiveTab] = useState<'seeds' | 'crawl' | 'stats' | 'rules'>('seeds')
const [seeds, setSeeds] = useState<SeedURL[]>([])
const [allSeeds, setAllSeeds] = useState<SeedURL[]>([]) // All seeds for category counts
const [categories, setCategories] = useState<Category[]>(DEFAULT_CATEGORIES)
const [stats, setStats] = useState<CrawlStats>(DEFAULT_STATS)
const [selectedCategory, setSelectedCategory] = useState<string>('all')
const [searchQuery, setSearchQuery] = useState('')
const [showAddModal, setShowAddModal] = useState(false)
const [editingSeed, setEditingSeed] = useState<SeedURL | null>(null)
const [loading, setLoading] = useState(false)
const [initialLoading, setInitialLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Fetch categories from API (via proxy)
const fetchCategories = useCallback(async () => {
try {
const res = await fetch(`/api/admin/edu-search?action=categories`)
if (res.ok) {
const data = await res.json()
if (data.categories && data.categories.length > 0) {
setCategories(data.categories.map((cat: { id: string; name: string; display_name: string; description: string; icon: string; sort_order: number; is_active: boolean }) => ({
id: cat.id,
name: cat.name,
display_name: cat.display_name,
description: cat.description || '',
icon: cat.icon || '📁',
sort_order: cat.sort_order,
is_active: cat.is_active,
})))
}
}
} catch (err) {
console.error('Failed to fetch categories:', err)
}
}, [])
// Fetch all seeds from API (for category counts)
const fetchAllSeeds = useCallback(async () => {
try {
const res = await fetch(`/api/admin/edu-search?action=seeds`)
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = await res.json()
setAllSeeds((data.seeds || []).map(apiSeedToFrontend))
} catch (err) {
console.error('Failed to fetch all seeds:', err)
}
}, [])
// Fetch seeds from API (via proxy) - filtered by category
const fetchSeeds = useCallback(async () => {
try {
const params = new URLSearchParams()
params.append('action', 'seeds')
if (selectedCategory !== 'all') {
params.append('category', selectedCategory)
}
const res = await fetch(`/api/admin/edu-search?${params}`)
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = await res.json()
const fetchedSeeds = (data.seeds || []).map(apiSeedToFrontend)
setSeeds(fetchedSeeds)
// If fetching all, also update allSeeds for counts
if (selectedCategory === 'all') {
setAllSeeds(fetchedSeeds)
}
setError(null)
} catch (err) {
console.error('Failed to fetch seeds:', err)
setError('Seeds konnten nicht geladen werden. API nicht erreichbar.')
}
}, [selectedCategory])
// Fetch stats from API (via proxy)
const fetchStats = useCallback(async (preserveCrawlStatus = false) => {
try {
const res = await fetch(`/api/admin/edu-search?action=stats`)
if (res.ok) {
const data = await res.json()
setStats(prev => ({
totalDocuments: data.total_documents || 0,
totalSeeds: data.total_seeds || 0,
lastCrawlTime: data.last_crawl_time || prev.lastCrawlTime,
crawlStatus: preserveCrawlStatus ? prev.crawlStatus : (data.crawl_status || 'idle'),
documentsPerCategory: data.seeds_per_category || {},
documentsPerDocType: {},
avgTrustScore: data.avg_trust_boost || 0,
}))
}
} catch (err) {
console.error('Failed to fetch stats:', err)
}
}, [])
// Initial load
useEffect(() => {
const loadData = async () => {
setInitialLoading(true)
await Promise.all([fetchCategories(), fetchSeeds(), fetchAllSeeds(), fetchStats()])
setInitialLoading(false)
}
loadData()
}, [fetchCategories, fetchSeeds, fetchAllSeeds, fetchStats])
// Reload seeds when category filter changes
useEffect(() => {
if (!initialLoading) {
fetchSeeds()
}
}, [selectedCategory, initialLoading, fetchSeeds])
// Filter seeds
const filteredSeeds = seeds.filter(seed => {
const matchesCategory = selectedCategory === 'all' || seed.category === selectedCategory
const matchesSearch = seed.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
seed.url.toLowerCase().includes(searchQuery.toLowerCase())
return matchesCategory && matchesSearch
})
// Add/Edit Seed Modal
const SeedModal = ({ seed, onClose }: { seed?: SeedURL | null, onClose: () => void }) => {
const [formData, setFormData] = useState<Partial<SeedURL>>(seed || {
url: '',
category: 'federal',
name: '',
description: '',
trustBoost: 0.5,
enabled: true,
})
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setSaveError(null)
try {
// Find category ID by name
const category = categories.find(c => c.name === formData.category || c.id === formData.category)
const payload = {
url: formData.url,
name: formData.name,
description: formData.description || '',
category_id: category?.id || null,
trust_boost: formData.trustBoost,
enabled: formData.enabled,
source_type: 'GOV',
scope: 'FEDERAL',
}
if (seed) {
// Update existing seed (via proxy)
const res = await fetch(`/api/admin/edu-search?id=${seed.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const errData = await res.json()
throw new Error(errData.detail || errData.error || `HTTP ${res.status}`)
}
} else {
// Create new seed (via proxy)
const res = await fetch(`/api/admin/edu-search?action=seed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const errData = await res.json()
throw new Error(errData.detail || errData.error || `HTTP ${res.status}`)
}
}
// Reload seeds and close modal
await fetchSeeds()
await fetchStats()
onClose()
} catch (err) {
console.error('Failed to save seed:', err)
setSaveError(err instanceof Error ? err.message : 'Fehler beim Speichern')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg mx-4">
<div className="px-6 py-4 border-b border-slate-200">
<h3 className="text-lg font-semibold">{seed ? 'Seed bearbeiten' : 'Neue Seed-URL hinzufügen'}</h3>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{saveError && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded-lg text-sm">
{saveError}
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">URL *</label>
<input
type="url"
required
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="https://www.example.de"
value={formData.url}
onChange={e => setFormData({ ...formData, url: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
required
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Name der Quelle"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kategorie *</label>
<select
required
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.category}
onChange={e => setFormData({ ...formData, category: e.target.value })}
>
{categories.map(cat => (
<option key={cat.id} value={cat.name}>{cat.icon} {cat.display_name || cat.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
rows={2}
placeholder="Kurze Beschreibung der Quelle"
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Trust-Boost: {formData.trustBoost?.toFixed(2)}
</label>
<input
type="range"
min="0"
max="1"
step="0.05"
className="w-full"
value={formData.trustBoost}
onChange={e => setFormData({ ...formData, trustBoost: parseFloat(e.target.value) })}
/>
<p className="text-xs text-slate-500 mt-1">
Höhere Werte für vertrauenswürdigere Quellen (1.0 = max für offizielle Regierungsquellen)
</p>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enabled"
className="rounded border-slate-300 text-primary-600 focus:ring-primary-500"
checked={formData.enabled}
onChange={e => setFormData({ ...formData, enabled: e.target.checked })}
/>
<label htmlFor="enabled" className="text-sm text-slate-700">Aktiv (wird beim nächsten Crawl berücksichtigt)</label>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
disabled={saving}
className="px-4 py-2 text-slate-700 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
>
Abbrechen
</button>
<button
type="submit"
disabled={saving}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{saving && (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{seed ? 'Speichern' : 'Hinzufügen'}
</button>
</div>
</form>
</div>
</div>
)
}
const handleDelete = async (id: string) => {
if (!confirm('Seed-URL wirklich löschen?')) return
try {
const res = await fetch(`/api/admin/edu-search?id=${id}`, {
method: 'DELETE',
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
await fetchSeeds()
await fetchStats()
} catch (err) {
console.error('Failed to delete seed:', err)
alert('Fehler beim Löschen')
}
}
const handleToggleEnabled = async (id: string) => {
const seed = seeds.find(s => s.id === id)
if (!seed) return
try {
const res = await fetch(`/api/admin/edu-search?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !seed.enabled }),
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
// Optimistic update
setSeeds(seeds.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s))
} catch (err) {
console.error('Failed to toggle seed:', err)
// Reload on error
await fetchSeeds()
}
}
// Poll for crawl status from backend
const pollCrawlStatus = useCallback(async () => {
try {
const res = await fetch('/api/admin/edu-search?action=legal-crawler-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
if (res.ok) {
const data = await res.json()
return data.status // 'running', 'idle', 'completed', 'error'
}
} catch {
// Ignore errors
}
return 'idle'
}, [])
const handleStartCrawl = async () => {
setLoading(true)
setError(null)
setStats(prev => ({ ...prev, crawlStatus: 'running', lastCrawlTime: new Date().toISOString() }))
try {
const response = await fetch('/api/admin/edu-search?action=crawl', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const data = await response.json()
if (response.ok) {
// Crawl gestartet - kontinuierlich Status prüfen
const checkStatus = async () => {
const status = await pollCrawlStatus()
if (status === 'running') {
// Noch am Laufen - weiter pollen
setStats(prev => ({ ...prev, crawlStatus: 'running' }))
setTimeout(checkStatus, 3000)
} else if (status === 'completed' || status === 'idle') {
// Fertig
setStats(prev => ({ ...prev, crawlStatus: 'idle' }))
setLoading(false)
await fetchStats(false) // Refresh stats
} else {
// Fehler oder unbekannter Status
setStats(prev => ({ ...prev, crawlStatus: 'error' }))
setLoading(false)
}
}
// Start polling nach kurzer Verzögerung
setTimeout(checkStatus, 2000)
} else {
setError(data.error || 'Fehler beim Starten des Crawls')
setLoading(false)
setStats(prev => ({ ...prev, crawlStatus: 'idle' }))
}
} catch (err) {
setError('Netzwerkfehler beim Starten des Crawls')
setLoading(false)
setStats(prev => ({ ...prev, crawlStatus: 'idle' }))
}
}
return (
<AdminLayout title="Education Search" description="Bildungsquellen & Crawler verwalten">
{/* Loading State */}
{initialLoading && (
<div className="flex items-center justify-center py-12">
<svg className="w-8 h-8 animate-spin text-primary-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="ml-3 text-slate-600">Seeds werden geladen...</span>
</div>
)}
{/* Error State */}
{error && !initialLoading && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<span className="text-red-500 text-xl"></span>
<div>
<h4 className="font-medium text-red-800">{error}</h4>
<p className="text-sm text-red-600 mt-1">
Stelle sicher, dass der Backend-Service (http://localhost:8000) erreichbar ist.
</p>
<button
onClick={() => { fetchSeeds(); fetchStats(); }}
className="mt-2 text-sm text-red-700 underline hover:no-underline"
>
Erneut versuchen
</button>
</div>
</div>
</div>
)}
{/* Tabs */}
{!initialLoading && (
<div className="bg-white rounded-xl shadow-sm border border-slate-200 mb-6">
<div className="border-b border-slate-200">
<nav className="flex -mb-px">
{[
{ id: 'seeds', name: 'Seed-URLs', icon: '🌱' },
{ id: 'crawl', name: 'Crawl-Steuerung', icon: '🕷️' },
{ id: 'stats', name: 'Statistiken', icon: '📊' },
{ id: 'rules', name: 'Tagging-Regeln', icon: '🏷️' },
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-600 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
}`}
>
<span className="mr-2">{tab.icon}</span>
{tab.name}
</button>
))}
</nav>
</div>
<div className="p-6">
{/* Seeds Tab */}
{activeTab === 'seeds' && (
<div>
{/* Header with filters */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex-1 min-w-[200px]">
<input
type="text"
placeholder="Suche nach Name oder URL..."
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
<select
className="px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
value={selectedCategory}
onChange={e => setSelectedCategory(e.target.value)}
>
<option value="all">Alle Kategorien</option>
{categories.map(cat => (
<option key={cat.id} value={cat.name}>{cat.icon} {cat.display_name || cat.name}</option>
))}
</select>
<button
onClick={() => setShowAddModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neue Seed-URL
</button>
</div>
{/* Category Quick Stats - show all categories, use allSeeds for counts */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4 mb-6">
{categories.map(cat => {
const count = allSeeds.filter(s => s.category === cat.name).length
return (
<button
key={cat.id}
onClick={() => setSelectedCategory(selectedCategory === cat.name ? 'all' : cat.name)}
className={`p-4 rounded-lg border transition-colors text-left ${
selectedCategory === cat.name
? 'border-primary-500 bg-primary-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="text-2xl mb-1">{cat.icon}</div>
<div className="font-medium text-slate-900 text-sm">{cat.display_name || cat.name}</div>
<div className="text-sm text-slate-500">{count} Seeds</div>
</button>
)
})}
</div>
{/* Seeds Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 font-medium text-slate-700">Status</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Name</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">URL</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Kategorie</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Trust</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Dokumente</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Aktionen</th>
</tr>
</thead>
<tbody>
{filteredSeeds.map(seed => (
<tr key={seed.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4">
<button
onClick={() => handleToggleEnabled(seed.id)}
className={`w-10 h-6 rounded-full transition-colors ${
seed.enabled ? 'bg-green-500' : 'bg-slate-300'
}`}
>
<span className={`block w-4 h-4 bg-white rounded-full transform transition-transform ${
seed.enabled ? 'translate-x-5' : 'translate-x-1'
}`} />
</button>
</td>
<td className="py-3 px-4">
<div className="font-medium text-slate-900">{seed.name}</div>
<div className="text-sm text-slate-500">{seed.description}</div>
</td>
<td className="py-3 px-4">
<a href={seed.url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline text-sm">
{seed.url.replace(/^https?:\/\/(www\.)?/, '').slice(0, 30)}...
</a>
</td>
<td className="py-3 px-4">
<span className="inline-flex items-center gap-1 px-2 py-1 bg-slate-100 rounded text-sm">
{categories.find(c => c.name === seed.category)?.icon || '📁'}
{categories.find(c => c.name === seed.category)?.display_name || seed.category}
</span>
</td>
<td className="py-3 px-4">
<span className={`inline-flex px-2 py-1 rounded text-sm font-medium ${
seed.trustBoost >= 0.4 ? 'bg-green-100 text-green-700' :
seed.trustBoost >= 0.2 ? 'bg-yellow-100 text-yellow-700' :
'bg-slate-100 text-slate-700'
}`}>
+{seed.trustBoost.toFixed(2)}
</span>
</td>
<td className="py-3 px-4 text-slate-600">
{seed.documentCount?.toLocaleString() || '-'}
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<button
onClick={() => setEditingSeed(seed)}
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded"
title="Bearbeiten"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(seed.id)}
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded"
title="Löschen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredSeeds.length === 0 && (
<div className="text-center py-12 text-slate-500">
Keine Seed-URLs gefunden
</div>
)}
</div>
)}
{/* Crawl Tab */}
{activeTab === 'crawl' && (
<div className="space-y-6">
{/* Crawl Status */}
<div className="bg-slate-50 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-slate-900">Crawl-Status</h3>
<p className="text-sm text-slate-500">
Letzter Crawl: {stats.lastCrawlTime ? new Date(stats.lastCrawlTime).toLocaleString('de-DE') : 'Noch nie'}
</p>
</div>
<div className={`px-3 py-1.5 rounded-full text-sm font-medium ${
stats.crawlStatus === 'running' ? 'bg-blue-100 text-blue-700' :
stats.crawlStatus === 'error' ? 'bg-red-100 text-red-700' :
'bg-green-100 text-green-700'
}`}>
{stats.crawlStatus === 'running' ? '🔄 Läuft...' :
stats.crawlStatus === 'error' ? '❌ Fehler' :
'✅ Bereit'}
</div>
</div>
<button
onClick={handleStartCrawl}
disabled={loading || stats.crawlStatus === 'running'}
className="px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading ? (
<>
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Crawl läuft...
</>
) : (
<>
<svg className="w-5 h-5" 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>
Crawl starten
</>
)}
</button>
</div>
{/* Crawl Settings */}
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-4">Crawl-Einstellungen</h4>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Max. Seiten pro Crawl</label>
<input type="number" defaultValue={500} className="w-full px-3 py-2 border border-slate-300 rounded-lg" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rate-Limit (Requests/Sek)</label>
<input type="number" defaultValue={0.2} step={0.1} className="w-full px-3 py-2 border border-slate-300 rounded-lg" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Max. Crawl-Tiefe</label>
<input type="number" defaultValue={4} className="w-full px-3 py-2 border border-slate-300 rounded-lg" />
</div>
</div>
</div>
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-4">Scheduler</h4>
<div className="space-y-4">
<div className="flex items-center gap-2">
<input type="checkbox" id="autoSchedule" className="rounded border-slate-300" />
<label htmlFor="autoSchedule" className="text-sm text-slate-700">Automatischer Crawl aktiviert</label>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Intervall</label>
<select className="w-full px-3 py-2 border border-slate-300 rounded-lg">
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
<option value="monthly">Monatlich</option>
</select>
</div>
</div>
</div>
</div>
</div>
)}
{/* Stats Tab */}
{activeTab === 'stats' && (
<div className="space-y-6">
{/* Overview Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl p-5 text-white">
<div className="text-3xl font-bold">{stats.totalDocuments.toLocaleString()}</div>
<div className="text-blue-100">Dokumente indexiert</div>
</div>
<div className="bg-gradient-to-br from-green-500 to-green-600 rounded-xl p-5 text-white">
<div className="text-3xl font-bold">{stats.totalSeeds}</div>
<div className="text-green-100">Seed-URLs aktiv</div>
</div>
<div className="bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl p-5 text-white">
<div className="text-3xl font-bold">{(stats.avgTrustScore * 100).toFixed(0)}%</div>
<div className="text-purple-100">Ø Trust-Score</div>
</div>
<div className="bg-gradient-to-br from-orange-500 to-orange-600 rounded-xl p-5 text-white">
<div className="text-3xl font-bold">{Object.keys(stats.documentsPerDocType).length}</div>
<div className="text-orange-100">Dokumenttypen</div>
</div>
</div>
{/* Charts */}
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-4">Dokumente nach Kategorie</h4>
<div className="space-y-3">
{Object.entries(stats.documentsPerCategory).map(([cat, count]) => {
const category = categories.find(c => c.name === cat)
const percentage = stats.totalDocuments > 0 ? (count / stats.totalDocuments) * 100 : 0
return (
<div key={cat}>
<div className="flex justify-between text-sm mb-1">
<span>{category?.icon || '📁'} {category?.display_name || cat}</span>
<span className="text-slate-500">{count.toLocaleString()} ({percentage.toFixed(1)}%)</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-4">Dokumente nach Typ</h4>
<div className="space-y-3">
{Object.entries(stats.documentsPerDocType)
.sort(([,a], [,b]) => b - a)
.slice(0, 6)
.map(([docType, count]) => {
const percentage = (count / stats.totalDocuments) * 100
return (
<div key={docType}>
<div className="flex justify-between text-sm mb-1">
<span>{docType.replace(/_/g, ' ')}</span>
<span className="text-slate-500">{count.toLocaleString()} ({percentage.toFixed(1)}%)</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
)}
{/* Rules Tab */}
{activeTab === 'rules' && (
<div className="space-y-6">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<span className="text-2xl"></span>
<div>
<h4 className="font-semibold text-amber-800">Tagging-Regeln Editor</h4>
<p className="text-sm text-amber-700">
Die Tagging-Regeln werden aktuell über YAML-Dateien verwaltet.
Ein visueller Editor ist in Entwicklung.
</p>
</div>
</div>
</div>
<div className="grid md:grid-cols-2 gap-6">
{[
{ name: 'Doc-Type Regeln', file: 'doc_type_rules.yaml', desc: 'Klassifiziert Dokumente (Lehrplan, Arbeitsblatt, etc.)' },
{ name: 'Fach-Regeln', file: 'subject_rules.yaml', desc: 'Erkennt Unterrichtsfächer' },
{ name: 'Schulstufen-Regeln', file: 'level_rules.yaml', desc: 'Erkennt Primar, SekI, SekII, etc.' },
{ name: 'Trust-Score Regeln', file: 'trust_rules.yaml', desc: 'Domain-basierte Vertrauensbewertung' },
].map(rule => (
<div key={rule.file} className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-2">{rule.name}</h4>
<p className="text-sm text-slate-500 mb-4">{rule.desc}</p>
<code className="text-xs bg-slate-100 px-2 py-1 rounded text-slate-600">
/rules/{rule.file}
</code>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Modals */}
{(showAddModal || editingSeed) && (
<SeedModal
seed={editingSeed}
onClose={() => {
setShowAddModal(false)
setEditingSeed(null)
}}
/>
)}
</AdminLayout>
)
}