diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3f89849..0486b88 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,13 +1,25 @@ import { Routes, Route, Navigate } from 'react-router-dom' import DrawIt from './pages/DrawIt' 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} : +} export default function App() { return ( - - } /> - } /> - } /> - + + + } /> + } /> + } /> + } /> + + ) } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5b3c5d4..01d9d7b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,5 +1,38 @@ import type { ObjectMeta, Sentence } from './types' +const DIRECTUS_URL = 'https://db.hejyou.com' + +export async function directusLogin(email: string, password: string): Promise { + 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 { + 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 { const res = await fetch(`/api/images?mode=${mode}`) if (!res.ok) throw new Error('Fehler beim Laden der Bilder') diff --git a/frontend/src/components/ObjectsList.tsx b/frontend/src/components/ObjectsList.tsx index b2b6ed4..70fada0 100644 --- a/frontend/src/components/ObjectsList.tsx +++ b/frontend/src/components/ObjectsList.tsx @@ -75,8 +75,7 @@ export default function ObjectsList({ {objects.map(obj => (
{ onSelect(obj.id) if (isGeneratePage) { diff --git a/frontend/src/components/SentencesList.tsx b/frontend/src/components/SentencesList.tsx index addb7f6..2ef3d9c 100644 --- a/frontend/src/components/SentencesList.tsx +++ b/frontend/src/components/SentencesList.tsx @@ -17,14 +17,10 @@ export default function SentencesList({ sentences }: Props) {
{[...sentences].reverse().map((s, i) => (
-
- Einfach: -
+
Einfach
{s.question_simple_en}
{s.answer_simple_en}
-
- Fortgeschritten: -
+
Fortgeschritten
{s.question_advanced_en}
{s.answer_advanced_en}
diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx new file mode 100644 index 0000000..61c9ed3 --- /dev/null +++ b/frontend/src/components/Topbar.tsx @@ -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 = () => ( + + + + +) + +const MoonIcon = () => ( + + + +) + +const LogoutIcon = () => ( + + + + + +) + +const CrosshairIcon = () => ( + + + + + + + +) + +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 ( +
+
+ + + + Content Mentor +
+ + + + {center &&
{center}
} + +
+ +
+ +
+
+ ) +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..8b27d70 --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -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(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [token, setToken] = useState(null) + + return ( + setToken(null) }}> + {children} + + ) +} + +export function useAuth() { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used within AuthProvider') + return ctx +} diff --git a/frontend/src/context/ThemeContext.tsx b/frontend/src/context/ThemeContext.tsx new file mode 100644 index 0000000..5a168ed --- /dev/null +++ b/frontend/src/context/ThemeContext.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext, useState, useEffect, type ReactNode } from 'react' + +interface ThemeContextType { + dark: boolean + toggle: () => void +} + +const ThemeContext = createContext({ 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 ( + setDark(d => !d) }}> + {children} + + ) +} + +export const useTheme = () => useContext(ThemeContext) diff --git a/frontend/src/index.css b/frontend/src/index.css index b3a204c..060ee04 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,112 +1,336 @@ -body { - font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +/* ===================================================== + DESIGN TOKENS + ===================================================== */ +:root { + --font: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; + + --bg: #F2F4F9; + --canvas-bg: #E6E9F2; + --surface: #FFFFFF; + --surface-2: #F6F8FC; + --surface-3: #EDF0F7; + --border: #DDE1EE; + --border-focus: #6366F1; + + --primary: #5C6CF6; + --primary-hover: #4A5AE5; + --primary-active: #3A4AD5; + --primary-muted: #EEF0FE; + --primary-muted-fg: #4A5AE5; + + --text-1: #0D1526; + --text-2: #4B5568; + --text-3: #8C95A8; + --text-inv: #FFFFFF; + + --success: #059669; + --success-bg: #ECFDF5; + --danger: #DC2626; + --danger-bg: #FEF2F2; + --warning: #D97706; + + --shadow-xs: 0 1px 2px rgba(13,21,38,.05); + --shadow-sm: 0 2px 6px rgba(13,21,38,.07); + --shadow-md: 0 6px 20px rgba(13,21,38,.09); + --shadow-lg: 0 16px 40px rgba(13,21,38,.12); + + --r-sm: 5px; + --r-md: 8px; + --r-lg: 12px; + --r-xl: 16px; + --r-full: 999px; + + --topbar-h: 56px; + --sidebar-w: 252px; + --sidebar-rw: 276px; + --panel-gap: 1px; +} + +[data-theme="dark"] { + --bg: #0C0F1A; + --canvas-bg: #080B14; + --surface: #141828; + --surface-2: #1A1F32; + --surface-3: #1F253A; + --border: #272D45; + + --primary: #7B8BFF; + --primary-hover: #8D9BFF; + --primary-active: #6A7AEE; + --primary-muted: #1C2348; + --primary-muted-fg: #A5B4FC; + + --text-1: #EDF0FA; + --text-2: #8892B0; + --text-3: #4A5270; + --text-inv: #0D1526; + + --success: #34D399; + --success-bg: #052E1E; + --danger: #F87171; + --danger-bg: #3B0E0E; + --warning: #FBBF24; + + --shadow-xs: 0 1px 2px rgba(0,0,0,.25); + --shadow-sm: 0 2px 6px rgba(0,0,0,.30); + --shadow-md: 0 6px 20px rgba(0,0,0,.40); + --shadow-lg: 0 16px 40px rgba(0,0,0,.55); +} + +/* ===================================================== + RESET + ===================================================== */ +*, *::before, *::after { + box-sizing: border-box; margin: 0; padding: 0; - background: #f5f7fb; - color: #222; } -.container { - max-width: 1180px; - margin: 24px auto; - padding: 16px 20px 32px; - background: #ffffff; - border-radius: 12px; - box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08); +html, body, #root { + height: 100%; } -h1 { - margin-top: 0; - margin-bottom: 16px; - font-size: 1.6rem; +body { + font-family: var(--font); + font-size: 13.5px; + line-height: 1.5; + background: var(--bg); + color: var(--text-1); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow: hidden; } -label { - font-weight: 500; -} - -.panel { - margin: 12px 0; - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.image-nav { - justify-content: space-between; - gap: 8px; -} - -.image-nav button { - padding: 4px 10px; - border-radius: 999px; -} - -.image-nav-left { - display: flex; - align-items: center; - gap: 8px; -} - -.page-switch select { - padding: 4px 8px; - border-radius: 999px; - border: 1px solid #cbd5f0; - background: #f9fbff; - font-size: 0.9rem; - min-width: unset; -} - -.main-layout { - display: flex; - gap: 16px; - align-items: flex-start; -} - -.objects-pane { - flex: 0 0 260px; -} - -.left-pane { - flex: 1 1 auto; -} - -.right-pane { - flex: 0 0 280px; +/* ===================================================== + APP SHELL + ===================================================== */ +.app-shell { display: flex; flex-direction: column; - gap: 12px; + height: 100vh; + overflow: hidden; } -.sentences-pane { - flex: 0 0 300px; +/* ===================================================== + TOPBAR + ===================================================== */ +.topbar { + height: var(--topbar-h); + flex-shrink: 0; + background: var(--surface); + border-bottom: 1px solid var(--border); display: flex; - flex-direction: column; - gap: 12px; -} - -.mode-option { - display: inline-flex; align-items: center; + padding: 0 16px; gap: 4px; - font-weight: 400; + z-index: 50; } -select { - min-width: 220px; - padding: 6px 10px; - border-radius: 8px; - border: 1px solid #cbd5f0; - background: #f9fbff; +.topbar-brand { + display: flex; + align-items: center; + gap: 8px; + margin-right: 8px; + user-select: none; } -.canvas-wrapper { - border-radius: 12px; - border: 1px solid #d4ddf5; - background: #f1f5ff; - overflow: visible; - padding: 8px; +.topbar-logo-icon { + display: flex; + align-items: center; + color: var(--primary); +} + +.topbar-brand-name { + font-size: 14px; + font-weight: 650; + letter-spacing: -0.01em; + color: var(--text-1); +} + +.topbar-nav { + display: flex; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 3px; + gap: 2px; + margin-right: 8px; +} + +.topbar-tab { + padding: 5px 14px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--text-2); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + box-shadow: none; +} + +.topbar-tab:hover:not(.active) { + background: var(--surface-3); + color: var(--text-1); + transform: none; + box-shadow: none; +} + +.topbar-tab.active { + background: var(--surface); + color: var(--text-1); + font-weight: 600; + box-shadow: var(--shadow-xs); + transform: none; +} + +.topbar-center { + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; +} + +.topbar-divider { + width: 1px; + height: 20px; + background: var(--border); + margin: 0 2px; +} + +/* ===================================================== + IMAGE NAVIGATION (in topbar) + ===================================================== */ +.image-nav { + display: flex; + align-items: center; + gap: 10px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 5px 10px; +} + +.image-counter { + display: flex; + align-items: baseline; + gap: 4px; + min-width: 80px; + justify-content: center; + font-variant-numeric: tabular-nums; +} + +.image-counter-num { + font-size: 15px; + font-weight: 700; + color: var(--text-1); +} + +.image-counter-sep { + font-size: 13px; + color: var(--text-3); +} + +.image-counter-total { + font-size: 13px; + font-weight: 500; + color: var(--text-2); +} + +.image-counter-empty { + font-size: 13px; + color: var(--text-3); + font-style: italic; +} + +/* ===================================================== + WORKSPACE + ===================================================== */ +.workspace { + display: flex; + flex: 1; + overflow: hidden; + min-height: 0; +} + +/* ===================================================== + SIDEBAR + ===================================================== */ +.sidebar { + width: var(--sidebar-w); + flex-shrink: 0; + background: var(--surface); + border-right: 1px solid var(--border); + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.sidebar--right { + width: var(--sidebar-rw); + border-right: none; + border-left: 1px solid var(--border); +} + +.sidebar::-webkit-scrollbar { width: 4px; } +.sidebar::-webkit-scrollbar-track { background: transparent; } +.sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } + +.sidebar-panel { + padding: 14px 14px; + border-bottom: 1px solid var(--border); +} + +.sidebar-panel:last-child { + border-bottom: none; + flex: 1; +} + +.sidebar-heading { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-3); + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 6px; +} + +/* ===================================================== + CANVAS AREA + ===================================================== */ +.canvas-area { + flex: 1; + background: var(--canvas-bg); + overflow: auto; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 28px; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.canvas-frame { + background: var(--surface); + border-radius: var(--r-lg); + box-shadow: var(--shadow-lg); + overflow: hidden; + display: inline-flex; + max-width: 100%; } canvas { @@ -115,84 +339,451 @@ canvas { height: auto; } +/* ===================================================== + BUTTONS + ===================================================== */ button { - padding: 8px 14px; - border-radius: 999px; - border: none; - background: #2563eb; - color: white; - font-weight: 500; + font-family: var(--font); + font-size: 13px; cursor: pointer; - box-shadow: 0 4px 10px rgba(37, 99, 235, 0.4); - transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + white-space: nowrap; + border: none; + border-radius: var(--r-md); } -button:disabled { - background: #9ca3af; - cursor: not-allowed; - box-shadow: none; +/* Primary */ +.btn-primary, +button:not([class]) { + background: var(--primary); + color: var(--text-inv); + font-weight: 600; + padding: 7px 14px; + box-shadow: 0 1px 3px rgba(92,108,246,.35); } -button:not(:disabled):hover { - background: #1d4ed8; - box-shadow: 0 6px 18px rgba(37, 99, 235, 0.5); +.btn-primary:hover:not(:disabled), +button:not([class]):hover:not(:disabled) { + background: var(--primary-hover); + box-shadow: 0 3px 10px rgba(92,108,246,.45); transform: translateY(-1px); } -.status { - font-size: 0.9rem; +.btn-primary:active:not(:disabled), +button:not([class]):active:not(:disabled) { + background: var(--primary-active); + transform: translateY(0); + box-shadow: none; } -.status.ok { - color: #16a34a; +/* Ghost */ +.btn-ghost { + background: transparent; + color: var(--text-2); + border: 1px solid var(--border); + font-weight: 500; + padding: 7px 12px; } -.status.error { - color: #dc2626; +.btn-ghost:hover:not(:disabled) { + background: var(--surface-2); + color: var(--text-1); + border-color: var(--text-3); + transform: none; + box-shadow: none; } -.sidebar-section { - border-radius: 10px; - border: 1px solid #e5e7eb; - padding: 10px 12px; - background: #f9fafb; +/* Danger ghost */ +.btn-danger { + color: var(--danger); + border-color: transparent; +} +.btn-danger:hover:not(:disabled) { + background: var(--danger-bg); + border-color: var(--danger); + color: var(--danger); } -.sidebar-section h2 { - font-size: 1rem; - margin: 0 0 6px; +/* Icon button */ +.btn-icon { + background: transparent; + color: var(--text-2); + width: 32px; + height: 32px; + border-radius: var(--r-md); + padding: 0; + flex-shrink: 0; } -.sidebar-row { - display: flex; - flex-direction: column; - gap: 4px; +.btn-icon:hover:not(:disabled) { + background: var(--surface-2); + color: var(--text-1); + transform: none; + box-shadow: none; +} + +.btn-icon:disabled { + opacity: 0.3; + cursor: not-allowed; + background: transparent; +} + +/* Sizes */ +.btn-sm { + font-size: 12.5px; + padding: 5px 11px; +} + +.btn-block { + width: 100%; margin-top: 6px; } -.sidebar-row input[type="text"] { - padding: 6px 8px; - border-radius: 6px; - border: 1px solid #d1d5db; - font-size: 0.9rem; +/* Disabled state */ +button:disabled { + opacity: 0.45; + cursor: not-allowed; + box-shadow: none; + transform: none; } -.detail-value { - padding: 4px 6px; - border-radius: 6px; - border: 1px solid #e5e7eb; - background: #f9fafb; - font-size: 0.9rem; - min-height: 24px; +/* ===================================================== + BADGE + ===================================================== */ +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: var(--r-full); + background: var(--primary-muted); + color: var(--primary-muted-fg); + font-size: 11px; + font-weight: 700; + line-height: 1; } +/* ===================================================== + FORM ELEMENTS + ===================================================== */ +.field { + margin-bottom: 8px; +} + +.field:last-of-type { + margin-bottom: 0; +} + +.field-label { + display: block; + font-size: 11.5px; + font-weight: 600; + color: var(--text-2); + margin-bottom: 4px; + letter-spacing: 0.01em; +} + +.field-input, +input[type="text"], +input[type="email"], +input[type="password"] { + width: 100%; + padding: 7px 10px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-md); + color: var(--text-1); + font-family: var(--font); + font-size: 13px; + transition: border-color 0.15s, box-shadow 0.15s; + outline: none; +} + +.field-input:focus, +input[type="text"]:focus, +input[type="email"]:focus, +input[type="password"]:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(92,108,246,.12); + background: var(--surface); +} + +select { + width: 100%; + padding: 7px 10px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-md); + color: var(--text-1); + font-family: var(--font); + font-size: 13px; + cursor: pointer; + outline: none; + transition: border-color 0.15s; + min-width: unset; +} + +select:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(92,108,246,.12); +} + +/* ===================================================== + MODE SELECTOR (Rect / Polygon toggle) + ===================================================== */ +.mode-group { + display: flex; + gap: 4px; +} + +.mode-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 6px 10px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-md); + color: var(--text-2); + font-size: 12.5px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + user-select: none; +} + +.mode-btn input[type="radio"] { + display: none; +} + +.mode-btn:hover { + background: var(--surface-3); + color: var(--text-1); +} + +.mode-btn.active { + background: var(--primary-muted); + border-color: var(--primary); + color: var(--primary-muted-fg); + font-weight: 600; +} + +/* ===================================================== + ACTION GROUP + ===================================================== */ +.action-group { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 10px; +} + +/* ===================================================== + SELECTIONS LIST + ===================================================== */ +.selections-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 140px; + overflow-y: auto; + margin-bottom: 2px; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.selection-chip { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-sm); + font-size: 12px; +} + +.selection-chip-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--primary-muted); + color: var(--primary-muted-fg); + font-size: 10px; + font-weight: 700; + flex-shrink: 0; +} + +.selection-chip-type { + font-weight: 600; + color: var(--text-1); +} + +.selection-chip-info { + color: var(--text-3); + font-variant-numeric: tabular-nums; + margin-left: auto; +} + +/* ===================================================== + OBJECTS LIST + ===================================================== */ .objects-list { display: flex; flex-direction: column; - gap: 8px; - max-height: 260px; + gap: 4px; + max-height: calc(100vh - 240px); overflow-y: auto; - padding-right: 4px; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.object-item { + display: flex; + flex-direction: column; + border-radius: var(--r-md); + border: 1px solid var(--border); + background: var(--surface-2); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + overflow: hidden; +} + +.object-item:hover { + background: var(--surface-3); + border-color: var(--text-3); +} + +.object-item.selected { + border-color: var(--primary); + background: var(--primary-muted); +} + +.object-item-header { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 8px; + flex-wrap: nowrap; +} + +.object-item-header input[type="checkbox"] { + width: 14px; + height: 14px; + flex-shrink: 0; + accent-color: var(--primary); + cursor: pointer; +} + +.object-item img { + width: 36px; + height: 36px; + object-fit: cover; + border-radius: var(--r-sm); + border: 1px solid var(--border); + flex-shrink: 0; +} + +.object-hierarchy-select { + width: 42px !important; + min-width: 0 !important; + padding: 3px 4px !important; + font-size: 11px !important; + border-radius: var(--r-sm) !important; + flex-shrink: 0; +} + +.object-parent-select { + width: 48px !important; + min-width: 0 !important; + padding: 3px 4px !important; + font-size: 11px !important; + border-radius: var(--r-sm) !important; + background: var(--primary-muted) !important; + border-color: var(--primary) !important; + color: var(--primary-muted-fg) !important; + flex-shrink: 0; +} + +.object-item-text { + display: flex; + flex-direction: column; + font-size: 12px; + flex: 1; + min-width: 0; + gap: 1px; +} + +.object-item-text strong { + font-size: 12.5px; + font-weight: 600; + color: var(--text-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.object-item-text span { + color: var(--text-3); + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.object-icon-button { + padding: 4px 6px !important; + font-size: 12px !important; + background: transparent !important; + color: var(--text-3) !important; + border: none !important; + box-shadow: none !important; + border-radius: var(--r-sm) !important; + flex-shrink: 0; +} + +.object-icon-button:hover:not(:disabled) { + background: var(--surface-3) !important; + color: var(--text-1) !important; + transform: none !important; + box-shadow: none !important; +} + +.object-item-details { + display: none; + flex-direction: column; + gap: 6px; + padding: 8px 10px; + border-top: 1px solid var(--border); + background: var(--surface); +} + +.object-item-details.visible { + display: flex; +} + +.object-item-details label { + font-size: 11px; + font-weight: 600; + color: var(--text-3); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.object-item-details input[type="text"] { + padding: 5px 8px; + font-size: 12px; + border-radius: var(--r-sm); } .objects-tags { @@ -203,180 +794,268 @@ button:not(:disabled):hover { } .object-tag { - padding: 2px 6px; - border-radius: 999px; - background: #e5e7eb; - font-size: 0.75rem; + padding: 2px 8px; + border-radius: var(--r-full); + background: var(--surface-3); + border: 1px solid var(--border); + font-size: 11px; + color: var(--text-2); white-space: nowrap; } -.object-item { +/* ===================================================== + DETAILS PANEL + ===================================================== */ +.sidebar-section { + margin-bottom: 0; +} + +.sidebar-section h2 { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-3); + margin-bottom: 8px; +} + +.sidebar-section h3 { + font-size: 11px; + font-weight: 600; + color: var(--text-2); + margin-top: 10px; + margin-bottom: 6px; + letter-spacing: 0.03em; +} + +.sidebar-row { display: flex; flex-direction: column; - gap: 4px; - padding: 4px 6px; - border-radius: 8px; - border: 1px solid #e5e7eb; - background: #ffffff; - cursor: pointer; + gap: 3px; + margin-bottom: 6px; } -.object-item-header { - display: flex; - align-items: center; - gap: 4px; - flex-wrap: nowrap; +.sidebar-row label { + font-size: 11px; + font-weight: 600; + color: var(--text-3); + text-transform: uppercase; + letter-spacing: 0.04em; } -.object-hierarchy-select { - width: 40px; - min-width: 0; - padding: 2px 3px; - border-radius: 6px; - border: 1px solid #d1d5db; - font-size: 0.8rem; - background: #f9fafb; +.sidebar-row input[type="text"] { + padding: 6px 8px; + font-size: 12.5px; + border-radius: var(--r-sm); } -.object-parent-select { - width: 50px; - min-width: 0; - padding: 2px 3px; - border-radius: 6px; - border: 1px solid #d1d5db; - font-size: 0.8rem; - background: #eef2ff; -} - -.object-item img { - width: 40px; - height: 40px; - object-fit: cover; - border-radius: 6px; - border: 1px solid #e5e7eb; -} - -.object-item-text { - display: flex; - flex-direction: column; - font-size: 0.8rem; - flex: 1; - min-width: 0; -} - -.object-item-text strong { - font-size: 0.85rem; -} - -.object-item-details { - padding-left: 24px; - display: none; - flex-direction: column; - gap: 4px; - margin-top: 4px; -} - -.object-item-details.visible { - display: flex; -} - -.object-item-details input[type="text"] { - padding: 4px 6px; - border-radius: 6px; - border: 1px solid #d1d5db; - font-size: 0.8rem; -} - -.object-item-details label { - font-size: 0.75rem; -} - -.object-icon-button { - padding: 2px 6px; - border-radius: 6px; - font-size: 0.85rem; - box-shadow: none; - background: #e5e7eb; - color: #374151; - flex-shrink: 0; -} - -.object-icon-button:not(:disabled):hover { - background: #d1d5db; - box-shadow: none; - transform: none; +.detail-value { + padding: 5px 8px; + border-radius: var(--r-sm); + border: 1px solid var(--border); + background: var(--surface-2); + font-size: 12.5px; + color: var(--text-1); + min-height: 26px; + word-break: break-word; } +/* ===================================================== + SENTENCES LIST + ===================================================== */ .sentences-list { display: flex; flex-direction: column; - gap: 12px; - max-height: 70vh; + gap: 10px; + max-height: 100%; overflow-y: auto; - padding: 4px; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; } .sentence-item { padding: 12px; - background: #f9fbff; - border: 1px solid #d4ddf5; - border-radius: 8px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-md); display: flex; flex-direction: column; - gap: 8px; + gap: 6px; +} + +.sentence-level { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--primary-muted-fg); + margin-bottom: 2px; } .sentence-item-question { font-weight: 600; - color: #1e40af; - font-size: 0.95rem; + color: var(--text-1); + font-size: 13px; } .sentence-item-answer { - color: #475569; - font-size: 0.9rem; - padding-left: 12px; - border-left: 2px solid #cbd5f0; + color: var(--text-2); + font-size: 12.5px; + padding-left: 10px; + border-left: 2px solid var(--primary-muted); + line-height: 1.5; } -.sentence-item-empty { - padding: 24px; +.sentence-item-empty, +.empty-state { + padding: 20px 12px; text-align: center; - color: #94a3b8; + color: var(--text-3); + font-size: 12.5px; font-style: italic; } -.selections-list { +/* ===================================================== + STATUS MESSAGE + ===================================================== */ +.status-msg { + display: flex; + align-items: center; + gap: 6px; + margin-top: 8px; + padding: 7px 10px; + border-radius: var(--r-md); + font-size: 12px; + font-weight: 500; +} + +.status-msg.ok { + background: var(--success-bg); + color: var(--success); + border: 1px solid var(--success); +} + +.status-msg.error { + background: var(--danger-bg); + color: var(--danger); + border: 1px solid var(--danger); +} + +/* Legacy compat */ +.status { font-size: 12px; } +.status.ok { color: var(--success); } +.status.error { color: var(--danger); } + +/* ===================================================== + AUTH / LOGIN PAGE + ===================================================== */ +.auth-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg); + padding: 24px; +} + +.auth-card { + width: 100%; + max-width: 380px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-xl); + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +.auth-card-header { + padding: 28px 28px 20px; + border-bottom: 1px solid var(--border); + background: var(--surface-2); display: flex; flex-direction: column; - gap: 8px; - max-height: 200px; - overflow-y: auto; - padding: 8px; - background: #f9fbff; - border: 1px solid #d4ddf5; - border-radius: 8px; - margin-bottom: 8px; + align-items: center; + gap: 10px; } -.selection-item { - padding: 8px; - background: #ffffff; - border: 1px solid #e5e7eb; - border-radius: 6px; - font-size: 0.85rem; - color: #475569; +.auth-logo { + width: 44px; + height: 44px; + border-radius: var(--r-lg); + background: var(--primary); + color: white; + display: flex; + align-items: center; + justify-content: center; } -.selection-item strong { - color: #1e40af; - font-weight: 600; +.auth-title { + font-size: 18px; + font-weight: 700; + color: var(--text-1); + letter-spacing: -0.02em; } -.selections-empty { - padding: 16px; +.auth-subtitle { + font-size: 13px; + color: var(--text-3); text-align: center; - color: #94a3b8; - font-style: italic; - font-size: 0.85rem; +} + +.auth-card-body { + padding: 24px 28px 28px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.auth-field { + display: flex; + flex-direction: column; + gap: 5px; +} + +.auth-field label { + font-size: 12px; + font-weight: 600; + color: var(--text-2); + letter-spacing: 0.02em; +} + +.auth-error { + padding: 9px 12px; + background: var(--danger-bg); + border: 1px solid var(--danger); + border-radius: var(--r-md); + color: var(--danger); + font-size: 12.5px; + font-weight: 500; +} + +.auth-submit { + background: var(--primary); + color: white; + font-size: 14px; + font-weight: 600; + padding: 10px 16px; + border-radius: var(--r-md); + border: none; + cursor: pointer; + width: 100%; + margin-top: 4px; + box-shadow: 0 2px 8px rgba(92,108,246,.35); + transition: all 0.15s; +} + +.auth-submit:hover:not(:disabled) { + background: var(--primary-hover); + box-shadow: 0 4px 14px rgba(92,108,246,.45); + transform: translateY(-1px); +} + +.auth-submit:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; + box-shadow: none; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2caafe8..5b81772 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,12 +2,15 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App' +import { AuthProvider } from './context/AuthContext' import './index.css' createRoot(document.getElementById('root')!).render( - + + + ) diff --git a/frontend/src/pages/DrawIt.tsx b/frontend/src/pages/DrawIt.tsx index 85c2fc8..06642f9 100644 --- a/frontend/src/pages/DrawIt.tsx +++ b/frontend/src/pages/DrawIt.tsx @@ -1,28 +1,43 @@ import { useState, useEffect, useCallback, useRef } from 'react' -import { useNavigate } from 'react-router-dom' import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas' 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' const FIELD_LABELS: Record = { - title_de: 'Titel / title_de', - position_de: 'Position / position_de', - action_de: 'Status (sitzt/schwimmt/segelt) / action_de', - condition_de: 'Zustand (alt/jung/rostig) / condition_de', + title_de: 'Titel', + position_de: 'Position', + action_de: 'Aktion', + condition_de: 'Zustand', } const FIELD_PLACEHOLDERS: Record = { + title_de: 'z.B. Hund', + position_de: 'z.B. links oben', action_de: 'z.B. sitzt', condition_de: 'z.B. rostig', } type FormKey = 'title_de' | 'position_de' | 'action_de' | 'condition_de' -export default function DrawIt() { - const navigate = useNavigate() +const ChevronLeftIcon = () => ( + + + +) - const [imageList, setImageList] = useState([]) +const ChevronRightIcon = () => ( + + + +) + +export default function DrawIt() { + const { token } = useAuth() + + const [pictureList, setPictureList] = useState([]) const [currentIndex, setCurrentIndex] = useState(-1) const [objects, setObjects] = useState([]) const [currentSelections, setCurrentSelections] = useState([]) @@ -39,32 +54,33 @@ export default function DrawIt() { }) const canvasRef = useRef(null) - const currentFilename = currentIndex >= 0 && currentIndex < imageList.length - ? imageList[currentIndex] + const currentPicture = currentIndex >= 0 && currentIndex < pictureList.length + ? pictureList[currentIndex] : null useEffect(() => { - getImages('draw') - .then(imgs => { - setImageList(imgs) - setCurrentIndex(imgs.length - 1) + if (!token) return + getDirectusPictures(token) + .then(pics => { + setPictureList(pics) + setCurrentIndex(pics.length > 0 ? 0 : -1) }) .catch(console.error) - }, []) + }, [token]) useEffect(() => { - if (!currentFilename) { + if (!currentPicture) { setObjects([]) setSelectedObjectId(null) return } - getObjects(currentFilename) + getObjects(currentPicture.id) .then(objs => { setObjects(objs.map(o => ({ ...o, visible: true }))) setSelectedObjectId(objs[0]?.id ?? null) }) .catch(console.error) - }, [currentFilename]) + }, [currentPicture?.id]) const handleHasSelection = useCallback((has: boolean) => setHasSelection(has), []) @@ -89,11 +105,11 @@ export default function DrawIt() { } const saveObject = async () => { - if (!currentFilename || currentSelections.length === 0) return + if (!currentPicture || currentSelections.length === 0) return try { - showStatus('Speichere Objekt...') + showStatus('Speichere Objekt…') const result = await cropImage({ - filename: currentFilename, + filename: currentPicture.id, selections: currentSelections.map((sel, idx) => ({ number: idx + 1, mode: sel.mode, @@ -105,80 +121,75 @@ export default function DrawIt() { showStatus(`Gespeichert – ID: ${result.id} (${currentSelections.length} Auswahl(en))`) setCurrentSelections([]) 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 }))) } catch (e) { showStatus(e instanceof Error ? e.message : 'Fehler beim Speichern.', true) } } - const handleSaveImage = async () => { - if (!currentFilename) return - 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) - } - } + const imageNav = ( +
+ + + {pictureList.length > 0 ? ( + <> + {currentIndex + 1} + / + {pictureList.length} + + ) : ( + Keine Bilder + )} + + +
+ ) return ( -
-

DrawIt

+
+ -
-
- - Bild: {currentFilename || '–'} - - -
-
- -
-
- -
- {/* Left: Objects */} -
-

Objekte zu diesem Bild

- - setObjects(prev => prev.map(o => o.id === id ? { ...o, visible } : o)) - } - onObjectsChange={setObjects} - isGeneratePage={false} - /> -
+
+ {/* Left sidebar: Objects */} + {/* Center: Canvas */} -
-
+
+
-
+ - {/* Right: Controls */} -
-
-

Auswahl

-
- Auswahl-Typ (Interface / Backend): -
-
-
) diff --git a/frontend/src/pages/GenerateIt.tsx b/frontend/src/pages/GenerateIt.tsx index 50c3449..ca49f6f 100644 --- a/frontend/src/pages/GenerateIt.tsx +++ b/frontend/src/pages/GenerateIt.tsx @@ -1,14 +1,36 @@ import { useState, useEffect } from 'react' -import { useNavigate } from 'react-router-dom' import ObjectsList from '../components/ObjectsList' import DetailsPanel from '../components/DetailsPanel' import SentencesList from '../components/SentencesList' +import Topbar from '../components/Topbar' import { getImages, getObjects, generateDetails, generateSentence, getSentences } from '../api' import type { ObjectMeta, Sentence } from '../types' -export default function GenerateIt() { - const navigate = useNavigate() +const ChevronLeftIcon = () => ( + + + +) +const ChevronRightIcon = () => ( + + + +) + +const SparkleIcon = () => ( + + + +) + +const ChatIcon = () => ( + + + +) + +export default function GenerateIt() { const [imageList, setImageList] = useState([]) const [currentIndex, setCurrentIndex] = useState(-1) const [objects, setObjects] = useState([]) @@ -52,8 +74,7 @@ export default function GenerateIt() { const loadSentences = async (objId: string) => { try { - const s = await getSentences(objId) - setSentences(s) + setSentences(await getSentences(objId)) } catch (e) { console.error(e) } @@ -90,76 +111,103 @@ export default function GenerateIt() { } } - return ( -
-

GenerateIt

- -
-
- - Bild: {currentFilename || '–'} - - - -
-
- -
+ const imageNav = ( +
+
+ + + {imageList.length > 0 ? ( + <> + {currentIndex + 1} + / + {imageList.length} + + ) : ( + Keine Bilder + )} + +
-
-
-

Objekte zu diesem Bild

- { - const obj = objects.find(o => o.id === id) - if (obj) { - setSelectedObj(obj) - loadSentences(obj.id) - } - }} - onObjectsChange={setObjects} - isGeneratePage={true} - onShowDetails={obj => setSelectedObj(obj)} - onLoadSentences={loadSentences} - /> -
+
-
- -
+ + +
+ ) -
-
-

Alle Sätze

+ return ( +
+ + +
+ {/* Left: Objects */} + + + {/* Center: Details */} +
+
+
+ +
+
+
+ + {/* Right: Sentences */} +
+
) diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..280a30d --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -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 = () => ( + + + + + + + +) + +const SunIcon = () => ( + + + + +) + +const MoonIcon = () => ( + + + +) + +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 ( +
+ + +
+
+
+ +
+
+
Content Mentor
+
Bitte melde dich an, um fortzufahren
+
+
+ +
+
+ + setEmail(e.target.value)} + placeholder="deine@email.com" + required + autoFocus + autoComplete="email" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + required + autoComplete="current-password" + /> +
+ + {error &&
{error}
} + + +
+
+
+ ) +}