Add Vocabulary Learning Platform (Phase 1: DB + API + Editor)
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 59s
CI / test-go-edu-search (push) Successful in 45s
CI / test-python-klausur (push) Failing after 3m7s
CI / test-python-agent-core (push) Successful in 24s
CI / test-nodejs-website (push) Successful in 31s
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 59s
CI / test-go-edu-search (push) Successful in 45s
CI / test-python-klausur (push) Failing after 3m7s
CI / test-python-agent-core (push) Successful in 24s
CI / test-nodejs-website (push) Successful in 31s
Strategic pivot: Studio-v2 becomes a language learning platform. Compliance guardrail added to CLAUDE.md — no scan/OCR of third-party content in customer frontend. Upload of OWN materials remains allowed. Phase 1.1 — vocabulary_db.py: PostgreSQL model for 160k+ words with english, german, IPA, syllables, examples, images, audio, difficulty, tags, translations (multilingual). Trigram search index. Phase 1.2 — vocabulary_api.py: Search, browse, filters, bulk import, learning unit creation from word selection. Creates QA items with enhanced fields (IPA, syllables, image, audio) for flashcards. Phase 1.3 — /vocabulary page: Search bar with POS/difficulty filters, word cards with audio buttons, unit builder sidebar. Teacher selects words → creates learning unit → redirects to flashcards. Sidebar: Added "Woerterbuch" (/vocabulary) and "Lernmodule" (/learn). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
319
studio-v2/app/vocabulary/page.tsx
Normal file
319
studio-v2/app/vocabulary/page.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { AudioButton } from '@/components/learn/AudioButton'
|
||||
|
||||
interface VocabWord {
|
||||
id: string
|
||||
english: string
|
||||
german: string
|
||||
ipa_en: string
|
||||
ipa_de: string
|
||||
part_of_speech: string
|
||||
syllables_en: string[]
|
||||
syllables_de: string[]
|
||||
example_en: string
|
||||
example_de: string
|
||||
image_url: string
|
||||
difficulty: number
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
function getBackendUrl() {
|
||||
if (typeof window === 'undefined') return 'http://localhost:8001'
|
||||
const { hostname, protocol } = window.location
|
||||
if (hostname === 'localhost') return 'http://localhost:8001'
|
||||
return `${protocol}//${hostname}:8001`
|
||||
}
|
||||
|
||||
export default function VocabularyPage() {
|
||||
const { isDark } = useTheme()
|
||||
const router = useRouter()
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<VocabWord[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [filters, setFilters] = useState<{ tags: string[]; parts_of_speech: string[]; total_words: number }>({ tags: [], parts_of_speech: [], total_words: 0 })
|
||||
const [posFilter, setPosFilter] = useState('')
|
||||
const [diffFilter, setDiffFilter] = useState(0)
|
||||
|
||||
// Unit builder
|
||||
const [selectedWords, setSelectedWords] = useState<VocabWord[]>([])
|
||||
const [unitTitle, setUnitTitle] = useState('')
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const glassCard = isDark
|
||||
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
||||
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
||||
|
||||
const glassInput = isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
||||
|
||||
// Load filters on mount
|
||||
useEffect(() => {
|
||||
fetch(`${getBackendUrl()}/api/vocabulary/filters`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d) setFilters(d) })
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Search with debounce
|
||||
useEffect(() => {
|
||||
if (!query.trim() && !posFilter && !diffFilter) {
|
||||
setResults([])
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
setIsSearching(true)
|
||||
try {
|
||||
let url: string
|
||||
if (query.trim()) {
|
||||
url = `${getBackendUrl()}/api/vocabulary/search?q=${encodeURIComponent(query)}&limit=30`
|
||||
} else {
|
||||
const params = new URLSearchParams({ limit: '30' })
|
||||
if (posFilter) params.set('pos', posFilter)
|
||||
if (diffFilter) params.set('difficulty', String(diffFilter))
|
||||
url = `${getBackendUrl()}/api/vocabulary/browse?${params}`
|
||||
}
|
||||
const resp = await fetch(url)
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
setResults(data.words || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err)
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [query, posFilter, diffFilter])
|
||||
|
||||
const toggleWord = useCallback((word: VocabWord) => {
|
||||
setSelectedWords(prev => {
|
||||
const exists = prev.find(w => w.id === word.id)
|
||||
if (exists) return prev.filter(w => w.id !== word.id)
|
||||
return [...prev, word]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const createUnit = useCallback(async () => {
|
||||
if (!unitTitle.trim() || selectedWords.length === 0) return
|
||||
setIsCreating(true)
|
||||
try {
|
||||
const resp = await fetch(`${getBackendUrl()}/api/vocabulary/units`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: unitTitle,
|
||||
word_ids: selectedWords.map(w => w.id),
|
||||
}),
|
||||
})
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
router.push(`/learn/${data.unit_id}/flashcards`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Create unit failed:', err)
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}, [unitTitle, selectedWords, router])
|
||||
|
||||
const isSelected = (wordId: string) => selectedWords.some(w => w.id === wordId)
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||
isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-blue-50 to-cyan-100'
|
||||
}`}>
|
||||
<div className="relative z-10 p-4"><Sidebar /></div>
|
||||
|
||||
<div className="flex-1 flex flex-col relative z-10 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className={`${glassCard} border-0 border-b`}>
|
||||
<div className="max-w-5xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-blue-500/30' : 'bg-blue-200'}`}>
|
||||
<svg className={`w-6 h-6 ${isDark ? 'text-blue-300' : 'text-blue-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>Woerterbuch</h1>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{filters.total_words > 0 ? `${filters.total_words.toLocaleString()} Woerter` : 'Woerter suchen und Lernunits erstellen'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto w-full px-6 py-6 flex gap-6">
|
||||
{/* Left: Search + Results */}
|
||||
<div className="flex-1 space-y-4">
|
||||
{/* Search Bar */}
|
||||
<div className={`${glassCard} rounded-2xl p-4`}>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Wort suchen (EN oder DE)..."
|
||||
className={`flex-1 px-4 py-3 rounded-xl border outline-none text-lg ${glassInput}`}
|
||||
autoFocus
|
||||
/>
|
||||
<select value={posFilter} onChange={e => setPosFilter(e.target.value)}
|
||||
className={`px-3 py-2 rounded-xl border text-sm ${glassInput}`}>
|
||||
<option value="">Alle Wortarten</option>
|
||||
{filters.parts_of_speech.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
<select value={diffFilter} onChange={e => setDiffFilter(Number(e.target.value))}
|
||||
className={`px-3 py-2 rounded-xl border text-sm ${glassInput}`}>
|
||||
<option value={0}>Alle Level</option>
|
||||
<option value={1}>A1</option>
|
||||
<option value={2}>A2</option>
|
||||
<option value={3}>B1</option>
|
||||
<option value={4}>B2</option>
|
||||
<option value={5}>C1</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{isSearching && (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className={`w-6 h-6 border-2 ${isDark ? 'border-blue-400' : 'border-blue-600'} border-t-transparent rounded-full animate-spin`} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && results.length === 0 && query.trim() && (
|
||||
<div className={`${glassCard} rounded-2xl p-8 text-center`}>
|
||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>Keine Ergebnisse fuer "{query}"</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{results.map(word => (
|
||||
<div
|
||||
key={word.id}
|
||||
className={`${glassCard} rounded-xl p-4 flex items-center gap-4 transition-all cursor-pointer ${
|
||||
isSelected(word.id)
|
||||
? (isDark ? 'ring-2 ring-blue-400/50 bg-blue-500/10' : 'ring-2 ring-blue-500/50 bg-blue-50')
|
||||
: 'hover:shadow-md'
|
||||
}`}
|
||||
onClick={() => toggleWord(word)}
|
||||
>
|
||||
{/* Image or emoji placeholder */}
|
||||
<div className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl flex-shrink-0 ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
|
||||
{word.image_url ? (
|
||||
<img src={word.image_url} alt={word.english} className="w-full h-full object-cover rounded-xl" />
|
||||
) : (
|
||||
<span className="text-xl">📝</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Word info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-bold text-lg ${isDark ? 'text-white' : 'text-slate-900'}`}>{word.english}</span>
|
||||
{word.ipa_en && <span className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{word.ipa_en}</span>}
|
||||
<AudioButton text={word.english} lang="en" isDark={isDark} size="sm" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${isDark ? 'text-white/70' : 'text-slate-600'}`}>{word.german}</span>
|
||||
<AudioButton text={word.german} lang="de" isDark={isDark} size="sm" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{word.part_of_speech && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${isDark ? 'bg-purple-500/20 text-purple-300' : 'bg-purple-100 text-purple-700'}`}>
|
||||
{word.part_of_speech}
|
||||
</span>
|
||||
)}
|
||||
{word.syllables_en.length > 0 && (
|
||||
<span className={`text-xs ${isDark ? 'text-white/30' : 'text-slate-400'}`}>
|
||||
{word.syllables_en.join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Select indicator */}
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||
isSelected(word.id)
|
||||
? 'bg-blue-500 text-white'
|
||||
: isDark ? 'bg-white/10 text-white/30' : 'bg-slate-100 text-slate-300'
|
||||
}`}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d={isSelected(word.id) ? "M5 13l4 4L19 7" : "M12 4v16m8-8H4"} />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Unit Builder */}
|
||||
<div className="w-80 flex-shrink-0">
|
||||
<div className={`${glassCard} rounded-2xl p-5 sticky top-6`}>
|
||||
<h3 className={`text-lg font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Lernunit erstellen
|
||||
</h3>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={unitTitle}
|
||||
onChange={e => setUnitTitle(e.target.value)}
|
||||
placeholder="Titel (z.B. Unit 3 - Food)"
|
||||
className={`w-full px-4 py-2.5 rounded-xl border outline-none text-sm mb-4 ${glassInput}`}
|
||||
/>
|
||||
|
||||
{selectedWords.length === 0 ? (
|
||||
<p className={`text-sm text-center py-6 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Klicke auf Woerter um sie hinzuzufuegen
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-80 overflow-y-auto mb-4">
|
||||
{selectedWords.map((w, i) => (
|
||||
<div key={w.id} className={`flex items-center justify-between px-3 py-2 rounded-lg ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`text-xs w-5 text-center ${isDark ? 'text-white/30' : 'text-slate-400'}`}>{i+1}</span>
|
||||
<span className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>{w.english}</span>
|
||||
<span className={`text-xs truncate ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{w.german}</span>
|
||||
</div>
|
||||
<button onClick={(e) => { e.stopPropagation(); toggleWord(w) }}
|
||||
className={`text-xs ${isDark ? 'text-red-400 hover:text-red-300' : 'text-red-500 hover:text-red-700'}`}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`text-xs mb-3 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{selectedWords.length} Woerter ausgewaehlt
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={createUnit}
|
||||
disabled={isCreating || selectedWords.length === 0 || !unitTitle.trim()}
|
||||
className={`w-full py-3 rounded-xl font-medium transition-all ${
|
||||
selectedWords.length > 0 && unitTitle.trim()
|
||||
? 'bg-gradient-to-r from-blue-500 to-cyan-500 text-white hover:shadow-lg'
|
||||
: isDark ? 'bg-white/5 text-white/30' : 'bg-slate-100 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{isCreating ? 'Wird erstellt...' : 'Lernunit starten'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user