feat(annotate): Words-Frame – Safe Words mit Level per Bild speichern
- Rechte Sidebar in zwei Frames aufgeteilt: Objects (bisherig) + Words (neu) - Words-Frame: Wörter + Level (1–100) per Bild anlegen, dedupliziert via words_pictures Junction - Pending-Words in Primary-Farbe mit inline Level-Edit, gespeicherte Words in neutralem Grau - Save-Button speichert alle pending Words nach Directus (status=draft, title_de, level, picture-Link) - Automatisches Laden der Bild-Words bei Bildwechsel - Backend: GET/POST /api/directus/pictures/<pic_id>/words (words_pictures Junction, _find_or_create_word) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
57
app.py
57
app.py
@@ -113,6 +113,63 @@ def directus_object(obj_id):
|
|||||||
return jsonify(data), status
|
return jsonify(data), status
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/directus/pictures/<pic_id>/words", methods=["GET", "POST"])
|
||||||
|
def directus_picture_words(pic_id):
|
||||||
|
"""Proxy: Safe-Words eines Bildes laden (GET) oder speichern (POST)."""
|
||||||
|
token = request.headers.get("Authorization", "")
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
junc, _ = _directus(
|
||||||
|
"GET",
|
||||||
|
f"/items/words_pictures?filter[pictures_id][_eq]={pic_id}&fields=id,words_id&limit=500",
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
w_ids = [e["words_id"] for e in (junc.get("data") or []) if e.get("words_id")]
|
||||||
|
if not w_ids:
|
||||||
|
return jsonify({"data": []})
|
||||||
|
ids_param = urllib.parse.quote(",".join(w_ids), safe="")
|
||||||
|
w_data, _ = _directus(
|
||||||
|
"GET",
|
||||||
|
f"/items/words?filter[id][_in]={ids_param}&filter[status][_neq]=archived&fields=id,title_de,level,status&limit=500",
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
junc_by_word = {e["words_id"]: e["id"] for e in (junc.get("data") or [])}
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"id": junc_by_word.get(w["id"], ""),
|
||||||
|
"word_id": w["id"],
|
||||||
|
"title_de": w["title_de"],
|
||||||
|
"level": w.get("level") or 50,
|
||||||
|
"status": w.get("status", ""),
|
||||||
|
}
|
||||||
|
for w in (w_data.get("data") or [])
|
||||||
|
]
|
||||||
|
return jsonify({"data": items})
|
||||||
|
|
||||||
|
else: # POST
|
||||||
|
body = request.get_json(force=True, silent=True) or {}
|
||||||
|
words = body.get("words", [])
|
||||||
|
_ensure_junction("words_pictures", "words_id", "pictures_id", token)
|
||||||
|
saved = 0
|
||||||
|
for entry in words:
|
||||||
|
title_de = (entry.get("title_de") or "").strip()
|
||||||
|
level = int(entry.get("level") or 50)
|
||||||
|
if not title_de:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
wid, _ = _find_or_create_word(title_de, level, token)
|
||||||
|
_ensure_link(
|
||||||
|
"words_pictures",
|
||||||
|
{"words_id": wid, "pictures_id": pic_id},
|
||||||
|
{"words_id": wid, "pictures_id": pic_id},
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
saved += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[picture_words] error for '{title_de}': {e}")
|
||||||
|
return jsonify({"ok": True, "saved": saved})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/images", methods=["GET"])
|
@app.route("/api/images", methods=["GET"])
|
||||||
def list_images():
|
def list_images():
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ObjectMeta, Sentence } from './types'
|
import type { ObjectMeta, Sentence, PictureWord } from './types'
|
||||||
|
|
||||||
const DIRECTUS_URL = 'https://db.hejyou.com'
|
const DIRECTUS_URL = 'https://db.hejyou.com'
|
||||||
|
|
||||||
@@ -282,6 +282,30 @@ export async function deleteWord(wId: string, token: string): Promise<void> {
|
|||||||
if (!res.ok) throw new Error('Fehler beim Löschen des Worts')
|
if (!res.ok) throw new Error('Fehler beim Löschen des Worts')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPictureWords(pictureId: string, token: string): Promise<PictureWord[]> {
|
||||||
|
const res = await fetch(`/api/directus/pictures/${pictureId}/words`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error('Fehler beim Laden der Bild-Wörter')
|
||||||
|
return data.data as PictureWord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePictureWords(
|
||||||
|
pictureId: string,
|
||||||
|
words: { title_de: string; level: number }[],
|
||||||
|
token: string
|
||||||
|
): Promise<{ saved: number }> {
|
||||||
|
const res = await fetch(`/api/directus/pictures/${pictureId}/words`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ words }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Fehler beim Speichern der Wörter')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export async function purgeOrphans(objId: string, token: string): Promise<{ orphans_removed: number }> {
|
export async function purgeOrphans(objId: string, token: string): Promise<{ orphans_removed: number }> {
|
||||||
const res = await fetch(`/api/object/${encodeURIComponent(objId)}/purge-orphans`, {
|
const res = await fetch(`/api/object/${encodeURIComponent(objId)}/purge-orphans`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import Topbar from '../components/Topbar'
|
|||||||
import {
|
import {
|
||||||
getDirectusPictures, directusAssetUrl, type DirectusPicture,
|
getDirectusPictures, directusAssetUrl, type DirectusPicture,
|
||||||
getDirectusObjects, createDirectusObject, updateDirectusObject, deleteDirectusObject,
|
getDirectusObjects, createDirectusObject, updateDirectusObject, deleteDirectusObject,
|
||||||
updatePictureStatus,
|
updatePictureStatus, getPictureWords, savePictureWords,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import type { DirectusObject, Selection, CanvasObject } from '../types'
|
import type { DirectusObject, Selection, CanvasObject, PictureWord } from '../types'
|
||||||
|
|
||||||
const ChevronLeftIcon = () => (
|
const ChevronLeftIcon = () => (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
@@ -34,6 +34,13 @@ export default function DrawIt() {
|
|||||||
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
|
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
|
||||||
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
|
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
|
||||||
const [userNotes, setUserNotes] = useState('')
|
const [userNotes, setUserNotes] = useState('')
|
||||||
|
const [safeWords, setSafeWords] = useState<{ title: string; level: number }[]>([])
|
||||||
|
const [safeWordInput, setSafeWordInput] = useState('')
|
||||||
|
const [safeWordLevel, setSafeWordLevel] = useState(50)
|
||||||
|
const [safeWordInputVisible, setSafeWordInputVisible] = useState(false)
|
||||||
|
const safeWordInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [pictureWords, setPictureWords] = useState<PictureWord[]>([])
|
||||||
|
const [savingWords, setSavingWords] = useState(false)
|
||||||
const [parentId, setParentId] = useState<string | null>(null)
|
const [parentId, setParentId] = useState<string | null>(null)
|
||||||
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null)
|
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null)
|
||||||
const [mode, setMode] = useState<'rect' | 'polygon'>('polygon')
|
const [mode, setMode] = useState<'rect' | 'polygon'>('polygon')
|
||||||
@@ -45,6 +52,37 @@ export default function DrawIt() {
|
|||||||
|
|
||||||
const canvasRef = useRef<DrawCanvasHandle>(null)
|
const canvasRef = useRef<DrawCanvasHandle>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (safeWordInputVisible) safeWordInputRef.current?.focus()
|
||||||
|
}, [safeWordInputVisible])
|
||||||
|
|
||||||
|
const addSafeWord = () => {
|
||||||
|
const title = safeWordInput.trim()
|
||||||
|
if (!title || safeWords.some(w => w.title === title) || pictureWords.some(w => w.title_de === title)) {
|
||||||
|
setSafeWordInput(''); return
|
||||||
|
}
|
||||||
|
setSafeWords(prev => [...prev, { title, level: safeWordLevel }])
|
||||||
|
setSafeWordInput('')
|
||||||
|
setSafeWordLevel(50)
|
||||||
|
setSafeWordInputVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSafeWords = async () => {
|
||||||
|
if (!currentPicture || !token || safeWords.length === 0) return
|
||||||
|
setSavingWords(true)
|
||||||
|
try {
|
||||||
|
await savePictureWords(currentPicture.id, safeWords.map(w => ({ title_de: w.title, level: w.level })), token)
|
||||||
|
const updated = await getPictureWords(currentPicture.id, token)
|
||||||
|
setPictureWords(updated)
|
||||||
|
setSafeWords([])
|
||||||
|
showStatus('Wörter gespeichert.')
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern der Wörter.', true)
|
||||||
|
} finally {
|
||||||
|
setSavingWords(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const currentPicture: DirectusPicture | null =
|
const currentPicture: DirectusPicture | null =
|
||||||
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
|
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
|
||||||
|
|
||||||
@@ -65,10 +103,17 @@ export default function DrawIt() {
|
|||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentPicture || !token) { setObjects([]); setSelectedObjectId(null); return }
|
if (!currentPicture || !token) {
|
||||||
|
setObjects([]); setSelectedObjectId(null)
|
||||||
|
setPictureWords([]); setSafeWords([])
|
||||||
|
return
|
||||||
|
}
|
||||||
getDirectusObjects(currentPicture.id, token)
|
getDirectusObjects(currentPicture.id, token)
|
||||||
.then(objs => { setObjects(objs.map(o => ({ ...o, visible: true }))); setSelectedObjectId(null) })
|
.then(objs => { setObjects(objs.map(o => ({ ...o, visible: true }))); setSelectedObjectId(null) })
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
|
getPictureWords(currentPicture.id, token)
|
||||||
|
.then(setPictureWords)
|
||||||
|
.catch(console.error)
|
||||||
}, [currentPicture?.id, token])
|
}, [currentPicture?.id, token])
|
||||||
|
|
||||||
const showStatus = (msg: string, isError = false) => {
|
const showStatus = (msg: string, isError = false) => {
|
||||||
@@ -372,6 +417,123 @@ export default function DrawIt() {
|
|||||||
{status && <div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{status}</div>}
|
{status && <div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{status}</div>}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
{/* Words sidebar */}
|
||||||
|
<aside className="sidebar sidebar--words">
|
||||||
|
<div className="sidebar-panel">
|
||||||
|
<h3 className="sidebar-heading" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span>Words{(pictureWords.length + safeWords.length) > 0 && <span className="badge" style={{ marginLeft: 6 }}>{pictureWords.length + safeWords.length}</span>}</span>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ width: 22, height: 22, borderRadius: 'var(--r-sm)', fontSize: 16, lineHeight: 1, padding: 0 }}
|
||||||
|
onClick={() => setSafeWordInputVisible(v => !v)}
|
||||||
|
title="Word hinzufügen"
|
||||||
|
>+</button>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{safeWordInputVisible && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
|
||||||
|
<input
|
||||||
|
ref={safeWordInputRef}
|
||||||
|
value={safeWordInput}
|
||||||
|
onChange={e => setSafeWordInput(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') addSafeWord()
|
||||||
|
if (e.key === 'Escape') { setSafeWordInputVisible(false); setSafeWordInput('') }
|
||||||
|
}}
|
||||||
|
placeholder="Wort…"
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '5px 8px', borderRadius: 'var(--r-sm)',
|
||||||
|
border: '1px solid var(--border)', background: 'var(--surface-2)',
|
||||||
|
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<label style={{ fontSize: 11, color: 'var(--text-2)', whiteSpace: 'nowrap' }}>Level</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1} max={100}
|
||||||
|
value={safeWordLevel}
|
||||||
|
onChange={e => setSafeWordLevel(Math.min(100, Math.max(1, Number(e.target.value))))}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '4px 6px', borderRadius: 'var(--r-sm)',
|
||||||
|
border: '1px solid var(--border)', background: 'var(--surface-2)',
|
||||||
|
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="btn-primary btn-sm" style={{ padding: '4px 8px' }} onClick={addSafeWord}>✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Saved words from Directus */}
|
||||||
|
{pictureWords.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: safeWords.length > 0 ? 8 : 0 }}>
|
||||||
|
{pictureWords.map(w => (
|
||||||
|
<div key={w.word_id} style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '4px 8px', borderRadius: 'var(--r-sm)',
|
||||||
|
background: 'var(--surface-2)', border: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-1)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{w.title_de}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-2)', marginLeft: 6, flexShrink: 0 }}>L{w.level}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending new words */}
|
||||||
|
{safeWords.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{safeWords.map((w, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '4px 8px', borderRadius: 'var(--r-sm)',
|
||||||
|
background: 'var(--primary-muted)', border: '1px solid color-mix(in srgb, var(--primary) 30%, transparent)',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--primary)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{w.title}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1} max={100}
|
||||||
|
value={w.level}
|
||||||
|
onChange={e => setSafeWords(prev => prev.map((x, j) => j === i ? { ...x, level: Math.min(100, Math.max(1, Number(e.target.value))) } : x))}
|
||||||
|
style={{
|
||||||
|
width: 44, padding: '2px 4px', borderRadius: 'var(--r-sm)',
|
||||||
|
border: '1px solid color-mix(in srgb, var(--primary) 40%, transparent)',
|
||||||
|
background: 'var(--surface)', color: 'var(--primary)',
|
||||||
|
fontFamily: 'var(--font)', fontSize: 11, textAlign: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setSafeWords(prev => prev.filter((_, j) => j !== i))}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--primary)', padding: 0, lineHeight: 1, fontSize: 14, flexShrink: 0 }}
|
||||||
|
title="Entfernen"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pictureWords.length === 0 && safeWords.length === 0 && (
|
||||||
|
<div className="empty-state" style={{ fontSize: 12 }}>Noch keine Words</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar-panel">
|
||||||
|
<button
|
||||||
|
className="btn-primary btn-sm btn-block"
|
||||||
|
onClick={saveSafeWords}
|
||||||
|
disabled={safeWords.length === 0 || savingWords || !currentPicture}
|
||||||
|
>
|
||||||
|
{savingWords ? 'Speichere…' : `Save${safeWords.length > 0 ? ` (${safeWords.length})` : ''}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ export interface DirectusObject {
|
|||||||
visible?: boolean // local UI state only
|
visible?: boolean // local UI state only
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Word linked to a picture (loaded from Directus via words_pictures junction)
|
||||||
|
export interface PictureWord {
|
||||||
|
id: string // junction row id
|
||||||
|
word_id: string
|
||||||
|
title_de: string
|
||||||
|
level: number
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
// Legacy — still used by GenerateIt
|
// Legacy — still used by GenerateIt
|
||||||
export interface ObjectMeta {
|
export interface ObjectMeta {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
Reference in New Issue
Block a user