Compare commits
35 Commits
claude/aff
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 79c6926cec | |||
| e066ff7420 | |||
| d02788bd0e | |||
| 05c62ac414 | |||
| ceaa7eff3c | |||
| 9a32e4a39b | |||
| 5a7777f555 | |||
| 22e6b0a5a5 | |||
| 79d1f2ba21 | |||
| 255ec51858 | |||
| 5424fea8e1 | |||
| 17918a414b | |||
| 40c36182f1 | |||
| 214f8a2019 | |||
| 2595b8d32e | |||
| 2e6cf094cb | |||
| 8bcb3b9168 | |||
| 1bbe64db66 | |||
| f4b082329e | |||
| 7c983a7460 | |||
|
|
5b99bef765 | ||
|
|
5357805530 | ||
|
|
2458a024b3 | ||
|
|
9c8ec853f6 | ||
|
|
d94c4a57c5 | ||
|
|
a622ac49df | ||
|
|
de124440a4 | ||
|
|
622907d426 | ||
|
|
0340f9bb7d | ||
|
|
5e0de3014e | ||
|
|
860391bcbe | ||
|
|
cc782c0ef0 | ||
|
|
9acc1d93b4 | ||
|
|
08cce17976 | ||
|
|
202d4333a8 |
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "content-mentor-frontend",
|
"name": "content-mentor-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"blurhash": "^2.0.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.0"
|
"react-router-dom": "^6.26.0"
|
||||||
@@ -1223,6 +1224,12 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.2",
|
"version": "4.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"blurhash": "^2.0.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.0"
|
"react-router-dom": "^6.26.0"
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { Routes, Route, Navigate } from 'react-router-dom'
|
|||||||
import DrawIt from './pages/DrawIt'
|
import DrawIt from './pages/DrawIt'
|
||||||
import ExpandIt from './pages/ExpandIt'
|
import ExpandIt from './pages/ExpandIt'
|
||||||
import GenerateIt from './pages/GenerateIt'
|
import GenerateIt from './pages/GenerateIt'
|
||||||
|
import Home from './pages/Home'
|
||||||
import Login from './pages/Login'
|
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 { useAuth } from './context/AuthContext'
|
||||||
import { ThemeProvider } from './context/ThemeContext'
|
import { ThemeProvider } from './context/ThemeContext'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
@@ -12,15 +16,25 @@ function PrivateRoute({ children }: { children: ReactNode }) {
|
|||||||
return token ? <>{children}</> : <Navigate to="/login" replace />
|
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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<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="/draw" element={<PrivateRoute><DrawIt /></PrivateRoute>} />
|
||||||
<Route path="/generate" element={<PrivateRoute><GenerateIt /></PrivateRoute>} />
|
<Route path="/generate" element={<PrivateRoute><GenerateIt /></PrivateRoute>} />
|
||||||
<Route path="/expand" element={<PrivateRoute><ExpandIt /></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>
|
</Routes>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,27 @@ export async function directusLogin(email: string, password: string): Promise<st
|
|||||||
return data.data.access_token
|
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 {
|
export interface DirectusPicture {
|
||||||
id: string
|
id: string
|
||||||
media: 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')
|
if (!res.ok) throw new Error('Fehler beim globalen Bereinigen')
|
||||||
return data
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
50
frontend/src/components/BlurhashCanvas.tsx
Normal file
50
frontend/src/components/BlurhashCanvas.tsx
Normal 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,11 +22,12 @@ interface Props {
|
|||||||
selectedObjectId: string | null
|
selectedObjectId: string | null
|
||||||
mode: 'rect' | 'polygon'
|
mode: 'rect' | 'polygon'
|
||||||
onHasSelection: (has: boolean) => void
|
onHasSelection: (has: boolean) => void
|
||||||
|
onImageLoad?: () => void
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default forwardRef<DrawCanvasHandle, Props>(function DrawCanvas(
|
export default forwardRef<DrawCanvasHandle, Props>(function DrawCanvas(
|
||||||
{ imageSrc, objects, selectedObjectId, mode, onHasSelection, readOnly = false },
|
{ imageSrc, objects, selectedObjectId, mode, onHasSelection, onImageLoad, readOnly = false },
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
@@ -271,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +280,7 @@ export default forwardRef<DrawCanvasHandle, Props>(function DrawCanvas(
|
|||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
imageCache.set(imageSrc, img)
|
imageCache.set(imageSrc, img)
|
||||||
applyImage(img)
|
applyImage(img)
|
||||||
|
onImageLoad?.()
|
||||||
}
|
}
|
||||||
img.onerror = () => console.error('Fehler beim Laden des Bildes:', imageSrc)
|
img.onerror = () => console.error('Fehler beim Laden des Bildes:', imageSrc)
|
||||||
img.src = imageSrc
|
img.src = imageSrc
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const CrosshairIcon = () => (
|
|||||||
)
|
)
|
||||||
|
|
||||||
interface TopbarProps {
|
interface TopbarProps {
|
||||||
page: 'draw' | 'generate' | 'expand'
|
page: 'home' | 'draw' | 'generate' | 'expand'
|
||||||
center?: ReactNode
|
center?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,12 +46,17 @@ export default function Topbar({ page, center }: TopbarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="topbar-brand">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topbar-brand"
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
title="Zur Startseite"
|
||||||
|
>
|
||||||
<span className="topbar-logo-icon">
|
<span className="topbar-logo-icon">
|
||||||
<CrosshairIcon />
|
<CrosshairIcon />
|
||||||
</span>
|
</span>
|
||||||
<span className="topbar-brand-name">Content Mentor</span>
|
<span className="topbar-brand-name">Content Mentor</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<nav className="topbar-nav">
|
<nav className="topbar-nav">
|
||||||
<button
|
<button
|
||||||
|
|||||||
157
frontend/src/components/WordAutocomplete.tsx
Normal file
157
frontend/src/components/WordAutocomplete.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||||
|
import type { DirectusMe } from '../api'
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
token: string | null
|
token: string | null
|
||||||
login: (token: string) => void
|
user: DirectusMe | null
|
||||||
|
isAdmin: boolean
|
||||||
|
login: (token: string, user: DirectusMe) => void
|
||||||
logout: () => void
|
logout: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10,9 +13,22 @@ const AuthContext = createContext<AuthContextType | null>(null)
|
|||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [token, setToken] = useState<string | null>(null)
|
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 (
|
return (
|
||||||
<AuthContext.Provider value={{ token, login: setToken, logout: () => setToken(null) }}>
|
<AuthContext.Provider value={{ token, user, isAdmin, login, logout }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -132,6 +132,18 @@ body {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
user-select: none;
|
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 {
|
.topbar-logo-icon {
|
||||||
@@ -1317,3 +1329,746 @@ select:focus {
|
|||||||
transform: none;
|
transform: none;
|
||||||
box-shadow: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
194
frontend/src/pages/ContentEdit.tsx
Normal file
194
frontend/src/pages/ContentEdit.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
247
frontend/src/pages/ContentList.tsx
Normal file
247
frontend/src/pages/ContentList.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
106
frontend/src/pages/ContentManage.tsx
Normal file
106
frontend/src/pages/ContentManage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,28 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
|
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
|
||||||
|
import BlurhashCanvas from '../components/BlurhashCanvas'
|
||||||
import Topbar from '../components/Topbar'
|
import Topbar from '../components/Topbar'
|
||||||
|
import WordAutocomplete from '../components/WordAutocomplete'
|
||||||
import {
|
import {
|
||||||
getDirectusPictures, directusAssetUrl, type DirectusPicture,
|
getDbPictures,
|
||||||
getDirectusObjects, createDirectusObject, updateDirectusObject, deleteDirectusObject,
|
updateDbPicture,
|
||||||
updatePictureStatus, getPictureWords, savePictureWords,
|
updateDbPictureStatus,
|
||||||
|
deleteDbPicture,
|
||||||
|
getDbObjects,
|
||||||
|
createDbObject,
|
||||||
|
updateDbObject,
|
||||||
|
deleteDbObject,
|
||||||
|
getDbObjectWords,
|
||||||
|
addDbObjectWord,
|
||||||
|
deleteDbObjectWord,
|
||||||
|
getDbPictureWords,
|
||||||
|
addDbPictureWord,
|
||||||
|
deleteDbPictureWord,
|
||||||
|
directusAssetUrl,
|
||||||
|
getDesignOptions,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import type { DirectusObject, Selection, CanvasObject, PictureWord } from '../types'
|
import type { DbPicture, DbObject, DbWord, Selection, CanvasObject } from '../types'
|
||||||
|
|
||||||
const ChevronLeftIcon = () => (
|
const ChevronLeftIcon = () => (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
@@ -28,65 +43,48 @@ const TrashIcon = () => (
|
|||||||
export default function DrawIt() {
|
export default function DrawIt() {
|
||||||
const { token } = useAuth()
|
const { token } = useAuth()
|
||||||
|
|
||||||
const [pictureList, setPictureList] = useState<DirectusPicture[]>([])
|
const [pictureList, setPictureList] = useState<DbPicture[]>([])
|
||||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||||
const [objects, setObjects] = useState<DirectusObject[]>([])
|
const [debouncedIndex, setDebouncedIndex] = useState(-1)
|
||||||
|
const [objects, setObjects] = useState<DbObject[]>([])
|
||||||
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
|
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
|
||||||
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
|
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
|
||||||
const [userNotes, setUserNotes] = useState('')
|
const [userNotes, setUserNotes] = useState('')
|
||||||
const [safeWords, setSafeWords] = useState<{ title: string; level: number }[]>([])
|
// per-object words: objectId → DbWord[]
|
||||||
const [safeWordInput, setSafeWordInput] = useState('')
|
const [objectWords, setObjectWords] = useState<Record<string, DbWord[]>>({})
|
||||||
const [safeWordLevel, setSafeWordLevel] = useState(50)
|
// per-object word input values: objectId → current input text
|
||||||
const [safeWordInputVisible, setSafeWordInputVisible] = useState(false)
|
const [wordInputs, setWordInputs] = useState<Record<string, string>>({})
|
||||||
const safeWordInputRef = useRef<HTMLInputElement>(null)
|
const [pendingWords, setPendingWords] = useState<string[]>([])
|
||||||
const [pictureWords, setPictureWords] = useState<PictureWord[]>([])
|
const [newWordInput, setNewWordInput] = useState('')
|
||||||
const [savingWords, setSavingWords] = useState(false)
|
const [designOptions, setDesignOptions] = useState<{ text: string; value: string }[]>([])
|
||||||
const [parentId, setParentId] = useState<string | null>(null)
|
|
||||||
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null)
|
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null)
|
||||||
const [mode, setMode] = useState<'rect' | 'polygon'>('polygon')
|
const [mode, setMode] = useState<'rect' | 'polygon'>('polygon')
|
||||||
const [hasSelection, setHasSelection] = useState(false)
|
const [hasSelection, setHasSelection] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [finishing, setFinishing] = 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 [statusError, setStatusError] = 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)
|
||||||
|
|
||||||
|
// Debounce: only load picture data after 350ms of no navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (safeWordInputVisible) safeWordInputRef.current?.focus()
|
const t = setTimeout(() => setDebouncedIndex(currentIndex), 350)
|
||||||
}, [safeWordInputVisible])
|
return () => clearTimeout(t)
|
||||||
|
}, [currentIndex])
|
||||||
|
|
||||||
const addSafeWord = () => {
|
// Reset imageLoaded immediately on navigation so blurhash shows right away
|
||||||
const title = safeWordInput.trim()
|
useEffect(() => {
|
||||||
if (!title || safeWords.some(w => w.title === title) || pictureWords.some(w => w.title_de === title)) {
|
setImageLoaded(false)
|
||||||
setSafeWordInput(''); return
|
}, [currentIndex])
|
||||||
}
|
|
||||||
setSafeWords(prev => [...prev, { title, level: safeWordLevel }])
|
|
||||||
setSafeWordInput('')
|
|
||||||
setSafeWordLevel(50)
|
|
||||||
setSafeWordInputVisible(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveSafeWords = async () => {
|
const currentPicture: DbPicture | null =
|
||||||
if (!currentPicture || !token || safeWords.length === 0) return
|
debouncedIndex >= 0 && debouncedIndex < pictureList.length ? pictureList[debouncedIndex] : null
|
||||||
setSavingWords(true)
|
|
||||||
try {
|
|
||||||
await savePictureWords(currentPicture.id, safeWords.map(w => ({ title_de: w.title, level: w.level })), token)
|
|
||||||
const updated = await getPictureWords(currentPicture.id, token)
|
|
||||||
setPictureWords(updated)
|
|
||||||
setSafeWords([])
|
|
||||||
showStatus('Wörter gespeichert.')
|
|
||||||
} catch (e) {
|
|
||||||
showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern der Wörter.', true)
|
|
||||||
} finally {
|
|
||||||
setSavingWords(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPicture: DirectusPicture | null =
|
|
||||||
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
|
|
||||||
|
|
||||||
// Map DirectusObject → CanvasObject for rendering
|
|
||||||
const canvasObjects: CanvasObject[] = objects.map((obj, i) => ({
|
const canvasObjects: CanvasObject[] = objects.map((obj, i) => ({
|
||||||
id: obj.id,
|
id: obj.id,
|
||||||
visible: obj.visible !== false,
|
visible: obj.visible !== false,
|
||||||
@@ -95,33 +93,95 @@ export default function DrawIt() {
|
|||||||
hierarchy: 1,
|
hierarchy: 1,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Load db_pictures with status=draft
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
getDirectusPictures(token)
|
getDbPictures(token, 'draft')
|
||||||
.then(pics => { setPictureList(pics); setCurrentIndex(pics.length > 0 ? 0 : -1) })
|
.then(pics => { setPictureList(pics); setCurrentIndex(pics.length > 0 ? 0 : -1) })
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
}, [token])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!currentPicture || !token) {
|
if (!currentPicture || !token) {
|
||||||
setObjects([]); setSelectedObjectId(null)
|
setObjects([]); setSelectedObjectId(null)
|
||||||
setPictureWords([]); setSafeWords([])
|
setObjectWords({})
|
||||||
|
setWordInputs({})
|
||||||
|
setImageLoaded(false)
|
||||||
|
setPictureWords([])
|
||||||
|
setPictureWordInput('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
getDirectusObjects(currentPicture.id, token)
|
getDbObjects(currentPicture.id, token)
|
||||||
.then(objs => { setObjects(objs.map(o => ({ ...o, visible: true }))); setSelectedObjectId(null) })
|
.then(objs => {
|
||||||
.catch(console.error)
|
setObjects(objs.map(o => ({ ...o, visible: true })))
|
||||||
getPictureWords(currentPicture.id, token)
|
setSelectedObjectId(null)
|
||||||
.then(setPictureWords)
|
// 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)
|
.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) => {
|
||||||
setStatus(msg); setStatusError(isError)
|
setStatusMsg(msg); setStatusError(isError)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), [])
|
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 addSelection = () => {
|
||||||
const sel = canvasRef.current?.getCurrentSelection()
|
const sel = canvasRef.current?.getCurrentSelection()
|
||||||
if (!sel) { showStatus('Bitte zuerst einen Bereich auswählen.', true); return }
|
if (!sel) { showStatus('Bitte zuerst einen Bereich auswählen.', true); return }
|
||||||
@@ -134,16 +194,27 @@ export default function DrawIt() {
|
|||||||
if (!currentPicture || !token || currentSelections.length === 0) return
|
if (!currentPicture || !token || currentSelections.length === 0) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const obj = await createDirectusObject({
|
const obj = await createDbObject({
|
||||||
picture: currentPicture.id,
|
picture: currentPicture.id,
|
||||||
selections: currentSelections,
|
selections: currentSelections,
|
||||||
user_notes: userNotes.trim() || null,
|
user_notes: userNotes.trim() || null,
|
||||||
parent: parentId,
|
|
||||||
}, token)
|
}, 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 }])
|
setObjects(prev => [...prev, { ...obj, visible: true }])
|
||||||
|
setObjectWords(prev => ({ ...prev, [obj.id]: savedWords }))
|
||||||
setCurrentSelections([])
|
setCurrentSelections([])
|
||||||
setUserNotes('')
|
setUserNotes('')
|
||||||
setParentId(null)
|
setPendingWords([])
|
||||||
|
setNewWordInput('')
|
||||||
canvasRef.current?.resetSelection()
|
canvasRef.current?.resetSelection()
|
||||||
showStatus('Objekt gespeichert.')
|
showStatus('Objekt gespeichert.')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -153,11 +224,16 @@ export default function DrawIt() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark picture as objects_created and remove from list
|
||||||
const finishPicture = async () => {
|
const finishPicture = async () => {
|
||||||
if (!currentPicture || !token) return
|
if (!currentPicture || !token) return
|
||||||
setFinishing(true)
|
setFinishing(true)
|
||||||
try {
|
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))
|
setPictureList(prev => prev.filter(p => p.id !== currentPicture.id))
|
||||||
setCurrentIndex(i => Math.max(0, i - 1))
|
setCurrentIndex(i => Math.max(0, i - 1))
|
||||||
setObjects([])
|
setObjects([])
|
||||||
@@ -172,7 +248,7 @@ export default function DrawIt() {
|
|||||||
const saveNoteEdit = async () => {
|
const saveNoteEdit = async () => {
|
||||||
if (!editingNotes || !token) return
|
if (!editingNotes || !token) return
|
||||||
try {
|
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))
|
setObjects(prev => prev.map(o => o.id === editingNotes.id ? { ...o, user_notes: editingNotes.notes } : o))
|
||||||
setEditingNotes(null)
|
setEditingNotes(null)
|
||||||
showStatus('Notizen gespeichert.')
|
showStatus('Notizen gespeichert.')
|
||||||
@@ -181,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) => {
|
const deleteObject = async (objId: string) => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
try {
|
try {
|
||||||
await deleteDirectusObject(objId, token)
|
await deleteDbObject(objId, token)
|
||||||
setObjects(prev => prev.filter(o => o.id !== objId))
|
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)
|
if (selectedObjectId === objId) setSelectedObjectId(null)
|
||||||
showStatus('Objekt gelöscht.')
|
showStatus('Objekt gelöscht.')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -193,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 = (
|
const imageNav = (
|
||||||
<div className="image-nav">
|
<div className="image-nav">
|
||||||
<button className="btn-icon" onClick={() => setCurrentIndex(i => i - 1)} disabled={currentIndex <= 0}>
|
<button className="btn-icon" onClick={() => setCurrentIndex(i => i - 1)} disabled={currentIndex <= 0}>
|
||||||
@@ -206,6 +322,15 @@ export default function DrawIt() {
|
|||||||
<button className="btn-icon" onClick={() => setCurrentIndex(i => i + 1)} disabled={currentIndex >= pictureList.length - 1}>
|
<button className="btn-icon" onClick={() => setCurrentIndex(i => i + 1)} disabled={currentIndex >= pictureList.length - 1}>
|
||||||
<ChevronRightIcon />
|
<ChevronRightIcon />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={deleteCurrentPicture}
|
||||||
|
disabled={deleting || !currentPicture}
|
||||||
|
title="Bild löschen"
|
||||||
|
style={{ color: 'var(--error, #dc2626)', marginLeft: 8 }}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -216,6 +341,45 @@ 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 }}>
|
||||||
|
<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 }}>
|
<div className="sidebar-panel" style={{ flex: 1 }}>
|
||||||
<h3 className="sidebar-heading">
|
<h3 className="sidebar-heading">
|
||||||
Objekte
|
Objekte
|
||||||
@@ -242,7 +406,6 @@ export default function DrawIt() {
|
|||||||
<div className="object-item-text">
|
<div className="object-item-text">
|
||||||
<strong>Objekt {i + 1}</strong>
|
<strong>Objekt {i + 1}</strong>
|
||||||
<span>{obj.selections?.length ? `${obj.selections.length} Auswahl(en)` : '–'}</span>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="object-icon-button"
|
className="object-icon-button"
|
||||||
@@ -278,19 +441,50 @@ export default function DrawIt() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</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">
|
<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>
|
||||||
@@ -300,14 +494,27 @@ export default function DrawIt() {
|
|||||||
|
|
||||||
{/* Center: Canvas */}
|
{/* Center: Canvas */}
|
||||||
<main className="canvas-area">
|
<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
|
<DrawCanvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.media, token) : null}
|
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.picture, token) : null}
|
||||||
objects={canvasObjects}
|
objects={canvasObjects}
|
||||||
selectedObjectId={selectedObjectId}
|
selectedObjectId={selectedObjectId}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
onHasSelection={handleHasSelection}
|
onHasSelection={handleHasSelection}
|
||||||
|
onImageLoad={() => setImageLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -361,21 +568,6 @@ export default function DrawIt() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="sidebar-panel">
|
||||||
<h3 className="sidebar-heading">
|
<h3 className="sidebar-heading">
|
||||||
Auswahlen
|
Auswahlen
|
||||||
@@ -414,124 +606,74 @@ export default function DrawIt() {
|
|||||||
Alle löschen
|
Alle löschen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{status && <div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{status}</div>}
|
{statusMsg && <div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{statusMsg}</div>}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Words sidebar */}
|
|
||||||
<aside className="sidebar sidebar--words">
|
|
||||||
<div className="sidebar-panel">
|
<div className="sidebar-panel">
|
||||||
<h3 className="sidebar-heading" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<h3 className="sidebar-heading">
|
||||||
<span>Words{(pictureWords.length + safeWords.length) > 0 && <span className="badge" style={{ marginLeft: 6 }}>{pictureWords.length + safeWords.length}</span>}</span>
|
Wörter
|
||||||
<button
|
{selectedObjectId
|
||||||
className="btn-icon"
|
? ` (Objekt ${objects.findIndex(o => o.id === selectedObjectId) + 1})`
|
||||||
style={{ width: 22, height: 22, borderRadius: 'var(--r-sm)', fontSize: 16, lineHeight: 1, padding: 0 }}
|
: ' (neues Objekt)'}
|
||||||
onClick={() => setSafeWordInputVisible(v => !v)}
|
|
||||||
title="Word hinzufügen"
|
|
||||||
>+</button>
|
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{safeWordInputVisible && (
|
{/* Existing word chips for selected object */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
|
{selectedObjectId && (
|
||||||
<input
|
<div style={{ display:'flex', flexWrap:'wrap', gap:4, marginBottom:6 }}>
|
||||||
ref={safeWordInputRef}
|
{(objectWords[selectedObjectId] || []).map(w => (
|
||||||
value={safeWordInput}
|
<span key={w.junction_id} style={{
|
||||||
onChange={e => setSafeWordInput(e.target.value)}
|
display:'flex', alignItems:'center', gap:3,
|
||||||
onKeyDown={e => {
|
padding:'2px 8px', background:'#e0e7ff', color:'#3730a3',
|
||||||
if (e.key === 'Enter') addSafeWord()
|
borderRadius:9999, fontSize:11,
|
||||||
if (e.key === 'Escape') { setSafeWordInputVisible(false); setSafeWordInput('') }
|
}}>
|
||||||
}}
|
{w.titel_de}
|
||||||
placeholder="Wort…"
|
<button onClick={() => handleRemoveObjectWord(selectedObjectId, w.junction_id!)}
|
||||||
style={{
|
style={{ background:'none', border:'none', cursor:'pointer', color:'#818cf8', padding:0, fontSize:13 }}>×</button>
|
||||||
width: '100%', padding: '5px 8px', borderRadius: 'var(--r-sm)',
|
</span>
|
||||||
border: '1px solid var(--border)', background: 'var(--surface-2)',
|
))}
|
||||||
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12,
|
{(objectWords[selectedObjectId] || []).length === 0 && (
|
||||||
boxSizing: 'border-box',
|
<span style={{ fontSize:11, color:'var(--text-2)' }}>Noch keine Wörter</span>
|
||||||
}}
|
)}
|
||||||
/>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
<label style={{ fontSize: 11, color: 'var(--text-2)', whiteSpace: 'nowrap' }}>Level</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1} max={100}
|
|
||||||
value={safeWordLevel}
|
|
||||||
onChange={e => setSafeWordLevel(Math.min(100, Math.max(1, Number(e.target.value))))}
|
|
||||||
style={{
|
|
||||||
flex: 1, padding: '4px 6px', borderRadius: 'var(--r-sm)',
|
|
||||||
border: '1px solid var(--border)', background: 'var(--surface-2)',
|
|
||||||
color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button className="btn-primary btn-sm" style={{ padding: '4px 8px' }} onClick={addSafeWord}>✓</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Saved words from Directus */}
|
{/* Pending words for new object */}
|
||||||
{pictureWords.length > 0 && (
|
{!selectedObjectId && pendingWords.length > 0 && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: safeWords.length > 0 ? 8 : 0 }}>
|
<div style={{ display:'flex', flexWrap:'wrap', gap:4, marginBottom:6 }}>
|
||||||
{pictureWords.map(w => (
|
{pendingWords.map((w, i) => (
|
||||||
<div key={w.word_id} style={{
|
<span key={i} style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display:'flex', alignItems:'center', gap:3,
|
||||||
padding: '4px 8px', borderRadius: 'var(--r-sm)',
|
padding:'2px 8px', background:'#fef3c7', color:'#92400e',
|
||||||
background: 'var(--surface-2)', border: '1px solid var(--border)',
|
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}
|
||||||
{w.title_de}
|
<button onClick={() => setPendingWords(prev => prev.filter((_, j) => j !== i))}
|
||||||
</span>
|
style={{ background:'none', border:'none', cursor:'pointer', color:'#d97706', padding:0, fontSize:13 }}>×</button>
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-2)', marginLeft: 6, flexShrink: 0 }}>L{w.level}</span>
|
</span>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pending new words */}
|
{/* Add word input */}
|
||||||
{safeWords.length > 0 && (
|
<div style={{ display:'flex', gap:4 }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<WordAutocomplete
|
||||||
{safeWords.map((w, i) => (
|
value={selectedObjectId ? (wordInputs[selectedObjectId] || '') : newWordInput}
|
||||||
<div key={i} style={{
|
onChange={v => selectedObjectId
|
||||||
display: 'flex', alignItems: 'center', gap: 4,
|
? setWordInputs(prev => ({ ...prev, [selectedObjectId]: v }))
|
||||||
padding: '4px 8px', borderRadius: 'var(--r-sm)',
|
: setNewWordInput(v)}
|
||||||
background: 'var(--primary-muted)', border: '1px solid color-mix(in srgb, var(--primary) 30%, transparent)',
|
onSubmit={() => selectedObjectId ? handleAddObjectWord(selectedObjectId) : handleAddPendingWord()}
|
||||||
}}>
|
token={token}
|
||||||
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--primary)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
placeholder="Wort hinzufügen…"
|
||||||
{w.title}
|
excludeTitles={selectedObjectId
|
||||||
</span>
|
? (objectWords[selectedObjectId] || []).map(w => w.titel_de)
|
||||||
<input
|
: pendingWords}
|
||||||
type="number"
|
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%' }}
|
||||||
min={1} max={100}
|
/>
|
||||||
value={w.level}
|
<button
|
||||||
onChange={e => setSafeWords(prev => prev.map((x, j) => j === i ? { ...x, level: Math.min(100, Math.max(1, Number(e.target.value))) } : x))}
|
onClick={() => selectedObjectId ? handleAddObjectWord(selectedObjectId) : handleAddPendingWord()}
|
||||||
style={{
|
style={{ padding:'4px 10px', borderRadius:'var(--r-sm)', background:'#6366f1', color:'#fff', border:'none', cursor:'pointer', fontSize:12 }}
|
||||||
width: 44, padding: '2px 4px', borderRadius: 'var(--r-sm)',
|
>+</button>
|
||||||
border: '1px solid color-mix(in srgb, var(--primary) 40%, transparent)',
|
</div>
|
||||||
background: 'var(--surface)', color: 'var(--primary)',
|
|
||||||
fontFamily: 'var(--font)', fontSize: 11, textAlign: 'center',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => setSafeWords(prev => prev.filter((_, j) => j !== i))}
|
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--primary)', padding: 0, lineHeight: 1, fontSize: 14, flexShrink: 0 }}
|
|
||||||
title="Entfernen"
|
|
||||||
>×</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pictureWords.length === 0 && safeWords.length === 0 && (
|
|
||||||
<div className="empty-state" style={{ fontSize: 12 }}>Noch keine Words</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sidebar-panel">
|
|
||||||
<button
|
|
||||||
className="btn-primary btn-sm btn-block"
|
|
||||||
onClick={saveSafeWords}
|
|
||||||
disabled={safeWords.length === 0 || savingWords || !currentPicture}
|
|
||||||
>
|
|
||||||
{savingWords ? 'Speichere…' : `Save${safeWords.length > 0 ? ` (${safeWords.length})` : ''}`}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
123
frontend/src/pages/Home.tsx
Normal file
123
frontend/src/pages/Home.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useTheme } from '../context/ThemeContext'
|
import { useTheme } from '../context/ThemeContext'
|
||||||
import { directusLogin } from '../api'
|
import { directusLogin, getDirectusMe } from '../api'
|
||||||
|
|
||||||
const CrosshairIcon = () => (
|
const CrosshairIcon = () => (
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<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)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const token = await directusLogin(email, password)
|
const token = await directusLogin(email, password)
|
||||||
login(token)
|
const me = await getDirectusMe(token)
|
||||||
navigate('/draw')
|
login(token, me)
|
||||||
|
navigate('/')
|
||||||
} catch {
|
} catch {
|
||||||
setError('Ungültige Zugangsdaten. Bitte erneut versuchen.')
|
setError('Ungültige Zugangsdaten. Bitte erneut versuchen.')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -54,6 +54,72 @@ export interface PictureWord {
|
|||||||
status: string
|
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
|
// Legacy — still used by GenerateIt
|
||||||
export interface ObjectMeta {
|
export interface ObjectMeta {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
Reference in New Issue
Block a user