Compare commits

20 Commits

Author SHA1 Message Date
79c6926cec Fix auth login URL and migrate users/me endpoint
- Fix SNAKKIMO_URL.replace('/api','') bug that stripped both /api occurrences
  producing wrong auth URL → use endswith/slice to remove only trailing /api
- Replace directus_users_me with JWT decode (snakkimo has no /users/me endpoint)
  Returns Directus-compatible shape with role.admin_access for UI admin checks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 16:46:38 +02:00
e066ff7420 Migrate app.py from Directus to snakkimo API
- Replace all db_* routes with snakkimo API equivalents
- Stub out Llama AI functions (generate_details, generate_sentence) with 501 error
- Map field names: user_notes↔notes, picture↔picture_link, level↔difficulty_level, statement_de↔positive_sentence_de
- Use word_id as junction_id for M2M deletes (snakkimo uses resource IDs not junction row IDs)
- Normalize db-pictures response to include picture/blurhash/status/design
- Extract unique design values from pictures table for design-options endpoint
- Pair DELETE now also deletes linked statement and question
- FLAG: question words, distractor_words, objects.parent not supported in snakkimo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:31:48 +02:00
d02788bd0e feat: CRM-Dashboard, Content-Verwaltung und Wort-Autocomplete
- Home-Seite nach Login mit Begrüßung und 3 Kacheln (Content erstellen, Content verwalten, User verwalten)
- AuthContext speichert User-Profil + Rolle; AdminRoute blockt Nicht-Admins
- Content verwalten (admin-only): Status-Dashboard pro Collection, Liste/Kachel-View, generisches Edit-Formular
- Nur aktive db_-Collections im Dashboard (alte pictures/objects/words/questions entfernt)
- Wort-Autocomplete in DrawIt: ab dem ersten Buchstaben Vorschläge aus db_words, Tastatur-Navigation, Duplikat-Filter
- Backend: /users/me Proxy, db-words/search Endpoint, generische Collection-Endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:37:48 +02:00
05c62ac414 feat: Bild in Annotieren-View löschen (Eintrag + Datei aus Directus)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:19:51 +02:00
ceaa7eff3c feat: lesbares Placeholder-Format {1.Hund} in Pair-Textfeldern
- Textarea zeigt {1.Hund} (Index + Wort), Directus speichert {uuid.uuid}
- displayToStorage / storageToDisplay Konvertierung bei Save und Edit
- PreviewText-Komponente: Placeholder farblich hervorgehoben (blau)
- Chip-Klick fügt {1.Hund} an Cursor-Position ein
- startEdit konvertiert gespeicherte UUIDs zurück zu lesbarem Format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 21:08:41 +02:00
9a32e4a39b feat: Objekt inline bearbeiten in GenerateIt (Notizen + Wörter)
✏️ Button pro Objekt in der linken Sidebar öffnet Edit-Panel:
- Notizen-Textarea bearbeitbar
- Wörter: Chips mit × + neues Wort hinzufügen (Enter oder +)
- Speichern / Abbrechen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:50:40 +02:00
5a7777f555 fix: Bearer-Token bei addDbObjectWord, deleteDbObjectWord, addDbPictureWord, deleteDbPictureWord, getDesignOptions
Alle fehlenden 'Bearer '-Prefixe ergänzt. Ohne Bearer lehnt Directus die
Requests stillschweigend ab → Wörter wurden nie gespeichert → leer in DB
→ keine Anzeige in GenerateIt → keine Chips im Pair-Form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:41:30 +02:00
22e6b0a5a5 fix: Design-PATCH fehlte Bearer-Token + Race Condition bei Fertigstellen
- Neues updateDbPicture() in api.ts mit korrektem Bearer-Header
- Design-onChange nutzt updateDbPicture statt rohem fetch (kein Bearer-Bug mehr)
- finishPicture sendet status + design in einem PATCH (kein Race Condition)
- app.py: get_json(force=True, silent=True) für db-pictures PATCH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:32:55 +02:00
79d1f2ba21 fix: design-options nutzt Admin-Token für Directus /fields/ Endpoint
/fields/ braucht Admin-Rechte – Session-Token des Users hat keinen Zugriff.
DIRECTUS_ADMIN_TOKEN als Konstante (überschreibbar via Env-Var).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:20:24 +02:00
255ec51858 fix: design-options fallback wenn Directus fields API nicht erreichbar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:15:45 +02:00
5424fea8e1 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>
2026-05-10 18:47:14 +02:00
17918a414b feat: object words in right sidebar + design dropdown
- Words panel moved to right sidebar: shows selected object's words or pending words for new object
- Pending words auto-saved to object after creation
- Remove word chips from left sidebar object cards
- Design dropdown in left sidebar (above Fertigstellen), loads choices dynamically from Directus field metadata
- Include design field in db_pictures GET response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:26:38 +02:00
40c36182f1 feat: multi-word per object + {objectID.wordID} placeholders
- Annotate: multiple words per object via db_objects_db_words M2M, word chips with add/remove per object card
- Generate sidebar: objects shown with comma-separated word list as display name
- Generate pair form: all object words as suggestion chips, click inserts {objectId.wordId} at cursor
- Preview resolves {objectId.wordId} → actual word text
- Backend: POST adds single word (no replace), new DELETE /db-objects/<id>/words/<junctionId>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 16:49:24 +02:00
214f8a2019 feat: object label per object + {obj:UUID} sentence placeholders
- Annotate: per-object single label input (M2M via db_objects_db_words), auto-save on blur, remove picture-level word section
- Generate: object chips insert {obj:UUID} at cursor position in question/statement textarea
- Live preview resolves {obj:UUID} → actual object label
- PairsList display also resolves placeholders
- Remove F/A/B word chip system from pair form (replaced by object placeholders)
- Backend: POST /api/directus/db-objects/<id>/words replaces existing word with single label

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:04:37 +02:00
2595b8d32e feat: individual word link_to (F/A/B), edit form word management, fix picture-based suggestions
- Words in pair form now linkable individually per word (Frage/Aussage/Beide toggle)
- Edit form includes full word management: view existing words with link indicator, remove/restore, add new words with link_to selector
- Fix word suggestions: load from picture words (db_words_db_pictures) instead of object words (always empty)
- Backend PATCH /api/directus/db-pairs/<id> handles words_add with link_to and words_remove with junction IDs
- Level range 1-100 throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:20:21 +02:00
2e6cf094cb feat: object words in left sidebar + suggestions in pair form
- Backend: GET /api/directus/db-objects/<id>/words via db_objects_db_words
- GenerateIt: load objectWords on object select, show as chips in left sidebar
- PairForm: show object words as clickable suggestion chips above word input
  (click to add, greyed out if already added)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:07:42 +02:00
8bcb3b9168 feat: edit and delete pairs in GenerateIt
- Backend: PATCH /api/directus/db-pairs/<id> updates level, statement, question
  (creates question if new, removes if cleared)
- Backend: DELETE /api/directus/db-pairs/<id> removes pair + all junctions,
  questions and statements
- Frontend: inline edit form per pair (level slider, statement, question)
- Frontend: delete button per pair with confirm dialog
- api.ts: updateDbPair, deleteDbPair

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 11:58:57 +02:00
1bbe64db66 fix: level range 1-100 everywhere (pair slider was 1-10)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:16:59 +02:00
f4b082329e feat: blurhash placeholder while image loads
- Add BlurhashCanvas component (decodes hash → canvas pixel data)
- DrawCanvas: expose onImageLoad callback prop
- DrawIt + GenerateIt: show blurhash layer until real image is ready,
  reset imageLoaded state on picture navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:09:09 +02:00
7c983a7460 refactor: migrate to new db_* Directus collections
- DrawIt: load db_pictures (status=draft), create db_objects/db_words
  with blurhash placeholder, finish sets status=objects_created
- GenerateIt: load db_pictures (status=objects_created), right panel
  replaced with manual QA pairs (db_pairs + db_question + db_statement)
- Backend: new routes for db_pictures, db_objects, db_words, db_pairs
- Types/API: full db_* type definitions and API helpers
- Directus: user_notes field in db_objects, M2M db_words<->db_pictures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:03:23 +02:00
19 changed files with 3882 additions and 1443 deletions

1598
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@
"name": "content-mentor-frontend",
"version": "0.1.0",
"dependencies": {
"blurhash": "^2.0.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
@@ -1223,6 +1224,12 @@
"node": ">=6.0.0"
}
},
"node_modules/blurhash": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
"integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==",
"license": "MIT"
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",

View File

@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"blurhash": "^2.0.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"

View File

@@ -2,7 +2,11 @@ import { Routes, Route, Navigate } from 'react-router-dom'
import DrawIt from './pages/DrawIt'
import ExpandIt from './pages/ExpandIt'
import GenerateIt from './pages/GenerateIt'
import Home from './pages/Home'
import Login from './pages/Login'
import ContentManage from './pages/ContentManage'
import ContentList from './pages/ContentList'
import ContentEdit from './pages/ContentEdit'
import { useAuth } from './context/AuthContext'
import { ThemeProvider } from './context/ThemeContext'
import type { ReactNode } from 'react'
@@ -12,15 +16,25 @@ function PrivateRoute({ children }: { children: ReactNode }) {
return token ? <>{children}</> : <Navigate to="/login" replace />
}
function AdminRoute({ children }: { children: ReactNode }) {
const { token, isAdmin } = useAuth()
if (!token) return <Navigate to="/login" replace />
if (!isAdmin) return <Navigate to="/" replace />
return <>{children}</>
}
export default function App() {
return (
<ThemeProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<Navigate to="/draw" replace />} />
<Route path="/" element={<PrivateRoute><Home /></PrivateRoute>} />
<Route path="/draw" element={<PrivateRoute><DrawIt /></PrivateRoute>} />
<Route path="/generate" element={<PrivateRoute><GenerateIt /></PrivateRoute>} />
<Route path="/expand" element={<PrivateRoute><ExpandIt /></PrivateRoute>} />
<Route path="/content" element={<AdminRoute><ContentManage /></AdminRoute>} />
<Route path="/content/:name" element={<AdminRoute><ContentList /></AdminRoute>} />
<Route path="/content/:name/:id" element={<AdminRoute><ContentEdit /></AdminRoute>} />
</Routes>
</ThemeProvider>
)

View File

@@ -13,6 +13,27 @@ export async function directusLogin(email: string, password: string): Promise<st
return data.data.access_token
}
export interface DirectusMe {
id: string
first_name: string | null
last_name: string | null
email: string | null
role: {
id: string
name: string
admin_access: boolean
} | null
}
export async function getDirectusMe(token: string): Promise<DirectusMe> {
const res = await fetch('/api/directus/users/me', {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Fehler beim Laden des Profils')
return data.data as DirectusMe
}
export interface DirectusPicture {
id: string
media: string
@@ -325,3 +346,357 @@ export async function purgeAllOrphans(token: string): Promise<{ orphans_removed:
if (!res.ok) throw new Error('Fehler beim globalen Bereinigen')
return data
}
// ── DB Pictures ───────────────────────────────────────────────────────────────
import type { DbPicture, DbObject, DbWord, DbPair } from './types'
export async function getDesignOptions(token: string): Promise<{ text: string; value: string; color?: string }[]> {
const res = await fetch('/api/directus/db-pictures/design-options', {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
return data.choices || []
}
export async function getDbPictures(token: string, status = 'draft'): Promise<DbPicture[]> {
const res = await fetch(`/api/directus/db-pictures?status=${status}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Fehler beim Laden der db_pictures')
const data = await res.json()
return data.data as DbPicture[]
}
export async function deleteDbPicture(pictureId: string, token: string): Promise<void> {
const res = await fetch(`/api/directus/db-pictures/${pictureId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || 'Fehler beim Löschen des Bildes')
}
}
export async function updateDbPicture(pictureId: string, fields: Record<string, unknown>, token: string): Promise<void> {
const res = await fetch(`/api/directus/db-pictures/${pictureId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(fields),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren des Bildes')
}
export async function updateDbPictureStatus(pictureId: string, status: string, token: string): Promise<void> {
return updateDbPicture(pictureId, { status }, token)
}
// ── DB Objects ────────────────────────────────────────────────────────────────
export async function getDbObjects(pictureId: string, token: string): Promise<DbObject[]> {
const res = await fetch(`/api/directus/db-objects?picture_id=${pictureId}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Fehler beim Laden der db_objects')
const data = await res.json()
return data.data as DbObject[]
}
export async function createDbObject(payload: {
picture: string
selections: import('./types').Selection[] | null
user_notes: string | null
}, token: string): Promise<DbObject> {
const res = await fetch('/api/directus/db-objects', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ ...payload, status: 'draft' }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Fehler beim Erstellen des Objekts')
return data.data as DbObject
}
export async function updateDbObject(
objId: string,
payload: Partial<Pick<DbObject, 'user_notes'>>,
token: string
): Promise<DbObject> {
const res = await fetch(`/api/directus/db-objects/${objId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(payload),
})
const data = await res.json()
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Fehler beim Aktualisieren')
return data.data as DbObject
}
export async function deleteDbObject(objId: string, token: string): Promise<void> {
const res = await fetch(`/api/directus/db-objects/${objId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Fehler beim Löschen des Objekts')
}
// ── DB Words for a picture ────────────────────────────────────────────────────
export async function getDbPictureWords(pictureId: string, token: string): Promise<DbWord[]> {
const res = await fetch(`/api/directus/db-pictures/${pictureId}/words`, {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
if (!res.ok) throw new Error('Fehler beim Laden der Wörter')
return data.data as DbWord[]
}
export async function saveDbPictureWords(
pictureId: string,
words: { titel_de: string; level: number }[],
token: string
): Promise<{ saved: number }> {
const res = await fetch(`/api/directus/db-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
}
// ── DB Pairs for an object ────────────────────────────────────────────────────
export async function getDbObjectPairs(objectId: string, token: string): Promise<DbPair[]> {
const res = await fetch(`/api/directus/db-objects/${objectId}/pairs`, {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
if (!res.ok) throw new Error('Fehler beim Laden der Pairs')
return data.data as DbPair[]
}
export async function createDbPair(
objectId: string,
payload: {
question_de?: string
statement_de: string
level: number
words: { titel_de: string; level: number; link_to: 'question' | 'statement' | 'both' }[]
},
token: string
): Promise<{ ok: boolean; pair_id: string; statement_id: string; question_id: string | null }> {
const res = await fetch(`/api/directus/db-objects/${objectId}/pairs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(payload),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Fehler beim Erstellen des Pairs')
return data
}
export async function getDbObjectWords(objectId: string, token: string): Promise<DbWord[]> {
const res = await fetch(`/api/directus/db-objects/${objectId}/words`, {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
if (!res.ok) throw new Error('Fehler beim Laden der Objekt-Wörter')
return data.data as DbWord[]
}
// Add a single word to an object (M2M)
export async function addDbObjectWord(
objectId: string,
word: { titel_de: string; level: number },
token: string
) {
const res = await fetch(`/api/directus/db-objects/${objectId}/words`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(word),
})
return res.json()
}
// Remove a single word from an object by junction ID
export async function deleteDbObjectWord(
objectId: string,
junctionId: string | number,
token: string
) {
const res = await fetch(`/api/directus/db-objects/${objectId}/words/${junctionId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
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: `Bearer ${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: `Bearer ${token}` },
})
return res.json()
}
export async function updateDbPair(
pairId: string,
payload: {
level?: number
question_de?: string
statement_de?: string
words_add?: { titel_de: string; level: number; link_to: 'question' | 'statement' | 'both' }[]
words_remove?: { junction_id: number; link_to: 'question' | 'statement' | 'both' }[]
},
token: string
): Promise<void> {
const res = await fetch(`/api/directus/db-pairs/${pairId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren des Pairs')
}
export async function deleteDbPair(pairId: string, token: string): Promise<void> {
const res = await fetch(`/api/directus/db-pairs/${pairId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Fehler beim Löschen des Pairs')
}
export interface DbWordSearchResult {
id: string
titel_de: string
level: number | null
}
export async function searchDbWords(q: string, token: string, limit = 10): Promise<DbWordSearchResult[]> {
const query = q.trim()
if (!query) return []
const res = await fetch(
`/api/directus/db-words/search?q=${encodeURIComponent(query)}&limit=${limit}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
const data = await res.json()
if (!res.ok) return []
return (data.data || []) as DbWordSearchResult[]
}
// ── Content-Management Dashboard ─────────────────────────────────────────────
export type CollectionKind = 'image' | 'text'
export interface CollectionMeta {
name: string
label: string
kind: CollectionKind
preview?: string | null
title_field?: string | null
}
export interface CollectionStatusGroup {
status: string | null
count: number
}
export interface DashboardEntry {
name: string
label: string
kind: CollectionKind
group: 'alt' | 'neu'
total: number
by_status: CollectionStatusGroup[]
has_status: boolean
error: string | null
}
export async function getDashboardSummary(token: string): Promise<DashboardEntry[]> {
const res = await fetch('/api/directus/dashboard/summary', {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Fehler beim Laden des Dashboards')
return (data.data || []) as DashboardEntry[]
}
export interface CollectionListResponse {
data: Record<string, unknown>[]
meta: { total: number; limit: number; offset: number }
collection: CollectionMeta
}
export async function getCollectionItems(
name: string,
opts: { status?: string; limit?: number; offset?: number },
token: string,
): Promise<CollectionListResponse> {
const qs = new URLSearchParams()
if (opts.status) qs.set('status', opts.status)
if (opts.limit !== undefined) qs.set('limit', String(opts.limit))
if (opts.offset !== undefined) qs.set('offset', String(opts.offset))
const res = await fetch(`/api/directus/collection/${encodeURIComponent(name)}?${qs.toString()}`, {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
if (!res.ok) throw new Error(data.errors?.[0]?.message || data.error || 'Fehler beim Laden')
return data as CollectionListResponse
}
export interface CollectionItemResponse {
data: Record<string, unknown> | null
collection: CollectionMeta
}
export async function getCollectionItem(
name: string,
id: string,
token: string,
): Promise<CollectionItemResponse> {
const res = await fetch(`/api/directus/collection/${encodeURIComponent(name)}/${encodeURIComponent(id)}`, {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
if (!res.ok) throw new Error(data.errors?.[0]?.message || data.error || 'Fehler beim Laden')
return data as CollectionItemResponse
}
export async function updateCollectionItem(
name: string,
id: string,
payload: Record<string, unknown>,
token: string,
): Promise<void> {
const res = await fetch(`/api/directus/collection/${encodeURIComponent(name)}/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(payload),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.errors?.[0]?.message || data.error || 'Fehler beim Aktualisieren')
}
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef } from 'react'
import { decode } from 'blurhash'
interface Props {
hash: string
width?: number
height?: number
style?: React.CSSProperties
}
/**
* Rendert einen Blurhash-Hash als Canvas-Platzhalter.
* Wird als absolut positionierter Layer unter dem echten Bild gelegt
* und verschwindet sobald das echte Bild geladen ist.
*/
export default function BlurhashCanvas({ hash, width = 32, height = 32, style }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas || !hash) return
try {
const pixels = decode(hash, width, height)
const ctx = canvas.getContext('2d')
if (!ctx) return
const imageData = ctx.createImageData(width, height)
imageData.data.set(pixels)
ctx.putImageData(imageData, 0, 0)
} catch {
// Ungültiger Hash → kein Crash
}
}, [hash, width, height])
return (
<canvas
ref={canvasRef}
width={width}
height={height}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
imageRendering: 'auto',
...style,
}}
/>
)
}

View File

@@ -22,11 +22,12 @@ interface Props {
selectedObjectId: string | null
mode: 'rect' | 'polygon'
onHasSelection: (has: boolean) => void
onImageLoad?: () => void
readOnly?: boolean
}
export default forwardRef<DrawCanvasHandle, Props>(function DrawCanvas(
{ imageSrc, objects, selectedObjectId, mode, onHasSelection, readOnly = false },
{ imageSrc, objects, selectedObjectId, mode, onHasSelection, onImageLoad, readOnly = false },
ref
) {
const canvasRef = useRef<HTMLCanvasElement>(null)
@@ -271,6 +272,7 @@ export default forwardRef<DrawCanvasHandle, Props>(function DrawCanvas(
const cached = imageCache.get(imageSrc)
if (cached) {
applyImage(cached)
onImageLoad?.() // ← notify parent so blurhash hides for cached images
return
}
@@ -278,6 +280,7 @@ export default forwardRef<DrawCanvasHandle, Props>(function DrawCanvas(
img.onload = () => {
imageCache.set(imageSrc, img)
applyImage(img)
onImageLoad?.()
}
img.onerror = () => console.error('Fehler beim Laden des Bildes:', imageSrc)
img.src = imageSrc

View File

@@ -35,7 +35,7 @@ const CrosshairIcon = () => (
)
interface TopbarProps {
page: 'draw' | 'generate' | 'expand'
page: 'home' | 'draw' | 'generate' | 'expand'
center?: ReactNode
}
@@ -46,12 +46,17 @@ export default function Topbar({ page, center }: TopbarProps) {
return (
<header className="topbar">
<div className="topbar-brand">
<button
type="button"
className="topbar-brand"
onClick={() => navigate('/')}
title="Zur Startseite"
>
<span className="topbar-logo-icon">
<CrosshairIcon />
</span>
<span className="topbar-brand-name">Content Mentor</span>
</div>
</button>
<nav className="topbar-nav">
<button

View File

@@ -0,0 +1,157 @@
import { useEffect, useRef, useState } from 'react'
import { searchDbWords, type DbWordSearchResult } from '../api'
interface WordAutocompleteProps {
value: string
onChange: (v: string) => void
onSubmit: () => void
onPick?: (word: DbWordSearchResult) => void
token: string | null
placeholder?: string
className?: string
inputStyle?: React.CSSProperties
minChars?: number
debounceMs?: number
excludeTitles?: string[]
}
export default function WordAutocomplete({
value,
onChange,
onSubmit,
onPick,
token,
placeholder,
className,
inputStyle,
minChars = 1,
debounceMs = 180,
excludeTitles = [],
}: WordAutocompleteProps) {
const [results, setResults] = useState<DbWordSearchResult[]>([])
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [highlight, setHighlight] = useState(0)
const wrapRef = useRef<HTMLDivElement>(null)
const reqIdRef = useRef(0)
const excludeSet = new Set(excludeTitles.map(s => s.trim().toLowerCase()))
// Debounced search
useEffect(() => {
if (!token) return
const trimmed = value.trim()
if (trimmed.length < minChars) {
setResults([])
setOpen(false)
return
}
const myId = ++reqIdRef.current
setLoading(true)
const t = setTimeout(() => {
searchDbWords(trimmed, token, 10)
.then(r => {
if (myId !== reqIdRef.current) return
const filtered = r.filter(x => !excludeSet.has(x.titel_de.trim().toLowerCase()))
setResults(filtered)
setOpen(filtered.length > 0)
setHighlight(0)
})
.finally(() => {
if (myId === reqIdRef.current) setLoading(false)
})
}, debounceMs)
return () => clearTimeout(t)
}, [value, token, minChars, debounceMs])
// Close on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!wrapRef.current) return
if (!wrapRef.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onClick)
return () => document.removeEventListener('mousedown', onClick)
}, [])
const pick = (r: DbWordSearchResult) => {
onChange(r.titel_de)
onPick?.(r)
setOpen(false)
}
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (open && results.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlight(h => (h + 1) % results.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setHighlight(h => (h - 1 + results.length) % results.length)
return
}
if (e.key === 'Escape') {
e.preventDefault()
setOpen(false)
return
}
if (e.key === 'Tab') {
e.preventDefault()
pick(results[highlight])
return
}
if (e.key === 'Enter') {
// If user has typed something matching a suggestion exactly, prefer submit; else pick.
const exact = results.find(r => r.titel_de.trim().toLowerCase() === value.trim().toLowerCase())
if (exact) {
setOpen(false)
onSubmit()
} else {
e.preventDefault()
pick(results[highlight])
}
return
}
}
if (e.key === 'Enter') {
onSubmit()
}
}
return (
<div ref={wrapRef} className={`wac-wrap${className ? ' ' + className : ''}`}>
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
onKeyDown={onKeyDown}
onFocus={() => { if (results.length > 0) setOpen(true) }}
placeholder={placeholder}
autoComplete="off"
spellCheck={false}
style={inputStyle}
/>
{open && (
<div className="wac-panel" role="listbox">
{loading && results.length === 0 && (
<div className="wac-empty">Suche</div>
)}
{results.map((r, i) => (
<button
key={r.id}
type="button"
role="option"
className={`wac-item${i === highlight ? ' wac-item-active' : ''}`}
onMouseDown={e => { e.preventDefault(); pick(r) }}
onMouseEnter={() => setHighlight(i)}
>
<span className="wac-item-title">{r.titel_de}</span>
{r.level != null && <span className="wac-item-meta">L{r.level}</span>}
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -1,8 +1,11 @@
import { createContext, useContext, useState, type ReactNode } from 'react'
import type { DirectusMe } from '../api'
interface AuthContextType {
token: string | null
login: (token: string) => void
user: DirectusMe | null
isAdmin: boolean
login: (token: string, user: DirectusMe) => void
logout: () => void
}
@@ -10,9 +13,22 @@ const AuthContext = createContext<AuthContextType | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(null)
const [user, setUser] = useState<DirectusMe | null>(null)
const login = (newToken: string, newUser: DirectusMe) => {
setToken(newToken)
setUser(newUser)
}
const logout = () => {
setToken(null)
setUser(null)
}
const isAdmin = !!user?.role?.admin_access
return (
<AuthContext.Provider value={{ token, login: setToken, logout: () => setToken(null) }}>
<AuthContext.Provider value={{ token, user, isAdmin, login, logout }}>
{children}
</AuthContext.Provider>
)

View File

@@ -132,6 +132,18 @@ body {
gap: 8px;
margin-right: 8px;
user-select: none;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
color: inherit;
font-family: inherit;
border-radius: var(--r-md);
transition: opacity 0.15s ease;
}
.topbar-brand:hover {
opacity: 0.8;
}
.topbar-logo-icon {
@@ -1317,3 +1329,746 @@ select:focus {
transform: none;
box-shadow: none;
}
/* =====================================================
HOME / DASHBOARD
===================================================== */
.home-page {
flex: 1;
overflow-y: auto;
padding: 56px 32px 64px;
display: flex;
flex-direction: column;
align-items: center;
}
.home-greeting {
text-align: center;
margin-bottom: 40px;
max-width: 720px;
}
.home-greeting h1 {
font-size: 30px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-1);
margin-bottom: 8px;
}
.home-greeting p {
font-size: 15px;
color: var(--text-3);
}
.home-tiles {
width: 100%;
max-width: 1080px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.home-tile {
position: relative;
display: flex;
align-items: flex-start;
gap: 16px;
padding: 22px 22px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-xl);
box-shadow: var(--shadow-sm);
text-align: left;
cursor: pointer;
font-family: inherit;
color: var(--text-1);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.home-tile:hover:not(.is-disabled) {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: var(--primary);
}
.home-tile:focus-visible {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(92,108,246,.18), var(--shadow-sm);
}
.home-tile.is-disabled {
cursor: not-allowed;
opacity: 0.72;
}
.home-tile-icon {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: var(--r-lg);
background: var(--primary-muted);
color: var(--primary-muted-fg);
display: flex;
align-items: center;
justify-content: center;
}
.home-tile-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.home-tile-title {
font-size: 16px;
font-weight: 650;
color: var(--text-1);
letter-spacing: -0.01em;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.home-tile-subtitle {
font-size: 13px;
color: var(--text-3);
line-height: 1.45;
}
.home-tile-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 2px 8px;
border-radius: var(--r-full);
background: var(--primary-muted);
color: var(--primary-muted-fg);
}
.home-tile-badge-soft {
background: var(--surface-3);
color: var(--text-2);
}
.home-tile-locked {
position: absolute;
top: 14px;
right: 14px;
color: var(--text-3);
}
/* =====================================================
CONTENT MANAGEMENT (Dashboard, Liste, Edit)
===================================================== */
.cm-page {
flex: 1;
overflow-y: auto;
padding: 32px 32px 64px;
max-width: 1280px;
width: 100%;
margin: 0 auto;
}
.cm-header,
.cm-listhead {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 28px;
}
.cm-listhead {
flex-wrap: wrap;
}
.cm-listhead-titles {
flex: 1;
min-width: 0;
}
.cm-listhead-actions {
display: flex;
align-items: center;
gap: 10px;
}
.cm-title {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-1);
}
.cm-subtitle {
font-size: 13.5px;
color: var(--text-3);
margin-top: 4px;
}
.cm-section { margin-bottom: 32px; }
.cm-section-title {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3);
margin-bottom: 12px;
}
.cm-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.cm-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 14px 16px 16px;
box-shadow: var(--shadow-xs);
display: flex;
flex-direction: column;
gap: 12px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.cm-card:hover { border-color: var(--primary); box-shadow: var(--shadow-sm); }
.cm-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
font-family: inherit;
color: var(--text-1);
text-align: left;
}
.cm-card-titlewrap {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.cm-card-label {
font-size: 15px;
font-weight: 650;
letter-spacing: -0.01em;
color: var(--text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cm-card-total {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--primary);
flex-shrink: 0;
}
.cm-kind {
display: inline-block;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 2px 7px;
border-radius: var(--r-full);
width: fit-content;
}
.cm-kind-image {
background: rgba(92, 108, 246, 0.12);
color: var(--primary-muted-fg);
}
.cm-kind-text {
background: var(--surface-3);
color: var(--text-2);
}
.cm-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.cm-pills-muted { opacity: 0.7; }
.cm-pill {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-full);
padding: 3px 10px 3px 8px;
font-size: 12px;
color: var(--text-2);
cursor: pointer;
font-family: inherit;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.cm-pill:hover {
background: var(--surface-3);
border-color: var(--primary);
color: var(--text-1);
}
.cm-pill-static { cursor: default; }
.cm-pill-static:hover { background: var(--surface-2); border-color: var(--border); color: var(--text-2); }
.cm-pill-label {
text-transform: lowercase;
font-weight: 500;
}
.cm-pill-count {
background: var(--surface);
color: var(--text-1);
border: 1px solid var(--border);
padding: 0 6px;
border-radius: var(--r-full);
font-size: 11px;
font-weight: 600;
margin-left: 2px;
}
.cm-pill-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-3);
}
.cm-pill-dot-draft { background: #94a3b8; }
.cm-pill-dot-new { background: #5C6CF6; }
.cm-pill-dot-published { background: #059669; }
.cm-pill-dot-publish { background: #059669; }
.cm-pill-dot-ready { background: #059669; }
.cm-pill-dot-rejected { background: #DC2626; }
.cm-pill-dot-archived { background: #6b7280; }
.cm-pill-dot-pending { background: #D97706; }
.cm-pill-dot-review { background: #D97706; }
.cm-pill-dot-unknown { background: #d1d5db; }
.cm-empty {
padding: 40px;
text-align: center;
color: var(--text-3);
font-size: 14px;
}
.cm-error {
padding: 12px 14px;
background: var(--danger-bg);
border: 1px solid var(--danger);
color: var(--danger);
border-radius: var(--r-md);
margin-bottom: 12px;
font-size: 13px;
}
.cm-info {
padding: 10px 14px;
background: var(--success-bg);
border: 1px solid var(--success);
color: var(--success);
border-radius: var(--r-md);
margin-bottom: 12px;
font-size: 13px;
}
.cm-card-error {
font-size: 12px;
color: var(--danger);
}
.cm-input {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 7px 11px;
font-size: 13px;
color: var(--text-1);
font-family: inherit;
outline: none;
min-width: 220px;
transition: border-color 0.12s, box-shadow 0.12s;
}
.cm-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(92,108,246,.14);
}
.cm-view-toggle {
display: inline-flex;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 2px;
gap: 2px;
}
.cm-view-btn {
background: transparent;
border: none;
padding: 5px 10px;
border-radius: 6px;
color: var(--text-2);
cursor: pointer;
display: inline-flex;
align-items: center;
font-family: inherit;
transition: background 0.12s ease, color 0.12s ease;
}
.cm-view-btn.active {
background: var(--surface);
color: var(--text-1);
box-shadow: var(--shadow-xs);
}
.cm-view-btn:hover:not(.active) {
background: var(--surface-3);
color: var(--text-1);
}
.cm-itemgrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 14px;
}
.cm-tile-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
overflow: hidden;
padding: 0;
cursor: pointer;
font-family: inherit;
text-align: left;
display: flex;
flex-direction: column;
transition: transform 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease;
}
.cm-tile-item:hover {
transform: translateY(-2px);
border-color: var(--primary);
box-shadow: var(--shadow-sm);
}
.cm-tile-thumb {
aspect-ratio: 1 / 1;
background: var(--surface-3);
display: flex;
align-items: center;
justify-content: center;
}
.cm-tile-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cm-tile-placeholder {
font-size: 32px;
color: var(--text-3);
}
.cm-tile-meta {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.cm-tile-title {
font-size: 13px;
font-weight: 600;
color: var(--text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cm-tile-status {
font-size: 11.5px;
color: var(--text-3);
}
.cm-tablewrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
overflow: hidden;
overflow-x: auto;
}
.cm-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.cm-table th,
.cm-table td {
padding: 9px 14px;
text-align: left;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.cm-table thead th {
background: var(--surface-2);
font-weight: 650;
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-3);
}
.cm-table tbody tr {
cursor: pointer;
transition: background 0.1s ease;
}
.cm-table tbody tr:hover {
background: var(--surface-2);
}
.cm-table tbody tr:last-child td {
border-bottom: none;
}
.cm-td-id {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
color: var(--text-3);
font-size: 12px;
}
.cm-td-arrow {
text-align: right;
color: var(--text-3);
font-size: 18px;
}
.cm-pager {
margin-top: 18px;
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
}
.cm-pager-info {
color: var(--text-3);
font-size: 13px;
}
.cm-edit {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 20px;
}
.cm-edit:has(.cm-edit-preview) {
grid-template-columns: 320px minmax(0, 1fr);
}
.cm-edit-preview img {
width: 100%;
border-radius: var(--r-md);
border: 1px solid var(--border);
}
.cm-edit-fields {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.cm-field {
display: flex;
flex-direction: column;
gap: 5px;
}
.cm-field label {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-2);
letter-spacing: 0.02em;
}
.cm-field-label {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
text-transform: none;
letter-spacing: 0;
}
.cm-field-readonly {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-3);
background: var(--surface-3);
padding: 1px 6px;
border-radius: var(--r-full);
}
.cm-field input,
.cm-field textarea {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 8px 11px;
font-size: 13px;
color: var(--text-1);
font-family: inherit;
outline: none;
transition: border-color 0.12s, box-shadow 0.12s;
width: 100%;
}
.cm-field textarea {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 12.5px;
resize: vertical;
}
.cm-field input:focus,
.cm-field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(92,108,246,.14);
}
.cm-field input[readonly],
.cm-field textarea[readonly] {
opacity: 0.7;
cursor: not-allowed;
}
.cm-save {
width: auto !important;
padding: 9px 18px !important;
margin-top: 0 !important;
}
/* =====================================================
WORD AUTOCOMPLETE
===================================================== */
.wac-wrap {
position: relative;
flex: 1;
display: flex;
min-width: 0;
}
.wac-wrap input {
width: 100%;
}
.wac-panel {
position: absolute;
z-index: 60;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-md);
box-shadow: var(--shadow-md);
padding: 4px;
max-height: 240px;
overflow-y: auto;
}
.wac-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 6px 10px;
background: transparent;
border: none;
border-radius: var(--r-sm);
cursor: pointer;
font-family: inherit;
font-size: 12.5px;
color: var(--text-1);
text-align: left;
}
.wac-item:hover,
.wac-item-active {
background: var(--primary-muted);
color: var(--primary-muted-fg);
}
.wac-item-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wac-item-meta {
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-full);
padding: 1px 7px;
flex-shrink: 0;
}
.wac-item-active .wac-item-meta {
background: var(--surface);
color: var(--primary-muted-fg);
border-color: var(--primary-muted-fg);
}
.wac-empty {
padding: 10px 12px;
font-size: 12px;
color: var(--text-3);
text-align: center;
}

View File

@@ -0,0 +1,194 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import Topbar from '../components/Topbar'
import {
getCollectionItem,
updateCollectionItem,
directusAssetUrl,
type CollectionMeta,
} from '../api'
const READONLY_KEYS = new Set([
'id',
'date_created',
'date_updated',
'user_created',
'user_updated',
'sort',
])
const BackIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
)
export default function ContentEdit() {
const { name = '', id = '' } = useParams()
const navigate = useNavigate()
const { token } = useAuth()
const [item, setItem] = useState<Record<string, unknown> | null>(null)
const [meta, setMeta] = useState<CollectionMeta | null>(null)
const [edits, setEdits] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
useEffect(() => {
if (!token || !name || !id) return
setLoading(true)
setError(null)
getCollectionItem(name, id, token)
.then(r => {
setItem(r.data)
setMeta(r.collection)
setEdits({})
})
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false))
}, [name, id, token])
const fields = useMemo(() => {
if (!item) return [] as string[]
return Object.keys(item).sort((a, b) => {
if (a === 'id') return -1
if (b === 'id') return 1
if (a === 'status') return -1
if (b === 'status') return 1
return a.localeCompare(b)
})
}, [item])
const previewUrl = useMemo(() => {
if (!item || !meta?.preview || !token) return null
const v = item[meta.preview]
if (typeof v === 'string' && v) return directusAssetUrl(v, token)
return null
}, [item, meta, token])
const setEdit = (key: string, value: string) => {
setEdits(prev => ({ ...prev, [key]: value }))
}
const onSave = async () => {
if (!token || !item) return
setSaving(true)
setError(null)
setInfo(null)
try {
const payload: Record<string, unknown> = {}
for (const [k, v] of Object.entries(edits)) {
const orig = item[k]
if (typeof orig === 'number') {
const n = Number(v)
payload[k] = Number.isFinite(n) ? n : v
} else if (typeof orig === 'boolean') {
payload[k] = v === 'true'
} else if (orig != null && typeof orig === 'object') {
try {
payload[k] = JSON.parse(v)
} catch {
throw new Error(`Feld "${k}" enthält kein gültiges JSON.`)
}
} else {
payload[k] = v
}
}
if (Object.keys(payload).length === 0) {
setInfo('Nichts zu speichern.')
return
}
await updateCollectionItem(name, id, payload, token)
setInfo('Gespeichert.')
// Reload
const r = await getCollectionItem(name, id, token)
setItem(r.data)
setEdits({})
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}
const valueFor = (k: string) => {
if (k in edits) return edits[k]
const v = item?.[k]
if (v == null) return ''
if (typeof v === 'object') return JSON.stringify(v, null, 2)
return String(v)
}
return (
<div className="app-shell">
<Topbar page="home" />
<div className="cm-page">
<div className="cm-listhead">
<button className="btn-ghost btn-sm" onClick={() => navigate(`/content/${name}`)}>
<BackIcon /> <span>Zurück zur Liste</span>
</button>
<div className="cm-listhead-titles">
<h1 className="cm-title">{meta?.label || name}</h1>
<p className="cm-subtitle">ID: <code>{id}</code></p>
</div>
<div className="cm-listhead-actions">
<button
className="auth-submit cm-save"
disabled={saving || Object.keys(edits).length === 0}
onClick={onSave}
style={{ width: 'auto' }}
>
{saving ? 'Wird gespeichert…' : 'Speichern'}
</button>
</div>
</div>
{loading && <div className="cm-empty">Wird geladen</div>}
{error && <div className="cm-error">{error}</div>}
{info && <div className="cm-info">{info}</div>}
{!loading && item && meta && (
<div className="cm-edit">
{previewUrl && (
<div className="cm-edit-preview">
<img src={previewUrl} alt={String(item.id ?? '')} />
</div>
)}
<div className="cm-edit-fields">
{fields.map(k => {
const orig = item[k]
const readonly = READONLY_KEYS.has(k)
const isJson = orig != null && typeof orig === 'object'
const v = valueFor(k)
return (
<div className="cm-field" key={k}>
<label>
<span className="cm-field-label">{k}</span>
{readonly && <span className="cm-field-readonly">read-only</span>}
</label>
{isJson || (typeof v === 'string' && v.length > 80)
? <textarea
rows={isJson ? 8 : 3}
value={v}
readOnly={readonly}
onChange={e => !readonly && setEdit(k, e.target.value)}
/>
: <input
type="text"
value={v}
readOnly={readonly}
onChange={e => !readonly && setEdit(k, e.target.value)}
/>}
</div>
)
})}
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,247 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import Topbar from '../components/Topbar'
import {
getCollectionItems,
directusAssetUrl,
type CollectionListResponse,
type CollectionMeta,
} from '../api'
const PAGE_SIZE = 50
const GridIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
</svg>
)
const ListIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
)
const BackIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
)
function pickPreviewUrl(item: Record<string, unknown>, preview: string | null | undefined, token: string | null): string | null {
if (!preview || !token) return null
const v = item[preview]
if (typeof v === 'string' && v.length > 0) return directusAssetUrl(v, token)
return null
}
function pickTitle(item: Record<string, unknown>, meta: CollectionMeta): string {
if (meta.title_field) {
const v = item[meta.title_field]
if (typeof v === 'string' && v.trim()) return v.trim()
}
const id = item.id
return id != null ? `#${String(id).slice(0, 8)}` : '—'
}
export default function ContentList() {
const { name = '' } = useParams()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const { token } = useAuth()
const status = searchParams.get('status') || ''
const offsetParam = parseInt(searchParams.get('offset') || '0', 10)
const viewParam = (searchParams.get('view') as 'grid' | 'list') || null
const [resp, setResp] = useState<CollectionListResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [view, setView] = useState<'grid' | 'list'>(viewParam ?? 'list')
useEffect(() => {
if (!token || !name) return
setLoading(true)
setError(null)
getCollectionItems(name, { status: status || undefined, limit: PAGE_SIZE, offset: offsetParam }, token)
.then(r => {
setResp(r)
if (!viewParam) setView(r.collection.kind === 'image' ? 'grid' : 'list')
})
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false))
}, [name, status, offsetParam, token, viewParam])
const total = resp?.meta.total ?? 0
const items = resp?.data ?? []
const meta = resp?.collection
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const currentPage = Math.floor(offsetParam / PAGE_SIZE) + 1
const headerCols = useMemo(() => {
if (!items.length) return [] as string[]
const first = items[0]
return Object.keys(first).filter(k => k !== 'id').slice(0, 5)
}, [items])
const setOffset = (newOffset: number) => {
const sp = new URLSearchParams(searchParams)
sp.set('offset', String(newOffset))
setSearchParams(sp, { replace: false })
}
const setStatus = (s: string) => {
const sp = new URLSearchParams(searchParams)
if (s) sp.set('status', s)
else sp.delete('status')
sp.delete('offset')
setSearchParams(sp, { replace: false })
}
const toggleView = (v: 'grid' | 'list') => {
setView(v)
const sp = new URLSearchParams(searchParams)
sp.set('view', v)
setSearchParams(sp, { replace: true })
}
return (
<div className="app-shell">
<Topbar page="home" />
<div className="cm-page">
<div className="cm-listhead">
<button className="btn-ghost btn-sm" onClick={() => navigate('/content')}>
<BackIcon /> <span>Zurück</span>
</button>
<div className="cm-listhead-titles">
<h1 className="cm-title">{meta?.label || name}</h1>
<p className="cm-subtitle">
{total.toLocaleString('de-DE')} Einträge{status ? ` · Status: ${status}` : ''}
</p>
</div>
<div className="cm-listhead-actions">
<input
className="cm-input"
placeholder="Status filtern (z.B. draft)"
value={status}
onChange={e => setStatus(e.target.value)}
/>
<div className="cm-view-toggle">
<button
className={`cm-view-btn${view === 'list' ? ' active' : ''}`}
onClick={() => toggleView('list')}
title="Liste"
>
<ListIcon />
</button>
<button
className={`cm-view-btn${view === 'grid' ? ' active' : ''}`}
onClick={() => toggleView('grid')}
title="Kacheln"
>
<GridIcon />
</button>
</div>
</div>
</div>
{loading && <div className="cm-empty">Wird geladen</div>}
{error && <div className="cm-error">{error}</div>}
{!loading && !error && meta && items.length === 0 && (
<div className="cm-empty">Keine Einträge.</div>
)}
{!loading && !error && meta && items.length > 0 && view === 'grid' && (
<div className="cm-itemgrid">
{items.map(item => {
const id = String(item.id ?? '')
const url = pickPreviewUrl(item, meta.preview, token)
const title = pickTitle(item, meta)
const status = (item.status as string | undefined) || ''
return (
<button
key={id}
type="button"
className="cm-tile-item"
onClick={() => navigate(`/content/${name}/${id}`)}
>
<div className="cm-tile-thumb">
{url
? <img src={url} alt={title} loading="lazy" />
: <div className="cm-tile-placeholder">{meta.kind === 'image' ? '🖼' : '📄'}</div>}
</div>
<div className="cm-tile-meta">
<div className="cm-tile-title" title={title}>{title}</div>
{status && <div className="cm-tile-status">{status}</div>}
</div>
</button>
)
})}
</div>
)}
{!loading && !error && meta && items.length > 0 && view === 'list' && (
<div className="cm-tablewrap">
<table className="cm-table">
<thead>
<tr>
<th>ID</th>
{headerCols.map(c => <th key={c}>{c}</th>)}
<th />
</tr>
</thead>
<tbody>
{items.map(item => {
const id = String(item.id ?? '')
return (
<tr key={id} onClick={() => navigate(`/content/${name}/${id}`)}>
<td className="cm-td-id" title={id}>#{id.slice(0, 8)}</td>
{headerCols.map(c => {
const v = item[c]
const display = v == null ? '—' : (typeof v === 'object' ? JSON.stringify(v) : String(v))
return <td key={c} title={display}>{display.length > 80 ? display.slice(0, 77) + '…' : display}</td>
})}
<td className="cm-td-arrow"></td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{!loading && !error && total > PAGE_SIZE && (
<div className="cm-pager">
<button
className="btn-ghost btn-sm"
disabled={offsetParam <= 0}
onClick={() => setOffset(Math.max(0, offsetParam - PAGE_SIZE))}
>
Zurück
</button>
<span className="cm-pager-info">Seite {currentPage} / {totalPages}</span>
<button
className="btn-ghost btn-sm"
disabled={offsetParam + PAGE_SIZE >= total}
onClick={() => setOffset(offsetParam + PAGE_SIZE)}
>
Weiter
</button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import Topbar from '../components/Topbar'
import { getDashboardSummary, type DashboardEntry } from '../api'
const groupLabel = (g: 'alt' | 'neu') =>
g === 'alt' ? 'Alt (vor DB-Refactor)' : 'Neu (DB-basiert)'
function StatusPill({ status, count, onClick }: { status: string | null; count: number; onClick?: () => void }) {
const label = status ?? '—'
return (
<button type="button" className="cm-pill" onClick={onClick} title={`Status: ${label}`}>
<span className={`cm-pill-dot cm-pill-dot-${(status || 'unknown').toLowerCase().replace(/[^a-z]/g, '')}`} />
<span className="cm-pill-label">{label}</span>
<span className="cm-pill-count">{count}</span>
</button>
)
}
export default function ContentManage() {
const navigate = useNavigate()
const { token } = useAuth()
const [entries, setEntries] = useState<DashboardEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!token) return
setLoading(true)
setError(null)
getDashboardSummary(token)
.then(setEntries)
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false))
}, [token])
const groups: ('neu' | 'alt')[] = ['neu', 'alt']
return (
<div className="app-shell">
<Topbar page="home" />
<div className="cm-page">
<div className="cm-header">
<div>
<h1 className="cm-title">Content verwalten</h1>
<p className="cm-subtitle">Übersicht aller Collections nach Status. Klick auf eine Karte oder einen Status zum Drill-Down.</p>
</div>
</div>
{loading && <div className="cm-empty">Wird geladen</div>}
{error && <div className="cm-error">{error}</div>}
{!loading && !error && groups.map(group => {
const items = entries.filter(e => e.group === group)
if (items.length === 0) return null
return (
<section key={group} className="cm-section">
<h2 className="cm-section-title">{groupLabel(group)}</h2>
<div className="cm-grid">
{items.map(entry => (
<article key={entry.name} className="cm-card">
<button
type="button"
className="cm-card-head"
onClick={() => navigate(`/content/${entry.name}`)}
>
<div className="cm-card-titlewrap">
<span className={`cm-kind cm-kind-${entry.kind}`}>{entry.kind === 'image' ? 'Bilder' : 'Text'}</span>
<span className="cm-card-label">{entry.label}</span>
</div>
<div className="cm-card-total">{entry.total.toLocaleString('de-DE')}</div>
</button>
{entry.error && <div className="cm-card-error">{entry.error}</div>}
{entry.has_status && entry.by_status.length > 0 && (
<div className="cm-pills">
{entry.by_status.map(g => (
<StatusPill
key={String(g.status)}
status={g.status}
count={g.count}
onClick={() => navigate(`/content/${entry.name}?status=${encodeURIComponent(g.status || '')}`)}
/>
))}
</div>
)}
{!entry.has_status && !entry.error && (
<div className="cm-pills cm-pills-muted">
<span className="cm-pill cm-pill-static">
<span className="cm-pill-label">Kein Status-Feld</span>
</span>
</div>
)}
</article>
))}
</div>
</section>
)
})}
</div>
</div>
)
}

View File

@@ -1,13 +1,28 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
import BlurhashCanvas from '../components/BlurhashCanvas'
import Topbar from '../components/Topbar'
import WordAutocomplete from '../components/WordAutocomplete'
import {
getDirectusPictures, directusAssetUrl, type DirectusPicture,
getDirectusObjects, createDirectusObject, updateDirectusObject, deleteDirectusObject,
updatePictureStatus, getPictureWords, savePictureWords,
getDbPictures,
updateDbPicture,
updateDbPictureStatus,
deleteDbPicture,
getDbObjects,
createDbObject,
updateDbObject,
deleteDbObject,
getDbObjectWords,
addDbObjectWord,
deleteDbObjectWord,
getDbPictureWords,
addDbPictureWord,
deleteDbPictureWord,
directusAssetUrl,
getDesignOptions,
} from '../api'
import { useAuth } from '../context/AuthContext'
import type { DirectusObject, Selection, CanvasObject, PictureWord } from '../types'
import type { DbPicture, DbObject, DbWord, Selection, CanvasObject } from '../types'
const ChevronLeftIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
@@ -28,73 +43,48 @@ const TrashIcon = () => (
export default function DrawIt() {
const { token } = useAuth()
const [pictureList, setPictureList] = useState<DirectusPicture[]>([])
const [pictureList, setPictureList] = useState<DbPicture[]>([])
const [currentIndex, setCurrentIndex] = useState(-1)
const [debouncedIndex, setDebouncedIndex] = useState(-1)
const [objects, setObjects] = useState<DirectusObject[]>([])
const [objects, setObjects] = useState<DbObject[]>([])
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
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)
// per-object words: objectId → DbWord[]
const [objectWords, setObjectWords] = useState<Record<string, DbWord[]>>({})
// per-object word input values: objectId → current input text
const [wordInputs, setWordInputs] = useState<Record<string, string>>({})
const [pendingWords, setPendingWords] = useState<string[]>([])
const [newWordInput, setNewWordInput] = useState('')
const [designOptions, setDesignOptions] = useState<{ text: string; value: string }[]>([])
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null)
const [mode, setMode] = useState<'rect' | 'polygon'>('polygon')
const [hasSelection, setHasSelection] = useState(false)
const [saving, setSaving] = useState(false)
const [finishing, setFinishing] = useState(false)
const [status, setStatus] = useState('')
const [deleting, setDeleting] = useState(false)
const [statusMsg, setStatusMsg] = useState('')
const [statusError, setStatusError] = useState(false)
const [imageLoaded, setImageLoaded] = useState(false)
const [pictureWords, setPictureWords] = useState<DbWord[]>([])
const [pictureWordInput, setPictureWordInput] = useState('')
const canvasRef = useRef<DrawCanvasHandle>(null)
// Debounce: Bild erst laden wenn 350ms keine weitere Navigation
// Debounce: only load picture data after 350ms of no navigation
useEffect(() => {
const t = setTimeout(() => setDebouncedIndex(currentIndex), 350)
return () => clearTimeout(t)
}, [currentIndex])
// Reset imageLoaded immediately on navigation so blurhash shows right away
useEffect(() => {
if (safeWordInputVisible) safeWordInputRef.current?.focus()
}, [safeWordInputVisible])
setImageLoaded(false)
}, [currentIndex])
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)
}
}
// currentPicture folgt dem debouncedIndex → lädt erst wenn Navigation pausiert
const currentPicture: DirectusPicture | null =
const currentPicture: DbPicture | null =
debouncedIndex >= 0 && debouncedIndex < pictureList.length ? pictureList[debouncedIndex] : null
// Map DirectusObject → CanvasObject for rendering
const canvasObjects: CanvasObject[] = objects.map((obj, i) => ({
id: obj.id,
visible: obj.visible !== false,
@@ -103,33 +93,95 @@ export default function DrawIt() {
hierarchy: 1,
}))
// Load db_pictures with status=draft
useEffect(() => {
if (!token) return
getDirectusPictures(token)
getDbPictures(token, 'draft')
.then(pics => { setPictureList(pics); setCurrentIndex(pics.length > 0 ? 0 : -1) })
.catch(console.error)
}, [token])
// Load design options once on mount
useEffect(() => {
if (!token) return
getDesignOptions(token).then(setDesignOptions).catch(console.error)
}, [token])
// Load objects when picture changes, then load each object's words and picture words
useEffect(() => {
if (!currentPicture || !token) {
setObjects([]); setSelectedObjectId(null)
setPictureWords([]); setSafeWords([])
setObjectWords({})
setWordInputs({})
setImageLoaded(false)
setPictureWords([])
setPictureWordInput('')
return
}
getDirectusObjects(currentPicture.id, token)
.then(objs => { setObjects(objs.map(o => ({ ...o, visible: true }))); setSelectedObjectId(null) })
.catch(console.error)
getPictureWords(currentPicture.id, token)
.then(setPictureWords)
getDbObjects(currentPicture.id, token)
.then(objs => {
setObjects(objs.map(o => ({ ...o, visible: true })))
setSelectedObjectId(null)
// Load words for each object
const newWords: Record<string, DbWord[]> = {}
const promises = objs.map(obj =>
getDbObjectWords(obj.id, token)
.then(words => { newWords[obj.id] = words })
.catch(() => { newWords[obj.id] = [] })
)
Promise.all(promises).then(() => {
setObjectWords(newWords)
})
})
.catch(console.error)
// Load picture-level words
getDbPictureWords(currentPicture.id, token)
.then(words => setPictureWords(words))
.catch(() => setPictureWords([]))
}, [currentPicture?.id, token])
const showStatus = (msg: string, isError = false) => {
setStatus(msg); setStatusError(isError)
setStatusMsg(msg); setStatusError(isError)
}
const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), [])
const handleAddObjectWord = async (objId: string) => {
if (!token) return
const titel_de = (wordInputs[objId] || '').trim()
if (!titel_de) return
try {
const result = await addDbObjectWord(objId, { titel_de, level: 50 }, token)
if (result.ok) {
const words = await getDbObjectWords(objId, token)
setObjectWords(prev => ({ ...prev, [objId]: words }))
setWordInputs(prev => ({ ...prev, [objId]: '' }))
}
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Fehler beim Hinzufügen des Worts.', true)
}
}
const handleAddPendingWord = () => {
const w = newWordInput.trim()
if (!w || pendingWords.includes(w)) return
setPendingWords(prev => [...prev, w])
setNewWordInput('')
}
const handleRemoveObjectWord = async (objId: string, junctionId: string | number) => {
if (!token) return
try {
await deleteDbObjectWord(objId, junctionId, token)
setObjectWords(prev => ({
...prev,
[objId]: (prev[objId] || []).filter(w => w.junction_id !== junctionId)
}))
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Fehler beim Entfernen des Worts.', true)
}
}
const addSelection = () => {
const sel = canvasRef.current?.getCurrentSelection()
if (!sel) { showStatus('Bitte zuerst einen Bereich auswählen.', true); return }
@@ -142,16 +194,27 @@ export default function DrawIt() {
if (!currentPicture || !token || currentSelections.length === 0) return
setSaving(true)
try {
const obj = await createDirectusObject({
const obj = await createDbObject({
picture: currentPicture.id,
selections: currentSelections,
user_notes: userNotes.trim() || null,
parent: parentId,
}, token)
// Save pending words to the new object
const savedWords: DbWord[] = []
for (const w of pendingWords) {
try {
const result = await addDbObjectWord(obj.id, { titel_de: w, level: 50 }, token)
if (result.ok) {
savedWords.push({ junction_id: result.junction_id, word_id: result.word_id, titel_de: w, level: 50, status: 'draft' })
}
} catch {}
}
setObjects(prev => [...prev, { ...obj, visible: true }])
setObjectWords(prev => ({ ...prev, [obj.id]: savedWords }))
setCurrentSelections([])
setUserNotes('')
setParentId(null)
setPendingWords([])
setNewWordInput('')
canvasRef.current?.resetSelection()
showStatus('Objekt gespeichert.')
} catch (e) {
@@ -161,11 +224,16 @@ export default function DrawIt() {
}
}
// Mark picture as objects_created and remove from list
const finishPicture = async () => {
if (!currentPicture || !token) return
setFinishing(true)
try {
await updatePictureStatus(currentPicture.id, 'drawing_created', token)
// Save status + current design in one call to avoid race conditions
await updateDbPicture(currentPicture.id, {
status: 'objects_created',
design: currentPicture.design ?? null,
}, token)
setPictureList(prev => prev.filter(p => p.id !== currentPicture.id))
setCurrentIndex(i => Math.max(0, i - 1))
setObjects([])
@@ -180,7 +248,7 @@ export default function DrawIt() {
const saveNoteEdit = async () => {
if (!editingNotes || !token) return
try {
await updateDirectusObject(editingNotes.id, { user_notes: editingNotes.notes }, token)
await updateDbObject(editingNotes.id, { user_notes: editingNotes.notes }, token)
setObjects(prev => prev.map(o => o.id === editingNotes.id ? { ...o, user_notes: editingNotes.notes } : o))
setEditingNotes(null)
showStatus('Notizen gespeichert.')
@@ -189,11 +257,33 @@ 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) => {
if (!token) return
try {
await deleteDirectusObject(objId, token)
await deleteDbObject(objId, token)
setObjects(prev => prev.filter(o => o.id !== objId))
setObjectWords(prev => { const n = { ...prev }; delete n[objId]; return n })
setWordInputs(prev => { const n = { ...prev }; delete n[objId]; return n })
if (selectedObjectId === objId) setSelectedObjectId(null)
showStatus('Objekt gelöscht.')
} catch (e) {
@@ -201,6 +291,24 @@ export default function DrawIt() {
}
}
const deleteCurrentPicture = async () => {
if (!token || !currentPicture) return
if (!window.confirm('Bild wirklich löschen? Der Eintrag und die Datei werden dauerhaft aus Directus entfernt.')) return
setDeleting(true)
try {
await deleteDbPicture(currentPicture.id, token)
const newList = pictureList.filter(p => p.id !== currentPicture.id)
setPictureList(newList)
const nextIndex = Math.min(currentIndex, newList.length - 1)
setCurrentIndex(nextIndex)
showStatus('Bild gelöscht.')
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Fehler beim Löschen.', true)
} finally {
setDeleting(false)
}
}
const imageNav = (
<div className="image-nav">
<button className="btn-icon" onClick={() => setCurrentIndex(i => i - 1)} disabled={currentIndex <= 0}>
@@ -214,6 +322,15 @@ export default function DrawIt() {
<button className="btn-icon" onClick={() => setCurrentIndex(i => i + 1)} disabled={currentIndex >= pictureList.length - 1}>
<ChevronRightIcon />
</button>
<button
className="btn-icon"
onClick={deleteCurrentPicture}
disabled={deleting || !currentPicture}
title="Bild löschen"
style={{ color: 'var(--error, #dc2626)', marginLeft: 8 }}
>
<TrashIcon />
</button>
</div>
)
@@ -224,6 +341,45 @@ export default function DrawIt() {
<div className="workspace">
{/* Left sidebar: saved objects */}
<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 }}>
<WordAutocomplete
value={pictureWordInput}
onChange={setPictureWordInput}
onSubmit={handleAddPictureWord}
token={token}
placeholder="Hauptwort hinzufügen…"
excludeTitles={pictureWords.map(w => w.titel_de)}
inputStyle={{ 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, width: '100%' }}
/>
<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 }}>
<h3 className="sidebar-heading">
Objekte
@@ -250,7 +406,6 @@ export default function DrawIt() {
<div className="object-item-text">
<strong>Objekt {i + 1}</strong>
<span>{obj.selections?.length ? `${obj.selections.length} Auswahl(en)` : ''}</span>
{obj.parent && <span style={{ color: 'var(--primary-muted-fg)', fontSize: 11 }}> Kind von #{objects.findIndex(o => o.id === obj.parent) + 1}</span>}
</div>
<button
className="object-icon-button"
@@ -286,19 +441,50 @@ export default function DrawIt() {
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
{objects.length > 0 && (
{currentPicture && (
<div className="sidebar-panel">
<h3 className="sidebar-heading">Design</h3>
<select
value={currentPicture.design || ''}
onChange={async e => {
const value = e.target.value
const design = value || null
setPictureList(prev => prev.map(p => p.id === currentPicture.id ? { ...p, design } : p))
try {
await updateDbPicture(currentPicture.id, { design }, token!)
showStatus('Design gespeichert.')
} catch (e) {
showStatus('Fehler beim Speichern des Designs.', true)
}
}}
style={{
width: '100%', padding: '6px 8px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', background: 'var(--surface-2)',
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 13, cursor: 'pointer',
}}
>
<option value=""> kein Design </option>
{designOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.text}</option>
))}
</select>
</div>
)}
{currentPicture && (
<div className="sidebar-panel">
<button
className="btn-primary btn-sm btn-block"
onClick={finishPicture}
disabled={finishing}
style={{ background: 'var(--success, #16a34a)' }}
disabled={finishing || objects.length === 0}
style={{ background: objects.length > 0 ? 'var(--success, #16a34a)' : undefined, opacity: objects.length === 0 ? 0.5 : 1 }}
>
{finishing ? 'Wird fertiggestellt…' : '✅ Fertigstellen'}
</button>
@@ -308,14 +494,27 @@ export default function DrawIt() {
{/* Center: Canvas */}
<main className="canvas-area">
<div className="canvas-frame">
<div
className="canvas-frame"
style={{ position: 'relative', background: 'var(--surface-2)' }}
>
{/* Blurhash-Platzhalter: sichtbar solange das echte Bild noch lädt */}
{currentPicture?.blurhash && !imageLoaded && (
<BlurhashCanvas
hash={currentPicture.blurhash}
width={32}
height={32}
style={{ zIndex: 1 }}
/>
)}
<DrawCanvas
ref={canvasRef}
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.media, token) : null}
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.picture, token) : null}
objects={canvasObjects}
selectedObjectId={selectedObjectId}
mode={mode}
onHasSelection={handleHasSelection}
onImageLoad={() => setImageLoaded(true)}
/>
</div>
</main>
@@ -369,21 +568,6 @@ export default function DrawIt() {
/>
</div>
<div className="sidebar-panel">
<h3 className="sidebar-heading">Parent-Objekt</h3>
<select
value={parentId ?? ''}
onChange={e => setParentId(e.target.value || null)}
>
<option value=""> kein Parent </option>
{objects.map((obj, i) => (
<option key={obj.id} value={obj.id}>
Objekt {i + 1}{obj.user_notes ? ` ${obj.user_notes.slice(0, 30)}` : ''}
</option>
))}
</select>
</div>
<div className="sidebar-panel">
<h3 className="sidebar-heading">
Auswahlen
@@ -422,124 +606,74 @@ export default function DrawIt() {
Alle löschen
</button>
</div>
{status && <div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{status}</div>}
{statusMsg && <div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{statusMsg}</div>}
</div>
</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 className="sidebar-heading">
Wörter
{selectedObjectId
? ` (Objekt ${objects.findIndex(o => o.id === selectedObjectId) + 1})`
: ' (neues Objekt)'}
</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>
{/* Existing word chips for selected object */}
{selectedObjectId && (
<div style={{ display:'flex', flexWrap:'wrap', gap:4, marginBottom:6 }}>
{(objectWords[selectedObjectId] || []).map(w => (
<span key={w.junction_id} style={{
display:'flex', alignItems:'center', gap:3,
padding:'2px 8px', background:'#e0e7ff', color:'#3730a3',
borderRadius:9999, fontSize:11,
}}>
{w.titel_de}
<button onClick={() => handleRemoveObjectWord(selectedObjectId, w.junction_id!)}
style={{ background:'none', border:'none', cursor:'pointer', color:'#818cf8', padding:0, fontSize:13 }}>×</button>
</span>
))}
{(objectWords[selectedObjectId] || []).length === 0 && (
<span style={{ fontSize:11, color:'var(--text-2)' }}>Noch keine Wörter</span>
)}
</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)',
{/* Pending words for new object */}
{!selectedObjectId && pendingWords.length > 0 && (
<div style={{ display:'flex', flexWrap:'wrap', gap:4, marginBottom:6 }}>
{pendingWords.map((w, i) => (
<span key={i} style={{
display:'flex', alignItems:'center', gap:3,
padding:'2px 8px', background:'#fef3c7', color:'#92400e',
borderRadius:9999, fontSize:11,
}}>
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-1)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{w.title_de}
{w}
<button onClick={() => setPendingWords(prev => prev.filter((_, j) => j !== i))}
style={{ background:'none', border:'none', cursor:'pointer', color:'#d97706', padding:0, fontSize:13 }}>×</button>
</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',
}}
{/* Add word input */}
<div style={{ display:'flex', gap:4 }}>
<WordAutocomplete
value={selectedObjectId ? (wordInputs[selectedObjectId] || '') : newWordInput}
onChange={v => selectedObjectId
? setWordInputs(prev => ({ ...prev, [selectedObjectId]: v }))
: setNewWordInput(v)}
onSubmit={() => selectedObjectId ? handleAddObjectWord(selectedObjectId) : handleAddPendingWord()}
token={token}
placeholder="Wort hinzufügen…"
excludeTitles={selectedObjectId
? (objectWords[selectedObjectId] || []).map(w => w.titel_de)
: pendingWords}
inputStyle={{ 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, width: '100%' }}
/>
<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>
onClick={() => selectedObjectId ? handleAddObjectWord(selectedObjectId) : handleAddPendingWord()}
style={{ padding:'4px 10px', borderRadius:'var(--r-sm)', background:'#6366f1', color:'#fff', border:'none', cursor:'pointer', fontSize:12 }}
>+</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>

File diff suppressed because it is too large Load Diff

123
frontend/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,123 @@
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import Topbar from '../components/Topbar'
const PenIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
)
const FolderIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" />
<line x1="8" y1="13" x2="16" y2="13" />
</svg>
)
const UsersIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
)
const LockIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
)
interface TileProps {
icon: React.ReactNode
title: string
subtitle: string
onClick?: () => void
disabled?: boolean
comingSoon?: boolean
adminOnly?: boolean
locked?: boolean
}
function Tile({ icon, title, subtitle, onClick, disabled, comingSoon, adminOnly, locked }: TileProps) {
return (
<button
type="button"
className={`home-tile${disabled ? ' is-disabled' : ''}`}
onClick={disabled ? undefined : onClick}
disabled={disabled}
>
<div className="home-tile-icon">{icon}</div>
<div className="home-tile-body">
<div className="home-tile-title">
{title}
{adminOnly && (
<span className="home-tile-badge" title="Nur für Admins">
<LockIcon /> Admin
</span>
)}
{comingSoon && <span className="home-tile-badge home-tile-badge-soft">bald</span>}
</div>
<div className="home-tile-subtitle">{subtitle}</div>
</div>
{locked && <div className="home-tile-locked"><LockIcon /></div>}
</button>
)
}
export default function Home() {
const navigate = useNavigate()
const { user, isAdmin } = useAuth()
const firstName = user?.first_name?.trim() || user?.email?.split('@')[0] || 'da'
return (
<div className="app-shell">
<Topbar page="draw" />
<div className="home-page">
<div className="home-greeting">
<h1>Super, {firstName} ist wieder da,</h1>
<p>Was möchtest du heute machen?</p>
</div>
<div className="home-tiles">
<Tile
icon={<PenIcon />}
title="Content erstellen"
subtitle="Bilder annotieren, Objekte zuschneiden, Fragen generieren."
onClick={() => navigate('/draw')}
/>
<Tile
icon={<FolderIcon />}
title="Content verwalten"
subtitle={isAdmin
? 'Übersicht aller Collections nach Status — bearbeiten als Liste oder Kacheln.'
: 'Status-Dashboard für alle Collections. Nur für Admins verfügbar.'}
adminOnly
disabled={!isAdmin}
locked={!isAdmin}
onClick={() => navigate('/content')}
/>
<Tile
icon={<UsersIcon />}
title="User verwalten"
subtitle={isAdmin
? 'Admins, API-Tokens und Endnutzer — inkl. aktive Sessions.'
: 'User-Übersicht und aktive Sessions. Nur für Admins verfügbar.'}
adminOnly
comingSoon
disabled
locked={!isAdmin}
onClick={() => { /* Schritt 3 */ }}
/>
</div>
</div>
</div>
)
}

View File

@@ -2,7 +2,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { useTheme } from '../context/ThemeContext'
import { directusLogin } from '../api'
import { directusLogin, getDirectusMe } from '../api'
const CrosshairIcon = () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -42,8 +42,9 @@ export default function Login() {
setLoading(true)
try {
const token = await directusLogin(email, password)
login(token)
navigate('/draw')
const me = await getDirectusMe(token)
login(token, me)
navigate('/')
} catch {
setError('Ungültige Zugangsdaten. Bitte erneut versuchen.')
} finally {

View File

@@ -54,6 +54,72 @@ export interface PictureWord {
status: string
}
// ── db_* Collection types ─────────────────────────────────────────────────────
export interface DbPicture {
id: string
picture: string // UUID → directus_files (für asset URL)
blurhash: string | null
status: string
design: string | null
}
export interface DbObject {
id: string
status: string
picture: string // → DbPicture.id
selections: Selection[] | null
user_notes: string | null
visible?: boolean // nur UI-State
}
export interface DbWord {
junction_id?: string | number
word_id: string
titel_de: string
level: number
status: string
}
export interface DbPairWordEntry {
junction_id: number
word_id: string
titel_de: string
level: number
}
export interface DbPairQuestion {
id: string
question_de: string
level: number
status: string
words: DbPairWordEntry[]
}
export interface DbPairStatement {
id: string
statement_de: string
level: number
status: string
words: DbPairWordEntry[]
}
export interface DbPair {
id: string
level: number
status: string
questions: DbPairQuestion[]
statements: DbPairStatement[]
}
export interface ObjectChip {
objectId: string
wordId: string
junctionId: string | number
label: string // titel_de
objectIndex: number // the object's index number for display
}
// Legacy — still used by GenerateIt
export interface ObjectMeta {
id: string