feat: vocab worksheet — full-quality images, insert triangles, dynamic columns
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 28s
CI / test-go-edu-search (push) Successful in 26s
CI / test-python-klausur (push) Failing after 1m50s
CI / test-python-agent-core (push) Successful in 15s
CI / test-nodejs-website (push) Successful in 15s
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 28s
CI / test-go-edu-search (push) Successful in 26s
CI / test-python-klausur (push) Failing after 1m50s
CI / test-python-agent-core (push) Successful in 15s
CI / test-nodejs-website (push) Successful in 15s
- Original pages rendered at full resolution (pdf-page-image endpoint, zoom=2.0) instead of downscaled thumbnails - Insert-row triangles on left margin between every row (hover to reveal) - Dynamic extra columns: "+" button in header adds custom columns (e.g. Aussprache, Wortart), removable via hover-x on column header - Extra columns stored per-page (pageExtraColumns state) so different source pages can have different column structures - Grid template adjusts dynamically based on number of columns Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,13 @@ interface VocabularyEntry {
|
|||||||
word_type?: string
|
word_type?: string
|
||||||
source_page?: number
|
source_page?: number
|
||||||
selected?: boolean
|
selected?: boolean
|
||||||
|
extras?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic column definition (per source page)
|
||||||
|
interface ExtraColumn {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
@@ -132,6 +139,9 @@ export default function VocabWorksheetPage() {
|
|||||||
const [isLoadingThumbnails, setIsLoadingThumbnails] = useState(false)
|
const [isLoadingThumbnails, setIsLoadingThumbnails] = useState(false)
|
||||||
const [excludedPages, setExcludedPages] = useState<number[]>([])
|
const [excludedPages, setExcludedPages] = useState<number[]>([])
|
||||||
|
|
||||||
|
// Dynamic extra columns per source page (key: page number, value: extra columns)
|
||||||
|
const [pageExtraColumns, setPageExtraColumns] = useState<Record<number, ExtraColumn[]>>({})
|
||||||
|
|
||||||
// Upload state
|
// Upload state
|
||||||
const [uploadedImage, setUploadedImage] = useState<string | null>(null)
|
const [uploadedImage, setUploadedImage] = useState<string | null>(null)
|
||||||
const [isExtracting, setIsExtracting] = useState(false)
|
const [isExtracting, setIsExtracting] = useState(false)
|
||||||
@@ -559,10 +569,63 @@ export default function VocabWorksheetPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update vocabulary entry
|
// Update vocabulary entry
|
||||||
const updateVocabularyEntry = (id: string, field: keyof VocabularyEntry, value: string) => {
|
const updateVocabularyEntry = (id: string, field: string, value: string) => {
|
||||||
setVocabulary(prev => prev.map(v =>
|
setVocabulary(prev => prev.map(v => {
|
||||||
v.id === id ? { ...v, [field]: value } : v
|
if (v.id !== id) return v
|
||||||
))
|
// Check if it's a base field or an extra column
|
||||||
|
if (field === 'english' || field === 'german' || field === 'example_sentence' || field === 'word_type') {
|
||||||
|
return { ...v, [field]: value }
|
||||||
|
}
|
||||||
|
// Extra column
|
||||||
|
return { ...v, extras: { ...(v.extras || {}), [field]: value } }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a custom column for a specific source page (0 = all pages)
|
||||||
|
const addExtraColumn = (sourcePage: number) => {
|
||||||
|
const label = prompt('Spaltenname:')
|
||||||
|
if (!label || !label.trim()) return
|
||||||
|
const key = `extra_${Date.now()}`
|
||||||
|
setPageExtraColumns(prev => ({
|
||||||
|
...prev,
|
||||||
|
[sourcePage]: [...(prev[sourcePage] || []), { key, label: label.trim() }],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a custom column
|
||||||
|
const removeExtraColumn = (sourcePage: number, key: string) => {
|
||||||
|
setPageExtraColumns(prev => ({
|
||||||
|
...prev,
|
||||||
|
[sourcePage]: (prev[sourcePage] || []).filter(c => c.key !== key),
|
||||||
|
}))
|
||||||
|
// Clean up extras from entries
|
||||||
|
setVocabulary(prev => prev.map(v => {
|
||||||
|
if (!v.extras || !(key in v.extras)) return v
|
||||||
|
const { [key]: _, ...rest } = v.extras
|
||||||
|
return { ...v, extras: rest }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get extra columns for a given source page (page-specific + global)
|
||||||
|
const getExtraColumnsForPage = (sourcePage: number): ExtraColumn[] => {
|
||||||
|
const global = pageExtraColumns[0] || []
|
||||||
|
const pageSpecific = pageExtraColumns[sourcePage] || []
|
||||||
|
return [...global, ...pageSpecific]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ALL extra columns across all pages (for unified table header)
|
||||||
|
const getAllExtraColumns = (): ExtraColumn[] => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const result: ExtraColumn[] = []
|
||||||
|
for (const cols of Object.values(pageExtraColumns)) {
|
||||||
|
for (const col of cols) {
|
||||||
|
if (!seen.has(col.key)) {
|
||||||
|
seen.add(col.key)
|
||||||
|
result.push(col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete vocabulary entry
|
// Delete vocabulary entry
|
||||||
@@ -1417,8 +1480,13 @@ export default function VocabWorksheetPage() {
|
|||||||
|
|
||||||
{/* Vocabulary Tab */}
|
{/* Vocabulary Tab */}
|
||||||
{session && activeTab === 'vocabulary' && (
|
{session && activeTab === 'vocabulary' && (
|
||||||
|
{(() => {
|
||||||
|
const extras = getAllExtraColumns()
|
||||||
|
const baseCols = 3 + extras.length // english, german, example + extras
|
||||||
|
const gridCols = `14px 32px 36px repeat(${baseCols}, 1fr) 32px`
|
||||||
|
return (
|
||||||
<div className="flex flex-col lg:flex-row gap-4" style={{ height: 'calc(100vh - 240px)', minHeight: '500px' }}>
|
<div className="flex flex-col lg:flex-row gap-4" style={{ height: 'calc(100vh - 240px)', minHeight: '500px' }}>
|
||||||
{/* Left: Original pages (scrollable, 1/3 width) */}
|
{/* Left: Original pages — full quality */}
|
||||||
<div className={`${glassCard} rounded-2xl p-4 lg:w-1/3 flex flex-col overflow-hidden`}>
|
<div className={`${glassCard} rounded-2xl p-4 lg:w-1/3 flex flex-col overflow-hidden`}>
|
||||||
<h2 className={`text-sm font-semibold mb-3 flex-shrink-0 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
<h2 className={`text-sm font-semibold mb-3 flex-shrink-0 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||||
Original ({(() => { const pp = selectedPages.length > 0 ? selectedPages : [...new Set(vocabulary.map(v => (v.source_page || 1) - 1))]; return pp.length; })()} Seiten)
|
Original ({(() => { const pp = selectedPages.length > 0 ? selectedPages : [...new Set(vocabulary.map(v => (v.source_page || 1) - 1))]; return pp.length; })()} Seiten)
|
||||||
@@ -1429,22 +1497,19 @@ export default function VocabWorksheetPage() {
|
|||||||
? selectedPages
|
? selectedPages
|
||||||
: [...new Set(vocabulary.map(v => (v.source_page || 1) - 1))].sort((a, b) => a - b)
|
: [...new Set(vocabulary.map(v => (v.source_page || 1) - 1))].sort((a, b) => a - b)
|
||||||
|
|
||||||
// Use blob URLs if available, otherwise fall back to direct API URLs
|
|
||||||
const apiBase = getApiBase()
|
const apiBase = getApiBase()
|
||||||
const thumbsToShow = processedPageIndices
|
const pagesToShow = processedPageIndices
|
||||||
.filter(idx => idx >= 0)
|
.filter(idx => idx >= 0)
|
||||||
.map(idx => ({
|
.map(idx => ({
|
||||||
idx,
|
idx,
|
||||||
src: (idx < pagesThumbnails.length && pagesThumbnails[idx])
|
src: session ? `${apiBase}/api/v1/vocab/sessions/${session.id}/pdf-page-image/${idx}` : null,
|
||||||
? pagesThumbnails[idx]
|
|
||||||
: session ? `${apiBase}/api/v1/vocab/sessions/${session.id}/pdf-thumbnail/${idx}?hires=true` : null,
|
|
||||||
}))
|
}))
|
||||||
.filter(t => t.src !== null) as { idx: number; src: string }[]
|
.filter(t => t.src !== null) as { idx: number; src: string }[]
|
||||||
|
|
||||||
if (thumbsToShow.length > 0) {
|
if (pagesToShow.length > 0) {
|
||||||
return thumbsToShow.map(({ idx, src }) => (
|
return pagesToShow.map(({ idx, src }) => (
|
||||||
<div key={idx} className={`relative rounded-xl overflow-hidden border ${isDark ? 'border-white/10' : 'border-black/10'}`}>
|
<div key={idx} className={`relative rounded-xl overflow-hidden border ${isDark ? 'border-white/10' : 'border-black/10'}`}>
|
||||||
<div className={`absolute top-2 left-2 px-2 py-0.5 rounded-lg text-xs font-medium ${isDark ? 'bg-black/60 text-white' : 'bg-white/90 text-slate-700'}`}>
|
<div className={`absolute top-2 left-2 px-2 py-0.5 rounded-lg text-xs font-medium z-10 ${isDark ? 'bg-black/60 text-white' : 'bg-white/90 text-slate-700'}`}>
|
||||||
S. {idx + 1}
|
S. {idx + 1}
|
||||||
</div>
|
</div>
|
||||||
<img src={src} alt={`Seite ${idx + 1}`} className="w-full h-auto" />
|
<img src={src} alt={`Seite ${idx + 1}`} className="w-full h-auto" />
|
||||||
@@ -1535,29 +1600,61 @@ export default function VocabWorksheetPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col flex-1 overflow-hidden">
|
<div className="flex flex-col flex-1 overflow-hidden">
|
||||||
{/* Fixed Header */}
|
{/* Fixed Header */}
|
||||||
<div className={`flex-shrink-0 grid gap-2 px-3 py-2 text-sm font-medium border-b ${isDark ? 'border-white/10 text-white/60' : 'border-black/10 text-slate-500'}`} style={{ gridTemplateColumns: '32px 36px 1fr 1fr 1fr 32px' }}>
|
<div className={`flex-shrink-0 grid gap-1 px-2 py-2 text-sm font-medium border-b items-center ${isDark ? 'border-white/10 text-white/60' : 'border-black/10 text-slate-500'}`} style={{ gridTemplateColumns: gridCols }}>
|
||||||
|
<div>{/* insert-triangle spacer */}</div>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={vocabulary.length > 0 && vocabulary.every(v => v.selected)}
|
checked={vocabulary.length > 0 && vocabulary.every(v => v.selected)}
|
||||||
onChange={toggleAllSelection}
|
onChange={toggleAllSelection}
|
||||||
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 cursor-pointer"
|
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 cursor-pointer"
|
||||||
title="Alle auswählen"
|
title="Alle auswaehlen"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>S.</div>
|
<div>S.</div>
|
||||||
<div>Englisch</div>
|
<div>Englisch</div>
|
||||||
<div>Deutsch</div>
|
<div>Deutsch</div>
|
||||||
<div>Beispiel</div>
|
<div>Beispiel</div>
|
||||||
<div></div>
|
{extras.map(col => (
|
||||||
|
<div key={col.key} className="flex items-center gap-1 group">
|
||||||
|
<span className="truncate">{col.label}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const page = Object.entries(pageExtraColumns).find(([, cols]) => cols.some(c => c.key === col.key))
|
||||||
|
if (page) removeExtraColumn(Number(page[0]), col.key)
|
||||||
|
}}
|
||||||
|
className={`opacity-0 group-hover:opacity-100 transition-opacity ${isDark ? 'text-red-400 hover:text-red-300' : 'text-red-500 hover:text-red-600'}`}
|
||||||
|
title="Spalte entfernen"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => addExtraColumn(0)}
|
||||||
|
className={`p-0.5 rounded transition-colors ${isDark ? 'hover:bg-white/10 text-white/40 hover:text-white/70' : 'hover:bg-slate-200 text-slate-400 hover:text-slate-600'}`}
|
||||||
|
title="Spalte hinzufuegen"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div className="flex-1 overflow-y-auto py-1">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{vocabulary.map((entry, index) => (
|
{vocabulary.map((entry, index) => (
|
||||||
<React.Fragment key={entry.id}>
|
<React.Fragment key={entry.id}>
|
||||||
{/* Vocabulary row */}
|
{/* Vocabulary row */}
|
||||||
<div className={`grid gap-2 px-3 py-1.5 rounded-lg mb-0.5 ${isDark ? 'hover:bg-white/5' : 'hover:bg-black/5'}`} style={{ gridTemplateColumns: '32px 36px 1fr 1fr 1fr 32px' }}>
|
<div className={`grid gap-1 px-2 py-1 items-center ${isDark ? 'hover:bg-white/5' : 'hover:bg-black/5'}`} style={{ gridTemplateColumns: gridCols }}>
|
||||||
|
{/* Insert triangle */}
|
||||||
|
<button
|
||||||
|
onClick={() => addVocabularyEntry(index)}
|
||||||
|
className={`w-3.5 h-3.5 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity ${isDark ? 'text-purple-400' : 'text-purple-500'}`}
|
||||||
|
title="Zeile einfuegen"
|
||||||
|
>
|
||||||
|
<svg className="w-2.5 h-2.5" viewBox="0 0 10 10" fill="currentColor"><polygon points="0,0 10,5 0,10" /></svg>
|
||||||
|
</button>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -1573,21 +1670,31 @@ export default function VocabWorksheetPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={entry.english}
|
value={entry.english}
|
||||||
onChange={(e) => updateVocabularyEntry(entry.id, 'english', e.target.value)}
|
onChange={(e) => updateVocabularyEntry(entry.id, 'english', e.target.value)}
|
||||||
className={`px-2 py-1 rounded-lg border text-sm ${glassInput} focus:outline-none focus:ring-1 focus:ring-purple-500`}
|
className={`px-2 py-1 rounded-lg border text-sm min-w-0 ${glassInput} focus:outline-none focus:ring-1 focus:ring-purple-500`}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={entry.german}
|
value={entry.german}
|
||||||
onChange={(e) => updateVocabularyEntry(entry.id, 'german', e.target.value)}
|
onChange={(e) => updateVocabularyEntry(entry.id, 'german', e.target.value)}
|
||||||
className={`px-2 py-1 rounded-lg border text-sm ${glassInput} focus:outline-none focus:ring-1 focus:ring-purple-500`}
|
className={`px-2 py-1 rounded-lg border text-sm min-w-0 ${glassInput} focus:outline-none focus:ring-1 focus:ring-purple-500`}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={entry.example_sentence || ''}
|
value={entry.example_sentence || ''}
|
||||||
onChange={(e) => updateVocabularyEntry(entry.id, 'example_sentence', e.target.value)}
|
onChange={(e) => updateVocabularyEntry(entry.id, 'example_sentence', e.target.value)}
|
||||||
placeholder="Beispiel"
|
placeholder="Beispiel"
|
||||||
className={`px-2 py-1 rounded-lg border text-sm ${glassInput} focus:outline-none focus:ring-1 focus:ring-purple-500`}
|
className={`px-2 py-1 rounded-lg border text-sm min-w-0 ${glassInput} focus:outline-none focus:ring-1 focus:ring-purple-500`}
|
||||||
/>
|
/>
|
||||||
|
{extras.map(col => (
|
||||||
|
<input
|
||||||
|
key={col.key}
|
||||||
|
type="text"
|
||||||
|
value={(entry.extras && entry.extras[col.key]) || ''}
|
||||||
|
onChange={(e) => updateVocabularyEntry(entry.id, col.key, e.target.value)}
|
||||||
|
placeholder={col.label}
|
||||||
|
className={`px-2 py-1 rounded-lg border text-sm min-w-0 ${glassInput} focus:outline-none focus:ring-1 focus:ring-purple-500`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
<button onClick={() => deleteVocabularyEntry(entry.id)} className={`p-1 rounded-lg ${isDark ? 'hover:bg-red-500/20 text-red-400' : 'hover:bg-red-100 text-red-500'}`}>
|
<button onClick={() => deleteVocabularyEntry(entry.id)} className={`p-1 rounded-lg ${isDark ? 'hover:bg-red-500/20 text-red-400' : 'hover:bg-red-100 text-red-500'}`}>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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" />
|
||||||
@@ -1596,6 +1703,16 @@ export default function VocabWorksheetPage() {
|
|||||||
</div>
|
</div>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
|
{/* Final insert triangle after last row */}
|
||||||
|
<div className="px-2 py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => addVocabularyEntry()}
|
||||||
|
className={`w-3.5 h-3.5 flex items-center justify-center opacity-30 hover:opacity-100 transition-opacity ${isDark ? 'text-purple-400' : 'text-purple-500'}`}
|
||||||
|
title="Zeile am Ende einfuegen"
|
||||||
|
>
|
||||||
|
<svg className="w-2.5 h-2.5" viewBox="0 0 10 10" fill="currentColor"><polygon points="0,0 10,5 0,10" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
@@ -1626,6 +1743,8 @@ export default function VocabWorksheetPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Worksheet Tab */}
|
{/* Worksheet Tab */}
|
||||||
|
|||||||
Reference in New Issue
Block a user