Professionelles Redesign + Directus-Auth + Tag/Nacht-Modus
- Vollbild App-Shell mit Topbar, drei-Spalten-Workspace - Login-Seite mit Directus JWT-Authentifizierung (in-memory Token) - Tag/Nacht-Modus mit CSS Custom Properties (Systemfarbe als Default) - Directus 'pictures' Collection (status=new) als Bildquelle in DrawIt - Pfeil-Navigation durch Bilder mit Bildnummer-Anzeige - Neues Design-System: Indigo-Akzent, SVG-Icons, professionelle Typografie - ThemeProvider, AuthProvider, PrivateRoute, Topbar-Komponente Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,25 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import DrawIt from './pages/DrawIt'
|
import DrawIt from './pages/DrawIt'
|
||||||
import GenerateIt from './pages/GenerateIt'
|
import GenerateIt from './pages/GenerateIt'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import { useAuth } from './context/AuthContext'
|
||||||
|
import { ThemeProvider } from './context/ThemeContext'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
function PrivateRoute({ children }: { children: ReactNode }) {
|
||||||
|
const { token } = useAuth()
|
||||||
|
return token ? <>{children}</> : <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/" element={<Navigate to="/draw" replace />} />
|
<Route path="/" element={<Navigate to="/draw" replace />} />
|
||||||
<Route path="/draw" element={<DrawIt />} />
|
<Route path="/draw" element={<PrivateRoute><DrawIt /></PrivateRoute>} />
|
||||||
<Route path="/generate" element={<GenerateIt />} />
|
<Route path="/generate" element={<PrivateRoute><GenerateIt /></PrivateRoute>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,38 @@
|
|||||||
import type { ObjectMeta, Sentence } from './types'
|
import type { ObjectMeta, Sentence } from './types'
|
||||||
|
|
||||||
|
const DIRECTUS_URL = 'https://db.hejyou.com'
|
||||||
|
|
||||||
|
export async function directusLogin(email: string, password: string): Promise<string> {
|
||||||
|
const res = await fetch(`${DIRECTUS_URL}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Login fehlgeschlagen')
|
||||||
|
return data.data.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectusPicture {
|
||||||
|
id: string
|
||||||
|
media: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDirectusPictures(token: string): Promise<DirectusPicture[]> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${DIRECTUS_URL}/items/pictures?filter[status][_eq]=new&fields=id,media,status&sort=date_created`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
)
|
||||||
|
if (!res.ok) throw new Error('Fehler beim Laden der Directus-Bilder')
|
||||||
|
const data = await res.json()
|
||||||
|
return data.data as DirectusPicture[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function directusAssetUrl(mediaId: string, token: string): string {
|
||||||
|
return `${DIRECTUS_URL}/assets/${mediaId}?access_token=${token}`
|
||||||
|
}
|
||||||
|
|
||||||
export async function getImages(mode: 'draw' | 'generate'): Promise<string[]> {
|
export async function getImages(mode: 'draw' | 'generate'): Promise<string[]> {
|
||||||
const res = await fetch(`/api/images?mode=${mode}`)
|
const res = await fetch(`/api/images?mode=${mode}`)
|
||||||
if (!res.ok) throw new Error('Fehler beim Laden der Bilder')
|
if (!res.ok) throw new Error('Fehler beim Laden der Bilder')
|
||||||
|
|||||||
@@ -75,8 +75,7 @@ export default function ObjectsList({
|
|||||||
{objects.map(obj => (
|
{objects.map(obj => (
|
||||||
<div
|
<div
|
||||||
key={obj.id}
|
key={obj.id}
|
||||||
className="object-item"
|
className={`object-item${obj.id === selectedObjectId ? ' selected' : ''}`}
|
||||||
style={obj.id === selectedObjectId ? { borderColor: '#2563eb' } : undefined}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelect(obj.id)
|
onSelect(obj.id)
|
||||||
if (isGeneratePage) {
|
if (isGeneratePage) {
|
||||||
|
|||||||
@@ -17,14 +17,10 @@ export default function SentencesList({ sentences }: Props) {
|
|||||||
<div className="sentences-list">
|
<div className="sentences-list">
|
||||||
{[...sentences].reverse().map((s, i) => (
|
{[...sentences].reverse().map((s, i) => (
|
||||||
<div className="sentence-item" key={i}>
|
<div className="sentence-item" key={i}>
|
||||||
<div style={{ fontWeight: 600, color: '#1e40af', marginBottom: 4, fontSize: '0.85rem' }}>
|
<div className="sentence-level">Einfach</div>
|
||||||
Einfach:
|
|
||||||
</div>
|
|
||||||
<div className="sentence-item-question">{s.question_simple_en}</div>
|
<div className="sentence-item-question">{s.question_simple_en}</div>
|
||||||
<div className="sentence-item-answer">{s.answer_simple_en}</div>
|
<div className="sentence-item-answer">{s.answer_simple_en}</div>
|
||||||
<div style={{ fontWeight: 600, color: '#1e40af', marginTop: 8, marginBottom: 4, fontSize: '0.85rem' }}>
|
<div className="sentence-level" style={{ marginTop: 6 }}>Fortgeschritten</div>
|
||||||
Fortgeschritten:
|
|
||||||
</div>
|
|
||||||
<div className="sentence-item-question">{s.question_advanced_en}</div>
|
<div className="sentence-item-question">{s.question_advanced_en}</div>
|
||||||
<div className="sentence-item-answer">{s.answer_advanced_en}</div>
|
<div className="sentence-item-answer">{s.answer_advanced_en}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
85
frontend/src/components/Topbar.tsx
Normal file
85
frontend/src/components/Topbar.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useTheme } from '../context/ThemeContext'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
const SunIcon = () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const MoonIcon = () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const LogoutIcon = () => (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<polyline points="16,17 21,12 16,7" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const CrosshairIcon = () => (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="7" />
|
||||||
|
<line x1="12" y1="1" x2="12" y2="5" />
|
||||||
|
<line x1="12" y1="19" x2="12" y2="23" />
|
||||||
|
<line x1="1" y1="12" x2="5" y2="12" />
|
||||||
|
<line x1="19" y1="12" x2="23" y2="12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
interface TopbarProps {
|
||||||
|
page: 'draw' | 'generate'
|
||||||
|
center?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Topbar({ page, center }: TopbarProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { logout } = useAuth()
|
||||||
|
const { dark, toggle } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="topbar-brand">
|
||||||
|
<span className="topbar-logo-icon">
|
||||||
|
<CrosshairIcon />
|
||||||
|
</span>
|
||||||
|
<span className="topbar-brand-name">Content Mentor</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="topbar-nav">
|
||||||
|
<button
|
||||||
|
className={`topbar-tab${page === 'draw' ? ' active' : ''}`}
|
||||||
|
onClick={() => navigate('/draw')}
|
||||||
|
>
|
||||||
|
Annotieren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`topbar-tab${page === 'generate' ? ' active' : ''}`}
|
||||||
|
onClick={() => navigate('/generate')}
|
||||||
|
>
|
||||||
|
Generieren
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{center && <div className="topbar-center">{center}</div>}
|
||||||
|
|
||||||
|
<div className="topbar-actions">
|
||||||
|
<button className="btn-icon" onClick={toggle} title={dark ? 'Hellmodus' : 'Dunkelmodus'}>
|
||||||
|
{dark ? <SunIcon /> : <MoonIcon />}
|
||||||
|
</button>
|
||||||
|
<div className="topbar-divider" />
|
||||||
|
<button className="btn-ghost btn-sm" onClick={logout}>
|
||||||
|
<LogoutIcon />
|
||||||
|
<span>Logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
frontend/src/context/AuthContext.tsx
Normal file
25
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
token: string | null
|
||||||
|
login: (token: string) => void
|
||||||
|
logout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [token, setToken] = useState<string | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ token, login: setToken, logout: () => setToken(null) }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
26
frontend/src/context/ThemeContext.tsx
Normal file
26
frontend/src/context/ThemeContext.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
dark: boolean
|
||||||
|
toggle: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType>({ dark: false, toggle: () => {} })
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [dark, setDark] = useState(
|
||||||
|
() => window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
|
||||||
|
}, [dark])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ dark, toggle: () => setDark(d => !d) }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTheme = () => useContext(ThemeContext)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,15 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
import { AuthProvider } from './context/AuthContext'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,28 +1,43 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
|
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
|
||||||
import ObjectsList from '../components/ObjectsList'
|
import ObjectsList from '../components/ObjectsList'
|
||||||
import { getImages, getObjects, cropImage, saveImage } from '../api'
|
import Topbar from '../components/Topbar'
|
||||||
|
import { getObjects, cropImage, getDirectusPictures, directusAssetUrl, type DirectusPicture } from '../api'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
import type { ObjectMeta, Selection } from '../types'
|
import type { ObjectMeta, Selection } from '../types'
|
||||||
|
|
||||||
const FIELD_LABELS: Record<string, string> = {
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
title_de: 'Titel / title_de',
|
title_de: 'Titel',
|
||||||
position_de: 'Position / position_de',
|
position_de: 'Position',
|
||||||
action_de: 'Status (sitzt/schwimmt/segelt) / action_de',
|
action_de: 'Aktion',
|
||||||
condition_de: 'Zustand (alt/jung/rostig) / condition_de',
|
condition_de: 'Zustand',
|
||||||
}
|
}
|
||||||
|
|
||||||
const FIELD_PLACEHOLDERS: Record<string, string> = {
|
const FIELD_PLACEHOLDERS: Record<string, string> = {
|
||||||
|
title_de: 'z.B. Hund',
|
||||||
|
position_de: 'z.B. links oben',
|
||||||
action_de: 'z.B. sitzt',
|
action_de: 'z.B. sitzt',
|
||||||
condition_de: 'z.B. rostig',
|
condition_de: 'z.B. rostig',
|
||||||
}
|
}
|
||||||
|
|
||||||
type FormKey = 'title_de' | 'position_de' | 'action_de' | 'condition_de'
|
type FormKey = 'title_de' | 'position_de' | 'action_de' | 'condition_de'
|
||||||
|
|
||||||
export default function DrawIt() {
|
const ChevronLeftIcon = () => (
|
||||||
const navigate = useNavigate()
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
const [imageList, setImageList] = useState<string[]>([])
|
const ChevronRightIcon = () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function DrawIt() {
|
||||||
|
const { token } = useAuth()
|
||||||
|
|
||||||
|
const [pictureList, setPictureList] = useState<DirectusPicture[]>([])
|
||||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||||
const [objects, setObjects] = useState<ObjectMeta[]>([])
|
const [objects, setObjects] = useState<ObjectMeta[]>([])
|
||||||
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
|
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
|
||||||
@@ -39,32 +54,33 @@ export default function DrawIt() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const canvasRef = useRef<DrawCanvasHandle>(null)
|
const canvasRef = useRef<DrawCanvasHandle>(null)
|
||||||
const currentFilename = currentIndex >= 0 && currentIndex < imageList.length
|
const currentPicture = currentIndex >= 0 && currentIndex < pictureList.length
|
||||||
? imageList[currentIndex]
|
? pictureList[currentIndex]
|
||||||
: null
|
: null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getImages('draw')
|
if (!token) return
|
||||||
.then(imgs => {
|
getDirectusPictures(token)
|
||||||
setImageList(imgs)
|
.then(pics => {
|
||||||
setCurrentIndex(imgs.length - 1)
|
setPictureList(pics)
|
||||||
|
setCurrentIndex(pics.length > 0 ? 0 : -1)
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
}, [])
|
}, [token])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentFilename) {
|
if (!currentPicture) {
|
||||||
setObjects([])
|
setObjects([])
|
||||||
setSelectedObjectId(null)
|
setSelectedObjectId(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
getObjects(currentFilename)
|
getObjects(currentPicture.id)
|
||||||
.then(objs => {
|
.then(objs => {
|
||||||
setObjects(objs.map(o => ({ ...o, visible: true })))
|
setObjects(objs.map(o => ({ ...o, visible: true })))
|
||||||
setSelectedObjectId(objs[0]?.id ?? null)
|
setSelectedObjectId(objs[0]?.id ?? null)
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
}, [currentFilename])
|
}, [currentPicture?.id])
|
||||||
|
|
||||||
const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), [])
|
const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), [])
|
||||||
|
|
||||||
@@ -89,11 +105,11 @@ export default function DrawIt() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saveObject = async () => {
|
const saveObject = async () => {
|
||||||
if (!currentFilename || currentSelections.length === 0) return
|
if (!currentPicture || currentSelections.length === 0) return
|
||||||
try {
|
try {
|
||||||
showStatus('Speichere Objekt...')
|
showStatus('Speichere Objekt…')
|
||||||
const result = await cropImage({
|
const result = await cropImage({
|
||||||
filename: currentFilename,
|
filename: currentPicture.id,
|
||||||
selections: currentSelections.map((sel, idx) => ({
|
selections: currentSelections.map((sel, idx) => ({
|
||||||
number: idx + 1,
|
number: idx + 1,
|
||||||
mode: sel.mode,
|
mode: sel.mode,
|
||||||
@@ -105,60 +121,54 @@ export default function DrawIt() {
|
|||||||
showStatus(`Gespeichert – ID: ${result.id} (${currentSelections.length} Auswahl(en))`)
|
showStatus(`Gespeichert – ID: ${result.id} (${currentSelections.length} Auswahl(en))`)
|
||||||
setCurrentSelections([])
|
setCurrentSelections([])
|
||||||
setForm({ title_de: '', position_de: '', action_de: '', condition_de: '' })
|
setForm({ title_de: '', position_de: '', action_de: '', condition_de: '' })
|
||||||
const objs = await getObjects(currentFilename)
|
const objs = await getObjects(currentPicture.id)
|
||||||
setObjects(objs.map(o => ({ ...o, visible: true })))
|
setObjects(objs.map(o => ({ ...o, visible: true })))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern.', true)
|
showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern.', true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveImage = async () => {
|
const imageNav = (
|
||||||
if (!currentFilename) return
|
<div className="image-nav">
|
||||||
try {
|
|
||||||
showStatus('Bild wird gespeichert...')
|
|
||||||
await saveImage(currentFilename)
|
|
||||||
const imgs = await getImages('draw')
|
|
||||||
setImageList(imgs)
|
|
||||||
setCurrentIndex(imgs.length - 1)
|
|
||||||
showStatus('Bild gespeichert.')
|
|
||||||
} catch (e) {
|
|
||||||
showStatus(e instanceof Error ? e.message : 'Fehler.', true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container">
|
|
||||||
<h1>DrawIt</h1>
|
|
||||||
|
|
||||||
<div className="panel image-nav">
|
|
||||||
<div className="image-nav-left">
|
|
||||||
<button
|
<button
|
||||||
|
className="btn-icon"
|
||||||
onClick={() => setCurrentIndex(i => i - 1)}
|
onClick={() => setCurrentIndex(i => i - 1)}
|
||||||
disabled={currentIndex <= 0}
|
disabled={currentIndex <= 0}
|
||||||
|
title="Vorheriges Bild"
|
||||||
>
|
>
|
||||||
⬅️
|
<ChevronLeftIcon />
|
||||||
</button>
|
</button>
|
||||||
<span>Bild: <code>{currentFilename || '–'}</code></span>
|
<span className="image-counter">
|
||||||
|
{pictureList.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<span className="image-counter-num">{currentIndex + 1}</span>
|
||||||
|
<span className="image-counter-sep">/</span>
|
||||||
|
<span className="image-counter-total">{pictureList.length}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="image-counter-empty">Keine Bilder</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
|
className="btn-icon"
|
||||||
onClick={() => setCurrentIndex(i => i + 1)}
|
onClick={() => setCurrentIndex(i => i + 1)}
|
||||||
disabled={currentIndex >= imageList.length - 1}
|
disabled={currentIndex >= pictureList.length - 1}
|
||||||
|
title="Nächstes Bild"
|
||||||
>
|
>
|
||||||
➡️
|
<ChevronRightIcon />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleSaveImage} disabled={!currentFilename}>💾</button>
|
|
||||||
</div>
|
|
||||||
<div className="page-switch">
|
|
||||||
<select value="/draw" onChange={e => navigate(e.target.value)}>
|
|
||||||
<option value="/draw">DrawIt</option>
|
|
||||||
<option value="/generate">GenerateIt</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
<div className="main-layout">
|
return (
|
||||||
{/* Left: Objects */}
|
<div className="app-shell">
|
||||||
<div className="objects-pane sidebar-section">
|
<Topbar page="draw" center={imageNav} />
|
||||||
<h2>Objekte zu diesem Bild</h2>
|
|
||||||
|
<div className="workspace">
|
||||||
|
{/* Left sidebar: Objects */}
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-panel" style={{ flex: 1 }}>
|
||||||
|
<h3 className="sidebar-heading">Objekte</h3>
|
||||||
<ObjectsList
|
<ObjectsList
|
||||||
objects={objects}
|
objects={objects}
|
||||||
selectedObjectId={selectedObjectId}
|
selectedObjectId={selectedObjectId}
|
||||||
@@ -170,15 +180,16 @@ export default function DrawIt() {
|
|||||||
isGeneratePage={false}
|
isGeneratePage={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
{/* Center: Canvas */}
|
{/* Center: Canvas */}
|
||||||
<div className="left-pane">
|
<main className="canvas-area">
|
||||||
<div className="canvas-wrapper">
|
<div className="canvas-frame">
|
||||||
<DrawCanvas
|
<DrawCanvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
imageSrc={
|
imageSrc={
|
||||||
currentFilename
|
currentPicture && token
|
||||||
? `/pictures/${encodeURIComponent(currentFilename)}`
|
? directusAssetUrl(currentPicture.media, token)
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
objects={objects}
|
objects={objects}
|
||||||
@@ -187,17 +198,14 @@ export default function DrawIt() {
|
|||||||
onHasSelection={handleHasSelection}
|
onHasSelection={handleHasSelection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
{/* Right: Controls */}
|
{/* Right sidebar: Controls */}
|
||||||
<div className="right-pane">
|
<aside className="sidebar sidebar--right">
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-panel">
|
||||||
<h2>Auswahl</h2>
|
<h3 className="sidebar-heading">Auswahl-Modus</h3>
|
||||||
<div className="sidebar-row">
|
<div className="mode-group">
|
||||||
<span>Auswahl-Typ (Interface / Backend):</span>
|
<label className={`mode-btn${mode === 'rect' ? ' active' : ''}`}>
|
||||||
</div>
|
|
||||||
<div className="sidebar-row">
|
|
||||||
<label className="mode-option">
|
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="mode"
|
name="mode"
|
||||||
@@ -205,11 +213,9 @@ export default function DrawIt() {
|
|||||||
checked={mode === 'rect'}
|
checked={mode === 'rect'}
|
||||||
onChange={() => setMode('rect')}
|
onChange={() => setMode('rect')}
|
||||||
/>
|
/>
|
||||||
Rechteck / <code>BBox</code>
|
<span>Rechteck</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
<label className={`mode-btn${mode === 'polygon' ? ' active' : ''}`}>
|
||||||
<div className="sidebar-row">
|
|
||||||
<label className="mode-option">
|
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="mode"
|
name="mode"
|
||||||
@@ -217,23 +223,27 @@ export default function DrawIt() {
|
|||||||
checked={mode === 'polygon'}
|
checked={mode === 'polygon'}
|
||||||
onChange={() => setMode('polygon')}
|
onChange={() => setMode('polygon')}
|
||||||
/>
|
/>
|
||||||
Polygon / <code>polygon</code>
|
<span>Polygon</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-row">
|
<div className="action-group">
|
||||||
<button type="button" onClick={() => canvasRef.current?.resetSelection()}>
|
<button
|
||||||
|
className="btn-ghost btn-sm btn-block"
|
||||||
|
onClick={() => canvasRef.current?.resetSelection()}
|
||||||
|
>
|
||||||
Auswahl zurücksetzen
|
Auswahl zurücksetzen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-panel">
|
||||||
<h2>Metadaten</h2>
|
<h3 className="sidebar-heading">Metadaten</h3>
|
||||||
{(['title_de', 'position_de', 'action_de', 'condition_de'] as FormKey[]).map(key => (
|
{(['title_de', 'position_de', 'action_de', 'condition_de'] as FormKey[]).map(key => (
|
||||||
<div className="sidebar-row" key={key}>
|
<div className="field" key={key}>
|
||||||
<label htmlFor={key}>{FIELD_LABELS[key]}</label>
|
<label className="field-label" htmlFor={key}>{FIELD_LABELS[key]}</label>
|
||||||
<input
|
<input
|
||||||
id={key}
|
id={key}
|
||||||
|
className="field-input"
|
||||||
type="text"
|
type="text"
|
||||||
value={form[key]}
|
value={form[key]}
|
||||||
placeholder={FIELD_PLACEHOLDERS[key] || ''}
|
placeholder={FIELD_PLACEHOLDERS[key] || ''}
|
||||||
@@ -243,55 +253,66 @@ export default function DrawIt() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-panel">
|
||||||
<h2>Auswahlen</h2>
|
<h3 className="sidebar-heading">
|
||||||
|
Auswahlen
|
||||||
|
{currentSelections.length > 0 && (
|
||||||
|
<span className="badge">{currentSelections.length}</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
<div className="selections-list">
|
<div className="selections-list">
|
||||||
{currentSelections.length === 0 ? (
|
{currentSelections.length === 0 ? (
|
||||||
<div className="selections-empty">Noch keine Auswahlen hinzugefügt.</div>
|
<div className="empty-state">Noch keine Auswahlen</div>
|
||||||
) : (
|
) : (
|
||||||
currentSelections.map((sel, i) => (
|
currentSelections.map((sel, i) => (
|
||||||
<div className="selection-item" key={i}>
|
<div className="selection-chip" key={i}>
|
||||||
<strong>Auswahl {i + 1}</strong> ({sel.mode === 'rect' ? 'Rechteck' : 'Polygon'}):
|
<span className="selection-chip-num">{i + 1}</span>
|
||||||
|
<span className="selection-chip-type">
|
||||||
|
{sel.mode === 'rect' ? 'Rect' : 'Poly'}
|
||||||
|
</span>
|
||||||
|
<span className="selection-chip-info">
|
||||||
{sel.mode === 'rect' && sel.bbox
|
{sel.mode === 'rect' && sel.bbox
|
||||||
? ` x=${sel.bbox.x}, y=${sel.bbox.y}, w=${sel.bbox.width}, h=${sel.bbox.height}`
|
? `${Math.round(sel.bbox.width)}×${Math.round(sel.bbox.height)}`
|
||||||
: ` ${sel.polygon?.length ?? 0} Punkte`}
|
: `${sel.polygon?.length ?? 0} Pkt.`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-row">
|
|
||||||
|
<div className="action-group">
|
||||||
<button
|
<button
|
||||||
|
className="btn-primary btn-sm btn-block"
|
||||||
onClick={addSelection}
|
onClick={addSelection}
|
||||||
disabled={!hasSelection || !currentFilename}
|
disabled={!hasSelection || !currentPicture}
|
||||||
>
|
>
|
||||||
➕ Auswahl hinzufügen
|
+ Auswahl hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
<div className="sidebar-row">
|
|
||||||
<button
|
<button
|
||||||
|
className="btn-primary btn-sm btn-block"
|
||||||
onClick={saveObject}
|
onClick={saveObject}
|
||||||
disabled={!currentFilename || currentSelections.length === 0}
|
disabled={!currentPicture || currentSelections.length === 0}
|
||||||
>
|
>
|
||||||
💾 Objekt speichern
|
Objekt speichern
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
<div className="sidebar-row">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
className="btn-ghost btn-sm btn-block btn-danger"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentSelections([])
|
setCurrentSelections([])
|
||||||
canvasRef.current?.resetSelection()
|
canvasRef.current?.resetSelection()
|
||||||
showStatus('Alle Auswahlen gelöscht.')
|
showStatus('Alle Auswahlen gelöscht.')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
🗑️ Alle Auswahlen löschen
|
Alle löschen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status && (
|
{status && (
|
||||||
<span className={`status ${statusError ? 'error' : 'ok'}`}>{status}</span>
|
<div className={`status-msg ${statusError ? 'error' : 'ok'}`}>{status}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,36 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import ObjectsList from '../components/ObjectsList'
|
import ObjectsList from '../components/ObjectsList'
|
||||||
import DetailsPanel from '../components/DetailsPanel'
|
import DetailsPanel from '../components/DetailsPanel'
|
||||||
import SentencesList from '../components/SentencesList'
|
import SentencesList from '../components/SentencesList'
|
||||||
|
import Topbar from '../components/Topbar'
|
||||||
import { getImages, getObjects, generateDetails, generateSentence, getSentences } from '../api'
|
import { getImages, getObjects, generateDetails, generateSentence, getSentences } from '../api'
|
||||||
import type { ObjectMeta, Sentence } from '../types'
|
import type { ObjectMeta, Sentence } from '../types'
|
||||||
|
|
||||||
export default function GenerateIt() {
|
const ChevronLeftIcon = () => (
|
||||||
const navigate = useNavigate()
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ChevronRightIcon = () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SparkleIcon = () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ChatIcon = () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function GenerateIt() {
|
||||||
const [imageList, setImageList] = useState<string[]>([])
|
const [imageList, setImageList] = useState<string[]>([])
|
||||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||||
const [objects, setObjects] = useState<ObjectMeta[]>([])
|
const [objects, setObjects] = useState<ObjectMeta[]>([])
|
||||||
@@ -52,8 +74,7 @@ export default function GenerateIt() {
|
|||||||
|
|
||||||
const loadSentences = async (objId: string) => {
|
const loadSentences = async (objId: string) => {
|
||||||
try {
|
try {
|
||||||
const s = await getSentences(objId)
|
setSentences(await getSentences(objId))
|
||||||
setSentences(s)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
@@ -90,49 +111,66 @@ export default function GenerateIt() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const imageNav = (
|
||||||
<div className="container">
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<h1>GenerateIt</h1>
|
<div className="image-nav">
|
||||||
|
|
||||||
<div className="panel image-nav">
|
|
||||||
<div className="image-nav-left">
|
|
||||||
<button
|
<button
|
||||||
|
className="btn-icon"
|
||||||
onClick={() => setCurrentIndex(i => i - 1)}
|
onClick={() => setCurrentIndex(i => i - 1)}
|
||||||
disabled={currentIndex <= 0}
|
disabled={currentIndex <= 0}
|
||||||
>
|
>
|
||||||
⬅️
|
<ChevronLeftIcon />
|
||||||
</button>
|
</button>
|
||||||
<span>Bild: <code>{currentFilename || '–'}</code></span>
|
<span className="image-counter">
|
||||||
|
{imageList.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<span className="image-counter-num">{currentIndex + 1}</span>
|
||||||
|
<span className="image-counter-sep">/</span>
|
||||||
|
<span className="image-counter-total">{imageList.length}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="image-counter-empty">Keine Bilder</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
|
className="btn-icon"
|
||||||
onClick={() => setCurrentIndex(i => i + 1)}
|
onClick={() => setCurrentIndex(i => i + 1)}
|
||||||
disabled={currentIndex >= imageList.length - 1}
|
disabled={currentIndex >= imageList.length - 1}
|
||||||
>
|
>
|
||||||
➡️
|
<ChevronRightIcon />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ width: 1, height: 24, background: 'var(--border)' }} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
className="btn-ghost btn-sm"
|
||||||
onClick={handleGenerateDetails}
|
onClick={handleGenerateDetails}
|
||||||
disabled={isGeneratingDetails || !selectedObj}
|
disabled={isGeneratingDetails || !selectedObj}
|
||||||
>
|
>
|
||||||
{isGeneratingDetails ? '⏳ KI-Details...' : '✨ KI-Details'}
|
<SparkleIcon />
|
||||||
|
{isGeneratingDetails ? 'Generiere…' : 'KI-Details'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
className="btn-ghost btn-sm"
|
||||||
onClick={handleGenerateSentence}
|
onClick={handleGenerateSentence}
|
||||||
disabled={isGeneratingSentence || !selectedObj}
|
disabled={isGeneratingSentence || !selectedObj}
|
||||||
>
|
>
|
||||||
{isGeneratingSentence ? '⏳ KI-Sentence...' : '💬 KI-Sentence'}
|
<ChatIcon />
|
||||||
|
{isGeneratingSentence ? 'Generiere…' : 'KI-Sentence'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-switch">
|
)
|
||||||
<select value="/generate" onChange={e => navigate(e.target.value)}>
|
|
||||||
<option value="/draw">DrawIt</option>
|
|
||||||
<option value="/generate">GenerateIt</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="main-layout">
|
return (
|
||||||
<div className="objects-pane sidebar-section">
|
<div className="app-shell">
|
||||||
<h2>Objekte zu diesem Bild</h2>
|
<Topbar page="generate" center={imageNav} />
|
||||||
|
|
||||||
|
<div className="workspace">
|
||||||
|
{/* Left: Objects */}
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-panel" style={{ flex: 1 }}>
|
||||||
|
<h3 className="sidebar-heading">Objekte</h3>
|
||||||
<ObjectsList
|
<ObjectsList
|
||||||
objects={objects}
|
objects={objects}
|
||||||
selectedObjectId={selectedObj?.id ?? null}
|
selectedObjectId={selectedObj?.id ?? null}
|
||||||
@@ -149,17 +187,27 @@ export default function GenerateIt() {
|
|||||||
onLoadSentences={loadSentences}
|
onLoadSentences={loadSentences}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<div className="right-pane">
|
{/* Center: Details */}
|
||||||
|
<main className="canvas-area" style={{ alignItems: 'flex-start', justifyContent: 'flex-start' }}>
|
||||||
|
<div style={{ width: '100%', maxWidth: 520, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<div className="sidebar-panel" style={{ background: 'var(--surface)', borderRadius: 'var(--r-lg)', border: '1px solid var(--border)' }}>
|
||||||
<DetailsPanel obj={selectedObj} objects={objects} sentences={sentences} />
|
<DetailsPanel obj={selectedObj} objects={objects} sentences={sentences} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
<div className="sentences-pane">
|
{/* Right: Sentences */}
|
||||||
<div className="sidebar-section">
|
<aside className="sidebar sidebar--right">
|
||||||
<h2>Alle Sätze</h2>
|
<div className="sidebar-panel" style={{ flex: 1 }}>
|
||||||
|
<h3 className="sidebar-heading">
|
||||||
|
Alle Sätze
|
||||||
|
{sentences.length > 0 && <span className="badge">{sentences.length}</span>}
|
||||||
|
</h3>
|
||||||
<SentencesList sentences={sentences} />
|
<SentencesList sentences={sentences} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
113
frontend/src/pages/Login.tsx
Normal file
113
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useTheme } from '../context/ThemeContext'
|
||||||
|
import { directusLogin } from '../api'
|
||||||
|
|
||||||
|
const CrosshairIcon = () => (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="7" />
|
||||||
|
<line x1="12" y1="1" x2="12" y2="5" />
|
||||||
|
<line x1="12" y1="19" x2="12" y2="23" />
|
||||||
|
<line x1="1" y1="12" x2="5" y2="12" />
|
||||||
|
<line x1="19" y1="12" x2="23" y2="12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SunIcon = () => (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const MoonIcon = () => (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login } = useAuth()
|
||||||
|
const { dark, toggle } = useTheme()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const token = await directusLogin(email, password)
|
||||||
|
login(token)
|
||||||
|
navigate('/draw')
|
||||||
|
} catch {
|
||||||
|
setError('Ungültige Zugangsdaten. Bitte erneut versuchen.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={toggle}
|
||||||
|
title={dark ? 'Hellmodus' : 'Dunkelmodus'}
|
||||||
|
style={{ position: 'absolute', top: 16, right: 16 }}
|
||||||
|
>
|
||||||
|
{dark ? <SunIcon /> : <MoonIcon />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="auth-card">
|
||||||
|
<div className="auth-card-header">
|
||||||
|
<div className="auth-logo">
|
||||||
|
<CrosshairIcon />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="auth-title">Content Mentor</div>
|
||||||
|
<div className="auth-subtitle">Bitte melde dich an, um fortzufahren</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="auth-card-body" onSubmit={handleSubmit}>
|
||||||
|
<div className="auth-field">
|
||||||
|
<label htmlFor="email">E-Mail-Adresse</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
placeholder="deine@email.com"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="auth-field">
|
||||||
|
<label htmlFor="password">Passwort</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="auth-error">{error}</div>}
|
||||||
|
|
||||||
|
<button type="submit" className="auth-submit" disabled={loading}>
|
||||||
|
{loading ? 'Wird angemeldet…' : 'Anmelden'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user