fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
339
studio-v2/components/SchoolSearch.tsx
Normal file
339
studio-v2/components/SchoolSearch.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
|
||||
interface SchoolSearchProps {
|
||||
city: string
|
||||
bundesland: string
|
||||
bundeslandName: string
|
||||
selectedSchool: string
|
||||
onSelectSchool: (schoolName: string, schoolId?: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface SchoolSuggestion {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
address?: string
|
||||
city?: string
|
||||
source: 'api' | 'mock'
|
||||
}
|
||||
|
||||
// Edu-Search API URL - uses HTTPS proxy on port 8089 (edu-search runs on 8088)
|
||||
const getApiUrl = () => {
|
||||
if (typeof window === 'undefined') return 'http://localhost:8088'
|
||||
const { hostname, protocol } = window.location
|
||||
// localhost: direct HTTP to 8088, macmini: HTTPS via nginx proxy on 8089
|
||||
return hostname === 'localhost' ? 'http://localhost:8088' : `${protocol}//${hostname}:8089`
|
||||
}
|
||||
|
||||
// Attribution data for Open Data sources by Bundesland (CTRL-SRC-001)
|
||||
const BUNDESLAND_ATTRIBUTION: Record<string, { source: string; license: string; licenseUrl: string }> = {
|
||||
BW: { source: 'Open Data Baden-Wuerttemberg', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
BY: { source: 'Open Data Bayern', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
BE: { source: 'Datenportal Berlin', license: 'CC-BY', licenseUrl: 'https://creativecommons.org/licenses/by/4.0/' },
|
||||
BB: { source: 'Daten Brandenburg', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
HB: { source: 'Open Data Bremen', license: 'CC-BY', licenseUrl: 'https://creativecommons.org/licenses/by/4.0/' },
|
||||
HH: { source: 'Transparenzportal Hamburg', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
HE: { source: 'Open Data Hessen', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
MV: { source: 'Open Data MV', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
NI: { source: 'Open Data Niedersachsen', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
NW: { source: 'Open.NRW', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
RP: { source: 'Open Data Rheinland-Pfalz', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
SL: { source: 'Open Data Saarland', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
SN: { source: 'Datenportal Sachsen', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
ST: { source: 'Open Data Sachsen-Anhalt', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
SH: { source: 'Open Data Schleswig-Holstein', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
TH: { source: 'Open Data Thueringen', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
}
|
||||
|
||||
// Category to display type mapping
|
||||
const getCategoryDisplayType = (category?: string): string => {
|
||||
switch (category) {
|
||||
case 'primary': return 'Grundschule'
|
||||
case 'secondary': return 'Sekundarschule'
|
||||
case 'vocational': return 'Berufsschule'
|
||||
case 'special': return 'Foerderschule'
|
||||
default: return 'Schule'
|
||||
}
|
||||
}
|
||||
|
||||
export function SchoolSearch({
|
||||
city,
|
||||
bundesland,
|
||||
bundeslandName,
|
||||
selectedSchool,
|
||||
onSelectSchool,
|
||||
className = ''
|
||||
}: SchoolSearchProps) {
|
||||
const { isDark } = useTheme()
|
||||
const [searchQuery, setSearchQuery] = useState(selectedSchool || '')
|
||||
const [suggestions, setSuggestions] = useState<SchoolSuggestion[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Suche nach Schulen (echte API)
|
||||
const searchSchools = useCallback(async (query: string) => {
|
||||
if (query.length < 2) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
|
||||
setIsSearching(true)
|
||||
|
||||
try {
|
||||
// Real API call to edu-search-service
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
limit: '10',
|
||||
})
|
||||
if (city) params.append('city', city)
|
||||
if (bundesland) params.append('state', bundesland)
|
||||
|
||||
const response = await fetch(`${getApiUrl()}/api/v1/schools/search?${params}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const apiSchools: SchoolSuggestion[] = (data.schools || []).map((school: {
|
||||
id: string
|
||||
name: string
|
||||
school_type_name?: string
|
||||
school_category?: string
|
||||
street?: string
|
||||
city?: string
|
||||
postal_code?: string
|
||||
}) => ({
|
||||
id: school.id,
|
||||
name: school.name,
|
||||
type: school.school_type_name || getCategoryDisplayType(school.school_category),
|
||||
address: school.street ? `${school.street}, ${school.postal_code || ''} ${school.city || ''}`.trim() : undefined,
|
||||
city: school.city,
|
||||
source: 'api' as const,
|
||||
}))
|
||||
setSuggestions(apiSchools)
|
||||
} else {
|
||||
console.error('School search API error:', response.status)
|
||||
setSuggestions([])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('School search error:', error)
|
||||
setSuggestions([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, [city, bundesland])
|
||||
|
||||
// Debounced Search
|
||||
useEffect(() => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
searchSchools(searchQuery)
|
||||
}, 300)
|
||||
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [searchQuery, searchSchools])
|
||||
|
||||
// Klick ausserhalb schließt Dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Schule auswaehlen
|
||||
const handleSelectSchool = (school: SchoolSuggestion) => {
|
||||
setSearchQuery(school.name)
|
||||
onSelectSchool(school.name, school.id)
|
||||
setSuggestions([])
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
|
||||
// Manuelle Eingabe bestaetigen
|
||||
const handleManualInput = () => {
|
||||
if (searchQuery.trim()) {
|
||||
onSelectSchool(searchQuery.trim())
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Schultyp-Icon
|
||||
const getSchoolTypeIcon = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'gymnasium': return '🎓'
|
||||
case 'gesamtschule':
|
||||
case 'stadtteilschule': return '🏫'
|
||||
case 'realschule': return '📚'
|
||||
case 'hauptschule':
|
||||
case 'mittelschule': return '📖'
|
||||
case 'grundschule': return '🏠'
|
||||
case 'berufsschule': return '🔧'
|
||||
case 'foerderschule': return '🤝'
|
||||
case 'privatschule': return '🏰'
|
||||
case 'waldorfschule': return '🌿'
|
||||
default: return '🏫'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`} ref={containerRef}>
|
||||
{/* Suchfeld */}
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setShowSuggestions(true)
|
||||
}}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleManualInput()
|
||||
}
|
||||
}}
|
||||
placeholder={`Schule in ${city} suchen...`}
|
||||
className={`w-full px-6 py-4 pl-14 text-lg rounded-2xl border transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-blue-400'
|
||||
: 'bg-white border-slate-300 text-slate-900 placeholder-slate-400 focus:border-blue-500'
|
||||
} focus:outline-none focus:ring-2 focus:ring-blue-500/30`}
|
||||
autoFocus
|
||||
/>
|
||||
<svg
|
||||
className={`absolute left-5 top-1/2 -translate-y-1/2 w-5 h-5 ${
|
||||
isDark ? 'text-white/40' : 'text-slate-400'
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
{isSearching && (
|
||||
<div className={`absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 border-2 border-t-transparent rounded-full animate-spin ${
|
||||
isDark ? 'border-white/40' : 'border-slate-400'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vorschlaege Dropdown */}
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div className={`absolute top-full left-0 right-0 mt-2 rounded-xl border shadow-xl z-50 max-h-80 overflow-y-auto ${
|
||||
isDark
|
||||
? 'bg-slate-800/95 border-white/20 backdrop-blur-xl'
|
||||
: 'bg-white/95 border-slate-200 backdrop-blur-xl'
|
||||
}`}>
|
||||
{suggestions.map((school, index) => (
|
||||
<button
|
||||
key={school.id}
|
||||
onClick={() => handleSelectSchool(school)}
|
||||
className={`w-full px-4 py-3 text-left transition-colors flex items-center gap-3 ${
|
||||
isDark
|
||||
? 'hover:bg-white/10 text-white/90'
|
||||
: 'hover:bg-slate-100 text-slate-800'
|
||||
} ${index > 0 ? (isDark ? 'border-t border-white/10' : 'border-t border-slate-100') : ''}`}
|
||||
>
|
||||
<span className="text-xl flex-shrink-0">{getSchoolTypeIcon(school.type)}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">{school.name}</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-white/10 text-white/60' : 'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
{school.type}
|
||||
</span>
|
||||
{school.address && (
|
||||
<span className={`text-xs truncate ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{school.address}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{school.source === 'api' && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
verifiziert
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{/* Attribution Footer (CTRL-SRC-001) */}
|
||||
{(() => {
|
||||
const attr = BUNDESLAND_ATTRIBUTION[bundesland]
|
||||
return attr ? (
|
||||
<div className={`px-4 py-2 text-xs border-t ${
|
||||
isDark ? 'bg-slate-900/50 border-white/10 text-white/40' : 'bg-slate-50 border-slate-200 text-slate-500'
|
||||
}`}>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<span>Quelle:</span>
|
||||
<span className="font-medium">{attr.source}</span>
|
||||
<span>•</span>
|
||||
<a
|
||||
href={attr.licenseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`underline hover:no-underline ${isDark ? 'text-blue-400' : 'text-blue-600'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{attr.license}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keine Ergebnisse Info */}
|
||||
{showSuggestions && searchQuery.length >= 2 && suggestions.length === 0 && !isSearching && (
|
||||
<div className={`absolute top-full left-0 right-0 mt-2 rounded-xl border p-4 ${
|
||||
isDark
|
||||
? 'bg-slate-800/95 border-white/20 backdrop-blur-xl'
|
||||
: 'bg-white/95 border-slate-200 backdrop-blur-xl'
|
||||
}`}>
|
||||
<p className={`text-sm text-center ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Keine Schulen gefunden. Tippen Sie den Namen ein und druecken Sie Enter.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Standort Info */}
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">📍</span>
|
||||
<div className="text-left flex-1">
|
||||
<p className={`text-sm ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
<strong>{city}</strong>, {bundeslandName}
|
||||
</p>
|
||||
<p className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
Ihr Standort
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hinweis */}
|
||||
<p className={`text-xs text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Waehlen Sie aus den Vorschlaegen oder geben Sie den Namen manuell ein
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user