fix: blurhash cache miss, design dropdown visibility + add Hauptwörter panel

- DrawCanvas: call onImageLoad for cached images (fixes permanent blur)
- DrawIt: reset imageLoaded immediately on navigation (shows blur right away)
- Design dropdown: always visible when picture loaded, no longer gated on designOptions or objects count
- Fertigstellen: always visible when picture loaded, disabled when no objects
- Hauptwörter: new panel above object list for picture-level words (db_words_db_pictures)
- Backend: DELETE /api/directus/db-pictures/<id>/words/<junction_id>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 18:47:14 +02:00
parent 17918a414b
commit 5424fea8e1
4 changed files with 114 additions and 6 deletions

7
app.py
View File

@@ -1814,6 +1814,13 @@ def directus_db_picture_words(pic_id):
return jsonify({"ok": True, "saved": saved}) return jsonify({"ok": True, "saved": saved})
@app.route("/api/directus/db-pictures/<pic_id>/words/<junction_id>", methods=["DELETE"])
def directus_db_picture_word_delete(pic_id, junction_id):
token = request.headers.get("Authorization", "")
_directus("DELETE", f"/items/db_words_db_pictures/{junction_id}", token)
return jsonify({"ok": True})
@app.route("/api/directus/db-objects/<obj_id>/pairs", methods=["GET", "POST"]) @app.route("/api/directus/db-objects/<obj_id>/pairs", methods=["GET", "POST"])
def directus_db_object_pairs(obj_id): def directus_db_object_pairs(obj_id):
token = request.headers.get("Authorization", "") token = request.headers.get("Authorization", "")

View File

@@ -498,6 +498,33 @@ export async function deleteDbObjectWord(
return res.json() return res.json()
} }
// Add a single word to a picture
export async function addDbPictureWord(
pictureId: string,
word: { titel_de: string; level: number },
token: string
) {
const res = await fetch(`/api/directus/db-pictures/${pictureId}/words`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: token },
body: JSON.stringify({ words: [word] }),
})
return res.json()
}
// Remove a single word from a picture by junction ID
export async function deleteDbPictureWord(
pictureId: string,
junctionId: string | number,
token: string
) {
const res = await fetch(`/api/directus/db-pictures/${pictureId}/words/${junctionId}`, {
method: 'DELETE',
headers: { Authorization: token },
})
return res.json()
}
export async function updateDbPair( export async function updateDbPair(
pairId: string, pairId: string,
payload: { payload: {

View File

@@ -272,6 +272,7 @@ export default forwardRef<DrawCanvasHandle, Props>(function DrawCanvas(
const cached = imageCache.get(imageSrc) const cached = imageCache.get(imageSrc)
if (cached) { if (cached) {
applyImage(cached) applyImage(cached)
onImageLoad?.() // ← notify parent so blurhash hides for cached images
return return
} }

View File

@@ -12,6 +12,9 @@ import {
getDbObjectWords, getDbObjectWords,
addDbObjectWord, addDbObjectWord,
deleteDbObjectWord, deleteDbObjectWord,
getDbPictureWords,
addDbPictureWord,
deleteDbPictureWord,
directusAssetUrl, directusAssetUrl,
getDesignOptions, getDesignOptions,
} from '../api' } from '../api'
@@ -59,6 +62,8 @@ export default function DrawIt() {
const [statusMsg, setStatusMsg] = useState('') const [statusMsg, setStatusMsg] = useState('')
const [statusError, setStatusError] = useState(false) const [statusError, setStatusError] = useState(false)
const [imageLoaded, setImageLoaded] = useState(false) const [imageLoaded, setImageLoaded] = useState(false)
const [pictureWords, setPictureWords] = useState<DbWord[]>([])
const [pictureWordInput, setPictureWordInput] = useState('')
const canvasRef = useRef<DrawCanvasHandle>(null) const canvasRef = useRef<DrawCanvasHandle>(null)
@@ -68,6 +73,11 @@ export default function DrawIt() {
return () => clearTimeout(t) return () => clearTimeout(t)
}, [currentIndex]) }, [currentIndex])
// Reset imageLoaded immediately on navigation so blurhash shows right away
useEffect(() => {
setImageLoaded(false)
}, [currentIndex])
const currentPicture: DbPicture | null = const currentPicture: DbPicture | null =
debouncedIndex >= 0 && debouncedIndex < pictureList.length ? pictureList[debouncedIndex] : null debouncedIndex >= 0 && debouncedIndex < pictureList.length ? pictureList[debouncedIndex] : null
@@ -93,13 +103,15 @@ export default function DrawIt() {
getDesignOptions(token).then(setDesignOptions).catch(console.error) getDesignOptions(token).then(setDesignOptions).catch(console.error)
}, [token]) }, [token])
// Load objects when picture changes, then load each object's word // Load objects when picture changes, then load each object's words and picture words
useEffect(() => { useEffect(() => {
if (!currentPicture || !token) { if (!currentPicture || !token) {
setObjects([]); setSelectedObjectId(null) setObjects([]); setSelectedObjectId(null)
setObjectWords({}) setObjectWords({})
setWordInputs({}) setWordInputs({})
setImageLoaded(false) setImageLoaded(false)
setPictureWords([])
setPictureWordInput('')
return return
} }
getDbObjects(currentPicture.id, token) getDbObjects(currentPicture.id, token)
@@ -118,6 +130,10 @@ export default function DrawIt() {
}) })
}) })
.catch(console.error) .catch(console.error)
// Load picture-level words
getDbPictureWords(currentPicture.id, token)
.then(words => setPictureWords(words))
.catch(() => setPictureWords([]))
}, [currentPicture?.id, token]) }, [currentPicture?.id, token])
const showStatus = (msg: string, isError = false) => { const showStatus = (msg: string, isError = false) => {
@@ -233,6 +249,26 @@ export default function DrawIt() {
} }
} }
const handleAddPictureWord = async () => {
if (!token || !currentPicture) return
const titel_de = pictureWordInput.trim()
if (!titel_de) return
try {
await addDbPictureWord(currentPicture.id, { titel_de, level: 50 }, token)
const words = await getDbPictureWords(currentPicture.id, token)
setPictureWords(words)
setPictureWordInput('')
} catch (e) { showStatus('Fehler beim Hinzufügen.', true) }
}
const handleRemovePictureWord = async (junctionId: string | number) => {
if (!token || !currentPicture) return
try {
await deleteDbPictureWord(currentPicture.id, junctionId, token)
setPictureWords(prev => prev.filter(w => w.junction_id !== junctionId))
} catch (e) { showStatus('Fehler beim Entfernen.', true) }
}
const deleteObject = async (objId: string) => { const deleteObject = async (objId: string) => {
if (!token) return if (!token) return
try { try {
@@ -270,6 +306,44 @@ export default function DrawIt() {
<div className="workspace"> <div className="workspace">
{/* Left sidebar: saved objects */} {/* Left sidebar: saved objects */}
<aside className="sidebar"> <aside className="sidebar">
{currentPicture && (
<div className="sidebar-panel">
<h3 className="sidebar-heading">Hauptwörter</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 }}>
{pictureWords.length === 0 && (
<span style={{ fontSize: 11, color: 'var(--text-2)' }}>Noch keine Hauptwörter</span>
)}
{pictureWords.map(w => (
<span key={w.junction_id} style={{
display: 'flex', alignItems: 'center', gap: 3,
padding: '2px 8px', background: '#dcfce7', color: '#166534',
borderRadius: 9999, fontSize: 11,
}}>
{w.titel_de}
<button
onClick={() => handleRemovePictureWord(w.junction_id!)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#16a34a', padding: 0, fontSize: 13 }}
>×</button>
</span>
))}
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="text"
value={pictureWordInput}
onChange={e => setPictureWordInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddPictureWord() }}
placeholder="Hauptwort hinzufügen…"
style={{ flex: 1, padding: '4px 8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12 }}
/>
<button
onClick={handleAddPictureWord}
style={{ padding: '4px 10px', borderRadius: 'var(--r-sm)', background: '#16a34a', color: '#fff', border: 'none', cursor: 'pointer', fontSize: 12 }}
>+</button>
</div>
</div>
)}
<div className="sidebar-panel" style={{ flex: 1 }}> <div className="sidebar-panel" style={{ flex: 1 }}>
<h3 className="sidebar-heading"> <h3 className="sidebar-heading">
Objekte Objekte
@@ -338,14 +412,13 @@ export default function DrawIt() {
)} )}
</div> </div>
{currentPicture && designOptions.length > 0 && ( {currentPicture && (
<div className="sidebar-panel"> <div className="sidebar-panel">
<h3 className="sidebar-heading">Design</h3> <h3 className="sidebar-heading">Design</h3>
<select <select
value={currentPicture.design || ''} value={currentPicture.design || ''}
onChange={async e => { onChange={async e => {
const value = e.target.value const value = e.target.value
// Optimistic update
setPictureList(prev => prev.map(p => p.id === currentPicture.id ? { ...p, design: value || null } : p)) setPictureList(prev => prev.map(p => p.id === currentPicture.id ? { ...p, design: value || null } : p))
try { try {
await fetch(`/api/directus/db-pictures/${currentPicture.id}`, { await fetch(`/api/directus/db-pictures/${currentPicture.id}`, {
@@ -369,13 +442,13 @@ export default function DrawIt() {
</div> </div>
)} )}
{objects.length > 0 && ( {currentPicture && (
<div className="sidebar-panel"> <div className="sidebar-panel">
<button <button
className="btn-primary btn-sm btn-block" className="btn-primary btn-sm btn-block"
onClick={finishPicture} onClick={finishPicture}
disabled={finishing} disabled={finishing || objects.length === 0}
style={{ background: 'var(--success, #16a34a)' }} style={{ background: objects.length > 0 ? 'var(--success, #16a34a)' : undefined, opacity: objects.length === 0 ? 0.5 : 1 }}
> >
{finishing ? 'Wird fertiggestellt…' : '✅ Fertigstellen'} {finishing ? 'Wird fertiggestellt…' : '✅ Fertigstellen'}
</button> </button>