'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 documentsPerDocType: Record 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([]) const [allSeeds, setAllSeeds] = useState([]) // All seeds for category counts const [categories, setCategories] = useState(DEFAULT_CATEGORIES) const [stats, setStats] = useState(DEFAULT_STATS) const [selectedCategory, setSelectedCategory] = useState('all') const [searchQuery, setSearchQuery] = useState('') const [showAddModal, setShowAddModal] = useState(false) const [editingSeed, setEditingSeed] = useState(null) const [loading, setLoading] = useState(false) const [initialLoading, setInitialLoading] = useState(true) const [error, setError] = useState(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>(seed || { url: '', category: 'federal', name: '', description: '', trustBoost: 0.5, enabled: true, }) const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(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 (

{seed ? 'Seed bearbeiten' : 'Neue Seed-URL hinzufügen'}

{saveError && (
{saveError}
)}
setFormData({ ...formData, url: e.target.value })} />
setFormData({ ...formData, name: e.target.value })} />