Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 29s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 2m24s
CI / test-python-agent-core (push) Successful in 22s
CI / test-nodejs-website (push) Successful in 20s
Phase 1 of the clean architecture refactor: Replaces the 751-line ocr-overlay monolith with a modular pipeline. Each step gets its own component file. Frontend: /ai/ocr-kombi route with 11 steps (Upload, Orientation, PageSplit, Deskew, Dewarp, ContentCrop, OCR, Structure, GridBuild, GridReview, GroundTruth). Session list supports document grouping for multi-page uploads. Backend: New ocr_kombi/ module with multi-page PDF upload (splits PDF into N sessions with shared document_group_id). DB migration adds document_group_id and page_number columns. Old /ai/ocr-overlay remains fully functional for A/B testing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { DOCUMENT_CATEGORIES, type DocumentCategory } from '@/app/(admin)/ai/ocr-pipeline/types'
|
|
import type { SessionListItem, DocumentGroupView } from '@/app/(admin)/ai/ocr-kombi/useKombiPipeline'
|
|
|
|
const KLAUSUR_API = '/klausur-api'
|
|
|
|
interface SessionListProps {
|
|
items: (SessionListItem | DocumentGroupView)[]
|
|
loading: boolean
|
|
activeSessionId: string | null
|
|
onOpenSession: (sid: string) => void
|
|
onNewSession: () => void
|
|
onDeleteSession: (sid: string) => void
|
|
onRenameSession: (sid: string, newName: string) => void
|
|
onUpdateCategory: (sid: string, category: DocumentCategory) => void
|
|
}
|
|
|
|
function isGroup(item: SessionListItem | DocumentGroupView): item is DocumentGroupView {
|
|
return 'group_id' in item
|
|
}
|
|
|
|
export function SessionList({
|
|
items,
|
|
loading,
|
|
activeSessionId,
|
|
onOpenSession,
|
|
onNewSession,
|
|
onDeleteSession,
|
|
onRenameSession,
|
|
onUpdateCategory,
|
|
}: SessionListProps) {
|
|
const [editingName, setEditingName] = useState<string | null>(null)
|
|
const [editNameValue, setEditNameValue] = useState('')
|
|
const [editingCategory, setEditingCategory] = useState<string | null>(null)
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
|
|
|
const toggleGroup = (groupId: string) => {
|
|
setExpandedGroups(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(groupId)) next.delete(groupId)
|
|
else next.add(groupId)
|
|
return next
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Sessions ({items.length})
|
|
</h3>
|
|
<button
|
|
onClick={onNewSession}
|
|
className="text-xs px-3 py-1.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
|
|
>
|
|
+ Neue Session
|
|
</button>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="text-sm text-gray-400 py-2">Lade Sessions...</div>
|
|
) : items.length === 0 ? (
|
|
<div className="text-sm text-gray-400 py-2">Noch keine Sessions vorhanden.</div>
|
|
) : (
|
|
<div className="space-y-1.5 max-h-[320px] overflow-y-auto">
|
|
{items.map(item =>
|
|
isGroup(item) ? (
|
|
<GroupRow
|
|
key={item.group_id}
|
|
group={item}
|
|
expanded={expandedGroups.has(item.group_id)}
|
|
activeSessionId={activeSessionId}
|
|
onToggle={() => toggleGroup(item.group_id)}
|
|
onOpenSession={onOpenSession}
|
|
onDeleteSession={onDeleteSession}
|
|
/>
|
|
) : (
|
|
<SessionRow
|
|
key={item.id}
|
|
session={item}
|
|
isActive={activeSessionId === item.id}
|
|
editingName={editingName}
|
|
editNameValue={editNameValue}
|
|
editingCategory={editingCategory}
|
|
onOpenSession={() => onOpenSession(item.id)}
|
|
onStartRename={() => {
|
|
setEditNameValue(item.name || item.filename)
|
|
setEditingName(item.id)
|
|
}}
|
|
onFinishRename={(newName) => {
|
|
onRenameSession(item.id, newName)
|
|
setEditingName(null)
|
|
}}
|
|
onCancelRename={() => setEditingName(null)}
|
|
onEditNameChange={setEditNameValue}
|
|
onToggleCategory={() => setEditingCategory(editingCategory === item.id ? null : item.id)}
|
|
onUpdateCategory={(cat) => {
|
|
onUpdateCategory(item.id, cat)
|
|
setEditingCategory(null)
|
|
}}
|
|
onDelete={() => {
|
|
if (confirm('Session loeschen?')) onDeleteSession(item.id)
|
|
}}
|
|
/>
|
|
)
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---- Group row (multi-page document) ----
|
|
|
|
function GroupRow({
|
|
group,
|
|
expanded,
|
|
activeSessionId,
|
|
onToggle,
|
|
onOpenSession,
|
|
onDeleteSession,
|
|
}: {
|
|
group: DocumentGroupView
|
|
expanded: boolean
|
|
activeSessionId: string | null
|
|
onToggle: () => void
|
|
onOpenSession: (sid: string) => void
|
|
onDeleteSession: (sid: string) => void
|
|
}) {
|
|
const isActive = group.sessions.some(s => s.id === activeSessionId)
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
onClick={onToggle}
|
|
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm cursor-pointer transition-colors ${
|
|
isActive
|
|
? 'bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700'
|
|
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
|
}`}
|
|
>
|
|
<span className="text-base">{expanded ? '\u25BC' : '\u25B6'}</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="truncate font-medium text-gray-700 dark:text-gray-300">
|
|
{group.title}
|
|
</div>
|
|
<div className="text-xs text-gray-400">
|
|
{group.page_count} Seiten
|
|
</div>
|
|
</div>
|
|
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-blue-600 dark:text-blue-400">
|
|
Dokument
|
|
</span>
|
|
</div>
|
|
|
|
{expanded && (
|
|
<div className="ml-6 mt-1 space-y-1 border-l-2 border-gray-200 dark:border-gray-700 pl-3">
|
|
{group.sessions.map(s => (
|
|
<div
|
|
key={s.id}
|
|
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs cursor-pointer transition-colors ${
|
|
activeSessionId === s.id
|
|
? 'bg-teal-50 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300'
|
|
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 text-gray-600 dark:text-gray-400'
|
|
}`}
|
|
onClick={() => onOpenSession(s.id)}
|
|
>
|
|
{/* Thumbnail */}
|
|
<div className="flex-shrink-0 w-8 h-8 rounded overflow-hidden bg-gray-100 dark:bg-gray-700">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${s.id}/thumbnail?size=64`}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
loading="lazy"
|
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
|
/>
|
|
</div>
|
|
<span className="truncate flex-1">S. {s.page_number || '?'}</span>
|
|
<span className="text-[10px] text-gray-400">Step {s.current_step}</span>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
if (confirm('Seite loeschen?')) onDeleteSession(s.id)
|
|
}}
|
|
className="p-0.5 text-gray-400 hover:text-red-500"
|
|
title="Loeschen"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" 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>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---- Single session row ----
|
|
|
|
function SessionRow({
|
|
session,
|
|
isActive,
|
|
editingName,
|
|
editNameValue,
|
|
editingCategory,
|
|
onOpenSession,
|
|
onStartRename,
|
|
onFinishRename,
|
|
onCancelRename,
|
|
onEditNameChange,
|
|
onToggleCategory,
|
|
onUpdateCategory,
|
|
onDelete,
|
|
}: {
|
|
session: SessionListItem
|
|
isActive: boolean
|
|
editingName: string | null
|
|
editNameValue: string
|
|
editingCategory: string | null
|
|
onOpenSession: () => void
|
|
onStartRename: () => void
|
|
onFinishRename: (name: string) => void
|
|
onCancelRename: () => void
|
|
onEditNameChange: (val: string) => void
|
|
onToggleCategory: () => void
|
|
onUpdateCategory: (cat: DocumentCategory) => void
|
|
onDelete: () => void
|
|
}) {
|
|
const catInfo = DOCUMENT_CATEGORIES.find(c => c.value === session.document_category)
|
|
const isEditing = editingName === session.id
|
|
|
|
return (
|
|
<div
|
|
className={`relative flex items-start gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors cursor-pointer ${
|
|
isActive
|
|
? 'bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700'
|
|
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
|
}`}
|
|
>
|
|
{/* Thumbnail */}
|
|
<div
|
|
className="flex-shrink-0 w-12 h-12 rounded-md overflow-hidden bg-gray-100 dark:bg-gray-700"
|
|
onClick={onOpenSession}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${session.id}/thumbnail?size=96`}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
loading="lazy"
|
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0" onClick={onOpenSession}>
|
|
{isEditing ? (
|
|
<input
|
|
autoFocus
|
|
value={editNameValue}
|
|
onChange={(e) => onEditNameChange(e.target.value)}
|
|
onBlur={() => onFinishRename(editNameValue)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') onFinishRename(editNameValue)
|
|
if (e.key === 'Escape') onCancelRename()
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="w-full px-1 py-0.5 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
|
|
/>
|
|
) : (
|
|
<div className="truncate font-medium text-gray-700 dark:text-gray-300">
|
|
{session.name || session.filename}
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
navigator.clipboard.writeText(session.id)
|
|
const btn = e.currentTarget
|
|
btn.textContent = 'Kopiert!'
|
|
setTimeout(() => { btn.textContent = `ID: ${session.id.slice(0, 8)}` }, 1500)
|
|
}}
|
|
className="text-[10px] font-mono text-gray-400 hover:text-teal-500 transition-colors"
|
|
title={`Volle ID: ${session.id} — Klick zum Kopieren`}
|
|
>
|
|
ID: {session.id.slice(0, 8)}
|
|
</button>
|
|
<div className="text-xs text-gray-400 mt-0.5">
|
|
{new Date(session.created_at).toLocaleDateString('de-DE', {
|
|
day: '2-digit', month: '2-digit', year: '2-digit',
|
|
hour: '2-digit', minute: '2-digit',
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Category badge */}
|
|
<div className="flex flex-col gap-1 items-end flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
onClick={onToggleCategory}
|
|
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition-colors ${
|
|
catInfo
|
|
? 'bg-teal-50 dark:bg-teal-900/30 border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300'
|
|
: 'bg-gray-50 dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-400 hover:text-gray-600'
|
|
}`}
|
|
title="Kategorie setzen"
|
|
>
|
|
{catInfo ? `${catInfo.icon} ${catInfo.label}` : '+ Kategorie'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex flex-col gap-0.5 flex-shrink-0">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onStartRename() }}
|
|
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
title="Umbenennen"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onDelete() }}
|
|
className="p-1 text-gray-400 hover:text-red-500"
|
|
title="Loeschen"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" 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>
|
|
|
|
{/* Category dropdown */}
|
|
{editingCategory === session.id && (
|
|
<div
|
|
className="absolute right-0 top-full mt-1 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-64"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{DOCUMENT_CATEGORIES.map(cat => (
|
|
<button
|
|
key={cat.value}
|
|
onClick={() => onUpdateCategory(cat.value)}
|
|
className={`text-xs px-2 py-1.5 rounded-md text-left transition-colors ${
|
|
session.document_category === cat.value
|
|
? 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300'
|
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{cat.icon} {cat.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|