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:
@@ -0,0 +1,223 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AehnlicheDokumente - RAG-based similar documents panel
|
||||
* Shows documents with similar content based on vector similarity
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Loader2, FileText, AlertCircle, RefreshCw, ExternalLink } from 'lucide-react'
|
||||
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
|
||||
import type { SimilarDocument } from '@/lib/education/abitur-archiv-types'
|
||||
import { FAECHER } from '@/lib/education/abitur-docs-types'
|
||||
|
||||
interface AehnlicheDokumenteProps {
|
||||
documentId: string
|
||||
onSelectDocument: (doc: AbiturDokument) => void
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export function AehnlicheDokumente({
|
||||
documentId,
|
||||
onSelectDocument,
|
||||
limit = 5
|
||||
}: AehnlicheDokumenteProps) {
|
||||
const [similarDocs, setSimilarDocs] = useState<SimilarDocument[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSimilarDocuments = async () => {
|
||||
if (!documentId) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/education/abitur-archiv/similar?id=${documentId}&limit=${limit}`)
|
||||
|
||||
if (!res.ok) {
|
||||
// Use mock data if endpoint not available
|
||||
setSimilarDocs(getMockSimilarDocuments(documentId))
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setSimilarDocs(data.similar || [])
|
||||
} catch (err) {
|
||||
console.log('Similar docs fetch failed, using mock data')
|
||||
setSimilarDocs(getMockSimilarDocuments(documentId))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchSimilarDocuments()
|
||||
}, [documentId, limit])
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLoading(true)
|
||||
// Re-trigger the effect
|
||||
setSimilarDocs([])
|
||||
setTimeout(() => {
|
||||
setSimilarDocs(getMockSimilarDocuments(documentId))
|
||||
setLoading(false)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<Loader2 className="w-8 h-8 text-blue-600 animate-spin mb-3" />
|
||||
<p className="text-sm text-slate-500">Suche aehnliche Dokumente...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-red-600 mb-3">{error}</p>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="px-4 py-2 text-sm text-blue-600 hover:bg-blue-50 rounded-lg flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (similarDocs.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<FileText className="w-10 h-10 text-slate-300 mx-auto mb-3" />
|
||||
<p className="text-sm text-slate-500">Keine aehnlichen Dokumente gefunden</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Versuchen Sie eine andere Suche oder laden Sie mehr Dokumente hoch.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-slate-700">Aehnliche Dokumente</h4>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded"
|
||||
title="Aktualisieren"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{similarDocs.map((doc) => (
|
||||
<SimilarDocumentCard
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
onSelect={() => {
|
||||
// Convert SimilarDocument to AbiturDokument for selection
|
||||
// In production, this would fetch the full document
|
||||
onSelectDocument(doc as unknown as AbiturDokument)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400 text-center pt-2">
|
||||
Basierend auf semantischer Aehnlichkeit (RAG)
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SimilarDocumentCard({
|
||||
document,
|
||||
onSelect
|
||||
}: {
|
||||
document: SimilarDocument
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
const similarityPercent = Math.round(document.similarity_score * 100)
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="w-full flex items-start gap-3 p-3 bg-white border border-slate-200 rounded-lg
|
||||
hover:bg-blue-50 hover:border-blue-200 transition-colors text-left group"
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center flex-shrink-0
|
||||
group-hover:bg-blue-100 transition-colors">
|
||||
<FileText className="w-5 h-5 text-slate-400 group-hover:text-blue-500" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 truncate group-hover:text-blue-700">
|
||||
{fachLabel} {document.jahr}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 flex items-center gap-2">
|
||||
<span>{document.niveau}</span>
|
||||
<span>|</span>
|
||||
<span>Aufgabe {document.aufgaben_nummer}</span>
|
||||
{document.typ === 'erwartungshorizont' && (
|
||||
<>
|
||||
<span>|</span>
|
||||
<span className="text-orange-600">EWH</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Similarity Score */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
similarityPercent >= 80
|
||||
? 'bg-green-100 text-green-700'
|
||||
: similarityPercent >= 60
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{similarityPercent}%
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Mock data generator for development
|
||||
function getMockSimilarDocuments(documentId: string): SimilarDocument[] {
|
||||
// Generate consistent mock data based on document ID
|
||||
const idHash = documentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
|
||||
const faecher = ['deutsch', 'englisch']
|
||||
const jahre = [2021, 2022, 2023, 2024, 2025]
|
||||
const niveaus: Array<'eA' | 'gA'> = ['eA', 'gA']
|
||||
const nummern = ['I', 'II', 'III']
|
||||
const typen: Array<'aufgabe' | 'erwartungshorizont'> = ['aufgabe', 'erwartungshorizont']
|
||||
|
||||
const docs: SimilarDocument[] = []
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const idx = (idHash + i) % (faecher.length * jahre.length * niveaus.length)
|
||||
docs.push({
|
||||
id: `similar-${documentId}-${i}`,
|
||||
dateiname: `${jahre[idx % jahre.length]}_${faecher[idx % faecher.length]}_${niveaus[idx % niveaus.length]}_${nummern[idx % nummern.length]}.pdf`,
|
||||
similarity_score: 0.95 - (i * 0.1) + (Math.random() * 0.05),
|
||||
fach: faecher[idx % faecher.length],
|
||||
jahr: jahre[(idx + i) % jahre.length],
|
||||
niveau: niveaus[idx % niveaus.length],
|
||||
typ: typen[(idx + i) % typen.length],
|
||||
aufgaben_nummer: nummern[(idx + i) % nummern.length]
|
||||
})
|
||||
}
|
||||
|
||||
return docs.sort((a, b) => b.similarity_score - a.similarity_score)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DokumentCard - Card component for Abitur document grid view
|
||||
* Features: Preview, Download, Add to Klausur actions
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { FileText, Eye, Download, Plus, Calendar, Layers, BookOpen, ExternalLink } from 'lucide-react'
|
||||
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
|
||||
import { formatFileSize, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
|
||||
|
||||
interface DokumentCardProps {
|
||||
document: AbiturDokument
|
||||
onPreview: (doc: AbiturDokument) => void
|
||||
onDownload: (doc: AbiturDokument) => void
|
||||
onAddToKlausur?: (doc: AbiturDokument) => void
|
||||
}
|
||||
|
||||
export function DokumentCard({
|
||||
document,
|
||||
onPreview,
|
||||
onDownload,
|
||||
onAddToKlausur
|
||||
}: DokumentCardProps) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
const niveauLabel = document.niveau === 'eA' ? 'Erhoehtes Niveau' : 'Grundlegendes Niveau'
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onDownload(document)
|
||||
}
|
||||
|
||||
const handleAddToKlausur = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onAddToKlausur?.(document)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white rounded-xl border border-slate-200 overflow-hidden hover:shadow-lg
|
||||
transition-all duration-200 cursor-pointer group"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={() => onPreview(document)}
|
||||
>
|
||||
{/* Header with Type Badge */}
|
||||
<div className="relative h-32 bg-gradient-to-br from-slate-100 to-slate-50 flex items-center justify-center">
|
||||
<FileText className="w-16 h-16 text-slate-300 group-hover:text-blue-400 transition-colors" />
|
||||
|
||||
{/* Type Badge */}
|
||||
<div className="absolute top-3 left-3">
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||
document.typ === 'erwartungshorizont'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{document.typ === 'erwartungshorizont' ? 'Erwartungshorizont' : 'Aufgabe'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Year Badge */}
|
||||
<div className="absolute top-3 right-3">
|
||||
<span className="px-2 py-1 bg-white/80 backdrop-blur-sm rounded-lg text-xs font-semibold text-slate-700">
|
||||
{document.jahr}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="absolute bottom-3 right-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
document.status === 'indexed'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: document.status === 'error'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{document.status === 'indexed' ? 'Indexiert' : document.status === 'error' ? 'Fehler' : 'Ausstehend'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Hover Overlay with Preview */}
|
||||
{isHovered && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<button
|
||||
className="px-4 py-2 bg-white text-slate-800 rounded-lg font-medium
|
||||
flex items-center gap-2 shadow-lg hover:bg-blue-50 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPreview(document)
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Vorschau
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-slate-800 mb-2 line-clamp-2 min-h-[2.5rem]">
|
||||
{fachLabel} {document.niveau} - Aufgabe {document.aufgaben_nummer}
|
||||
</h3>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="space-y-1.5 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span>{fachLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<Layers className="w-4 h-4" />
|
||||
<span>{niveauLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span className="capitalize">{document.bundesland}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<span>{formatFileSize(document.file_size)}</span>
|
||||
<span>|</span>
|
||||
<span>{document.dateiname}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onPreview(document)}
|
||||
className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100
|
||||
transition-colors text-sm font-medium flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Vorschau
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="px-3 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Herunterladen"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
{onAddToKlausur && (
|
||||
<button
|
||||
onClick={handleAddToKlausur}
|
||||
className="px-3 py-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors"
|
||||
title="Zur Klausur hinzufuegen"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact card variant for list view or similar documents
|
||||
*/
|
||||
export function DokumentCardCompact({
|
||||
document,
|
||||
onPreview,
|
||||
similarity_score
|
||||
}: {
|
||||
document: AbiturDokument
|
||||
onPreview: (doc: AbiturDokument) => void
|
||||
similarity_score?: number
|
||||
}) {
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onPreview(document)}
|
||||
className="w-full flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-lg
|
||||
hover:bg-slate-50 hover:border-slate-300 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-5 h-5 text-slate-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 truncate">
|
||||
{fachLabel} {document.jahr} - {document.niveau}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 truncate">
|
||||
Aufgabe {document.aufgaben_nummer}
|
||||
{document.typ === 'erwartungshorizont' && ' (EWH)'}
|
||||
</div>
|
||||
</div>
|
||||
{similarity_score !== undefined && (
|
||||
<div className="flex-shrink-0">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full">
|
||||
{Math.round(similarity_score * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* FullscreenViewer - Enhanced PDF viewer with fullscreen, zoom, and page navigation
|
||||
* Features: Keyboard shortcuts, zoom controls, similar documents panel
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
X, Download, ZoomIn, ZoomOut, Maximize2, Minimize2,
|
||||
ChevronLeft, ChevronRight, RotateCw, FileText, Search,
|
||||
BookOpen, Calendar, Layers, ExternalLink, Plus
|
||||
} from 'lucide-react'
|
||||
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
|
||||
import { formatFileSize, formatDocumentTitle, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
|
||||
import { ZOOM_LEVELS, MIN_ZOOM, MAX_ZOOM, ZOOM_STEP } from '@/lib/education/abitur-archiv-types'
|
||||
import { AehnlicheDokumente } from './AehnlicheDokumente'
|
||||
|
||||
interface FullscreenViewerProps {
|
||||
document: AbiturDokument | null
|
||||
onClose: () => void
|
||||
onAddToKlausur?: (doc: AbiturDokument) => void
|
||||
backendUrl?: string
|
||||
}
|
||||
|
||||
export function FullscreenViewer({
|
||||
document,
|
||||
onClose,
|
||||
onAddToKlausur,
|
||||
backendUrl = ''
|
||||
}: FullscreenViewerProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [zoom, setZoom] = useState(100)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [showSidebar, setShowSidebar] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'details' | 'similar'>('details')
|
||||
|
||||
// Reset state when document changes
|
||||
useEffect(() => {
|
||||
setZoom(100)
|
||||
setCurrentPage(1)
|
||||
setIsFullscreen(false)
|
||||
}, [document?.id])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (!document) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore if typing in an input
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
if (isFullscreen) {
|
||||
setIsFullscreen(false)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
break
|
||||
case 'f':
|
||||
case 'F11':
|
||||
e.preventDefault()
|
||||
setIsFullscreen(prev => !prev)
|
||||
break
|
||||
case '+':
|
||||
case '=':
|
||||
e.preventDefault()
|
||||
setZoom(z => Math.min(MAX_ZOOM, z + ZOOM_STEP))
|
||||
break
|
||||
case '-':
|
||||
e.preventDefault()
|
||||
setZoom(z => Math.max(MIN_ZOOM, z - ZOOM_STEP))
|
||||
break
|
||||
case '0':
|
||||
e.preventDefault()
|
||||
setZoom(100)
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
setCurrentPage(p => Math.max(1, p - 1))
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
setCurrentPage(p => Math.min(totalPages, p + 1))
|
||||
break
|
||||
case 's':
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
handleDownload()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [document, isFullscreen, totalPages, onClose])
|
||||
|
||||
// Handle native fullscreen changes
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!window.document.fullscreenElement)
|
||||
}
|
||||
|
||||
window.document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
return () => window.document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
}, [])
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!document) return
|
||||
const link = window.document.createElement('a')
|
||||
link.href = pdfUrl
|
||||
link.download = document.dateiname
|
||||
link.click()
|
||||
}, [document])
|
||||
|
||||
const handleSearchInRAG = () => {
|
||||
if (!document) return
|
||||
window.location.href = `/education/edu-search?doc=${document.id}&search=1`
|
||||
}
|
||||
|
||||
const handleAddToKlausur = () => {
|
||||
if (!document || !onAddToKlausur) return
|
||||
onAddToKlausur(document)
|
||||
}
|
||||
|
||||
if (!document) return null
|
||||
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
const niveauLabel = NIVEAUS.find(n => n.id === document.niveau)?.label || document.niveau
|
||||
|
||||
// Build PDF URL
|
||||
const pdfUrl = backendUrl
|
||||
? `${backendUrl}/api/abitur-docs/${document.id}/file`
|
||||
: document.file_path
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-50 flex ${isFullscreen ? 'bg-black' : 'bg-black/60 backdrop-blur-sm'}`}>
|
||||
{/* Modal Container */}
|
||||
<div className={`relative bg-white flex flex-col ${
|
||||
isFullscreen ? 'w-full h-full' : 'w-[95vw] h-[95vh] max-w-7xl m-auto rounded-2xl overflow-hidden shadow-2xl'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white border-b border-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">
|
||||
{formatDocumentTitle(document)}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{document.dateiname}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Zoom Controls */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-slate-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.max(MIN_ZOOM, z - ZOOM_STEP))}
|
||||
className="p-1.5 hover:bg-slate-200 rounded"
|
||||
title="Verkleinern (-)"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-slate-700 w-12 text-center">
|
||||
{zoom}%
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.min(MAX_ZOOM, z + ZOOM_STEP))}
|
||||
className="p-1.5 hover:bg-slate-200 rounded"
|
||||
title="Vergroessern (+)"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setZoom(100)}
|
||||
className="p-1.5 hover:bg-slate-200 rounded ml-1"
|
||||
title="Zuruecksetzen (0)"
|
||||
>
|
||||
<RotateCw className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Page Navigation */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-slate-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-1.5 hover:bg-slate-200 rounded disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-slate-700 min-w-[60px] text-center">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-1.5 hover:bg-slate-200 rounded disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-px h-6 bg-slate-200" />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<button
|
||||
onClick={handleSearchInRAG}
|
||||
className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 flex items-center gap-1.5"
|
||||
title="In RAG suchen"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">RAG-Suche</span>
|
||||
</button>
|
||||
|
||||
{onAddToKlausur && (
|
||||
<button
|
||||
onClick={handleAddToKlausur}
|
||||
className="px-3 py-1.5 text-sm bg-green-100 text-green-700 rounded-lg hover:bg-green-200 flex items-center gap-1.5"
|
||||
title="Als Vorlage verwenden"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Zur Klausur</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 flex items-center gap-1.5"
|
||||
title="Herunterladen (Ctrl+S)"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Download</span>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-slate-200" />
|
||||
|
||||
<button
|
||||
onClick={() => setShowSidebar(!showSidebar)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
showSidebar ? 'bg-slate-200 text-slate-700' : 'text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
title="Seitenleiste"
|
||||
>
|
||||
<Layers className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg"
|
||||
title={isFullscreen ? 'Vollbild beenden (F)' : 'Vollbild (F)'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="w-5 h-5 text-slate-600" />
|
||||
) : (
|
||||
<Maximize2 className="w-5 h-5 text-slate-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg"
|
||||
title="Schliessen (Esc)"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* PDF Viewer */}
|
||||
<div className="flex-1 bg-slate-100 p-4 overflow-auto">
|
||||
<div
|
||||
className="bg-white rounded-lg border border-slate-200 mx-auto shadow-sm transition-transform duration-200"
|
||||
style={{
|
||||
transform: `scale(${zoom / 100})`,
|
||||
transformOrigin: 'top center',
|
||||
width: '100%',
|
||||
maxWidth: zoom > 100 ? 'none' : '100%'
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src={pdfUrl}
|
||||
className="w-full h-[calc(90vh-120px)] rounded-lg"
|
||||
title={document.dateiname}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
{showSidebar && (
|
||||
<div className="w-80 border-l border-slate-200 bg-slate-50 flex flex-col">
|
||||
{/* Sidebar Tabs */}
|
||||
<div className="flex border-b border-slate-200">
|
||||
<button
|
||||
onClick={() => setActiveTab('details')}
|
||||
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === 'details'
|
||||
? 'text-blue-600 border-b-2 border-blue-600 bg-white'
|
||||
: 'text-slate-600 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('similar')}
|
||||
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === 'similar'
|
||||
? 'text-blue-600 border-b-2 border-blue-600 bg-white'
|
||||
: 'text-slate-600 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
Aehnliche
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{activeTab === 'details' ? (
|
||||
<div className="space-y-4">
|
||||
{/* Fach */}
|
||||
<div className="flex items-start gap-3">
|
||||
<BookOpen className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Fach</div>
|
||||
<div className="font-medium text-slate-900">{fachLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Jahr */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Calendar className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Jahr</div>
|
||||
<div className="font-medium text-slate-900">{document.jahr}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Niveau */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Layers className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Niveau</div>
|
||||
<div className="font-medium text-slate-900">{niveauLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aufgabe */}
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Aufgabe</div>
|
||||
<div className="font-medium text-slate-900">
|
||||
{document.aufgaben_nummer}
|
||||
<span className="ml-2 px-2 py-0.5 bg-slate-200 text-slate-700 text-xs rounded-full">
|
||||
{document.typ === 'erwartungshorizont' ? 'Erwartungshorizont' : 'Aufgabe'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bundesland */}
|
||||
<div className="flex items-start gap-3">
|
||||
<ExternalLink className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Bundesland</div>
|
||||
<div className="font-medium text-slate-900 capitalize">{document.bundesland}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-slate-200" />
|
||||
|
||||
{/* File Info */}
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">Datei-Info</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3 text-sm space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Dateiname</span>
|
||||
<span className="text-slate-900 font-mono text-xs truncate max-w-[150px]" title={document.dateiname}>
|
||||
{document.dateiname}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Groesse</span>
|
||||
<span className="text-slate-900">{formatFileSize(document.file_size)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Status</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
document.status === 'indexed'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: document.status === 'error'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{document.status === 'indexed' ? 'Indexiert' : document.status === 'error' ? 'Fehler' : 'Ausstehend'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RAG Info */}
|
||||
{document.indexed && document.vector_ids.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">RAG-Index</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-purple-700">
|
||||
<Search className="w-4 h-4" />
|
||||
<span>{document.vector_ids.length} Vektoren indexiert</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-purple-600">
|
||||
Confidence: {(document.confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="text-xs text-slate-400 pt-2">
|
||||
<div>Erstellt: {new Date(document.created_at).toLocaleString('de-DE')}</div>
|
||||
<div>Aktualisiert: {new Date(document.updated_at).toLocaleString('de-DE')}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<AehnlicheDokumente
|
||||
documentId={document.id}
|
||||
onSelectDocument={(doc) => {
|
||||
// This would be handled by parent - for now just show preview
|
||||
console.log('Selected similar document:', doc.id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcut Hint */}
|
||||
<div className="absolute bottom-4 left-4 text-xs text-slate-400 bg-white/80 backdrop-blur-sm px-3 py-1.5 rounded-lg shadow-sm">
|
||||
Tastenkuerzel: F (Vollbild) | +/- (Zoom) | 0 (Reset) | Esc (Schliessen)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ThemenSuche - Autocomplete search for Abitur themes
|
||||
* Features debounced API calls, suggestion display, and keyboard navigation
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Search, X, Loader2 } from 'lucide-react'
|
||||
import type { ThemaSuggestion } from '@/lib/education/abitur-archiv-types'
|
||||
import { POPULAR_THEMES } from '@/lib/education/abitur-archiv-types'
|
||||
|
||||
interface ThemenSucheProps {
|
||||
onSearch: (query: string) => void
|
||||
onClear: () => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function ThemenSuche({
|
||||
onSearch,
|
||||
onClear,
|
||||
placeholder = 'Thema suchen (z.B. Gedichtanalyse, Eroerterung, Drama...)'
|
||||
}: ThemenSucheProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [suggestions, setSuggestions] = useState<ThemaSuggestion[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Debounced API call for suggestions
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
if (query.length >= 2) {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/education/abitur-archiv/suggest?q=${encodeURIComponent(query)}`)
|
||||
const data = await res.json()
|
||||
setSuggestions(data.suggestions || [])
|
||||
setShowDropdown(true)
|
||||
} catch (error) {
|
||||
console.error('Suggest error:', error)
|
||||
// Fallback to popular themes
|
||||
setSuggestions(POPULAR_THEMES.filter(t =>
|
||||
t.label.toLowerCase().includes(query.toLowerCase())
|
||||
))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
} else if (query.length === 0) {
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
} else {
|
||||
setSuggestions([])
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [query])
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!showDropdown || suggestions.length === 0) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.max(prev - 1, -1))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0) {
|
||||
handleSelectSuggestion(suggestions[selectedIndex])
|
||||
} else if (query.trim()) {
|
||||
handleSearch()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
break
|
||||
}
|
||||
}, [showDropdown, suggestions, selectedIndex, query])
|
||||
|
||||
const handleSelectSuggestion = (suggestion: ThemaSuggestion) => {
|
||||
setQuery(suggestion.label)
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
onSearch(suggestion.label)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
if (query.trim()) {
|
||||
onSearch(query.trim())
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setQuery('')
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
onClear()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (query.length === 0) {
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
}
|
||||
setShowDropdown(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Search Input */}
|
||||
<div className="relative flex items-center">
|
||||
<div className="absolute left-4 text-slate-400">
|
||||
{loading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setSelectedIndex(-1)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-12 pr-24 py-3 text-lg border border-slate-300 rounded-xl
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
bg-white shadow-sm"
|
||||
/>
|
||||
<div className="absolute right-2 flex items-center gap-2">
|
||||
{query && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg"
|
||||
title="Suche loeschen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={!query.trim()}
|
||||
className="px-4 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700
|
||||
disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions Dropdown */}
|
||||
{showDropdown && suggestions.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl border border-slate-200
|
||||
shadow-lg z-50 max-h-80 overflow-y-auto"
|
||||
>
|
||||
<div className="p-2">
|
||||
{query.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||||
Beliebte Themen
|
||||
</div>
|
||||
)}
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={`${suggestion.aufgabentyp}-${suggestion.label}`}
|
||||
onClick={() => handleSelectSuggestion(suggestion)}
|
||||
className={`w-full px-3 py-2.5 text-left rounded-lg flex items-center justify-between
|
||||
transition-colors ${
|
||||
index === selectedIndex
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Search className="w-4 h-4 text-slate-400" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">{suggestion.label}</div>
|
||||
{suggestion.kategorie && (
|
||||
<div className="text-xs text-slate-500">{suggestion.kategorie}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-slate-400">
|
||||
{suggestion.count} Dokumente
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Theme Tags */}
|
||||
{!showDropdown && query.length === 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="text-sm text-slate-500">Vorschlaege:</span>
|
||||
{POPULAR_THEMES.slice(0, 5).map((theme) => (
|
||||
<button
|
||||
key={theme.aufgabentyp}
|
||||
onClick={() => handleSelectSuggestion(theme)}
|
||||
className="px-3 py-1 text-sm bg-slate-100 text-slate-700 rounded-full
|
||||
hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
{theme.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user