init push

This commit is contained in:
nadja
2026-08-25 20:29:14 +02:00
commit 2bb9aab82c
42 changed files with 3966 additions and 0 deletions

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

File diff suppressed because one or more lines are too long

67
frontend/dist/assets/index-CeRXDRLQ.js vendored Normal file

File diff suppressed because one or more lines are too long

13
frontend/dist/index.html vendored Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TikTok Rezepte</title>
<script type="module" crossorigin src="/assets/index-CeRXDRLQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CV-7lATv.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TikTok Rezepte</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

23
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,23 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
}

104
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,104 @@
import { Routes, Route, Link, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import Home from './pages/Home';
import AddRecipe from './pages/AddRecipe';
import RecipeDetail from './pages/RecipeDetail';
import Login from './pages/Login';
import Register from './pages/Register';
import Admin from './pages/Admin';
import Profile from './pages/Profile';
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
if (loading) return <div className="text-center py-12 text-gray-400">Laden...</div>;
if (!user) return <Navigate to="/login" />;
return children;
}
function AdminRoute({ children }) {
const { user, loading } = useAuth();
if (loading) return <div className="text-center py-12 text-gray-400">Laden...</div>;
if (!user) return <Navigate to="/login" />;
if (!user.is_admin) return <Navigate to="/" />;
return children;
}
function AppContent() {
const { user, logout } = useAuth();
if (!user) {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="*" element={<Navigate to="/login" />} />
</Routes>
);
}
return (
<div className="min-h-screen">
<nav className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
<Link to="/" className="text-xl font-bold text-blue-600">
TikTok Rezepte
</Link>
<div className="flex items-center gap-3">
<Link
to="/add"
className="text-sm text-gray-500 hover:text-gray-700 font-medium transition-colors"
>
+ Rezept hinzufügen
</Link>
{user.is_admin && (
<Link
to="/admin"
className="text-sm text-gray-500 hover:text-gray-700 font-medium transition-colors"
>
Dashboard
</Link>
)}
<Link
to="/profile"
className="text-sm text-gray-500 hover:text-gray-700 font-medium transition-colors"
>
Profil
</Link>
<span className="text-sm text-gray-400">|</span>
<span className="text-sm text-gray-500">
{user.username}
{user.is_admin && <span className="ml-1 text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded-full">Admin</span>}
</span>
<button
onClick={logout}
className="text-sm text-gray-400 hover:text-gray-600 font-medium transition-colors"
>
Abmelden
</button>
</div>
</div>
</nav>
<main className="max-w-6xl mx-auto px-4 py-6">
<Routes>
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
<Route path="/add" element={<ProtectedRoute><AddRecipe /></ProtectedRoute>} />
<Route path="/recipe/:id" element={<ProtectedRoute><RecipeDetail /></ProtectedRoute>} />
<Route path="/admin" element={<AdminRoute><Admin /></AdminRoute>} />
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
<Route path="/login" element={<Navigate to="/" />} />
<Route path="/register" element={<Navigate to="/" />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</main>
</div>
);
}
export default function App() {
return (
<AuthProvider>
<AppContent />
</AuthProvider>
);
}

60
frontend/src/api.js Normal file
View File

@@ -0,0 +1,60 @@
const API_BASE = '/api/recipes';
async function apiFetch(url, options = {}) {
const res = await fetch(url, options);
const text = await res.text();
if (!res.ok) {
let msg = `Server-Fehler (${res.status})`;
try { msg = JSON.parse(text).error || msg; } catch {}
throw new Error(msg);
}
if (!text) throw new Error('Leere Antwort vom Server Backend läuft nicht?');
return JSON.parse(text);
}
export async function fetchRecipes(search, category) {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (category && category !== 'Alle') params.set('category', category);
return apiFetch(`${API_BASE}?${params}`);
}
export async function fetchRecipe(id) {
return apiFetch(`${API_BASE}/${id}`);
}
export async function saveRecipe(recipe) {
return apiFetch(API_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recipe),
});
}
export async function updateRecipe(id, recipe) {
return apiFetch(`${API_BASE}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recipe),
});
}
export async function deleteRecipe(id) {
return apiFetch(`${API_BASE}/${id}`, { method: 'DELETE' });
}
export async function extractFromTikTok(url) {
return apiFetch(`${API_BASE}/from-tiktok`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
}
export async function fetchCategories() {
return apiFetch(`${API_BASE}/categories`);
}
export async function checkAiStatus() {
return apiFetch(`${API_BASE}/ai-status`);
}

70
frontend/src/auth.js Normal file
View File

@@ -0,0 +1,70 @@
const AUTH_BASE = '/api/auth';
async function authFetch(url, options = {}) {
const res = await fetch(url, options);
const text = await res.text();
if (!text) throw new Error('Leere Antwort vom Server Backend läuft nicht?');
const data = JSON.parse(text);
if (!res.ok) throw new Error(data.error || `Server-Fehler (${res.status})`);
return data;
}
export async function loginUser(username, password) {
return authFetch(`${AUTH_BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
}
export async function registerUser(username, password, inviteToken) {
return authFetch(`${AUTH_BASE}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, inviteToken }),
});
}
export async function fetchCurrentUser(token) {
const res = await fetch(`${AUTH_BASE}/me`, {
headers: { Authorization: `Bearer ${token}` },
});
const text = await res.text();
if (!res.ok || !text) throw new Error('Nicht authentifiziert');
return JSON.parse(text);
}
export async function createInvites(token, count = 1) {
return authFetch(`${AUTH_BASE}/invite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ count }),
});
}
export async function fetchInvites(token) {
return authFetch(`${AUTH_BASE}/invites`, {
headers: { Authorization: `Bearer ${token}` },
});
}
export async function fetchUsers(token) {
return authFetch(`${AUTH_BASE}/users`, {
headers: { Authorization: `Bearer ${token}` },
});
}
export async function updateCredentials(token, { currentPassword, newUsername, newPassword }) {
return authFetch(`${AUTH_BASE}/credentials`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ currentPassword, newUsername, newPassword }),
});
}
export async function deleteUser(token, userId) {
return authFetch(`${AUTH_BASE}/users/${userId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
}

View File

@@ -0,0 +1,28 @@
const CATEGORY_COLORS = {
'Frühstück': 'bg-yellow-100 text-yellow-800',
'Hauptgericht': 'bg-red-100 text-red-800',
'Dessert': 'bg-pink-100 text-pink-800',
'Snack': 'bg-green-100 text-green-800',
'Getränk': 'bg-blue-100 text-blue-800',
'Sonstiges': 'bg-gray-100 text-gray-800',
};
const CATEGORY_ICONS = {
'Frühstück': '🍳',
'Hauptgericht': '🍽️',
'Dessert': '🍰',
'Snack': '🍿',
'Getränk': '🥤',
'Sonstiges': '📋',
};
export default function CategoryBadge({ category }) {
const colorClass = CATEGORY_COLORS[category] || CATEGORY_COLORS['Sonstiges'];
const icon = CATEGORY_ICONS[category] || CATEGORY_ICONS['Sonstiges'];
return (
<span className={`text-xs font-medium px-2 py-1 rounded-full whitespace-nowrap ${colorClass}`}>
{icon} {category}
</span>
);
}

View File

@@ -0,0 +1,82 @@
import { useState } from 'react';
export default function IngredientList({ ingredients, editable, onChange }) {
const [checked, setChecked] = useState({});
const toggle = (i) => setChecked(prev => ({ ...prev, [i]: !prev[i] }));
const updateIngredient = (i, field, value) => {
const updated = ingredients.map((ing, idx) =>
idx === i ? { ...ing, [field]: value } : ing
);
onChange(updated);
};
const addIngredient = () => {
onChange([...ingredients, { name: '', amount: '', unit: '' }]);
};
const removeIngredient = (i) => {
onChange(ingredients.filter((_, idx) => idx !== i));
};
return (
<div>
<h3 className="font-semibold text-lg mb-3">Zutaten</h3>
<ul className="space-y-2">
{ingredients.map((ing, i) => (
<li key={i} className="flex items-center gap-2">
{!editable && (
<input
type="checkbox"
checked={!!checked[i]}
onChange={() => toggle(i)}
className="w-4 h-4 accent-blue-600"
/>
)}
{editable ? (
<>
<input
type="text"
value={ing.amount}
onChange={e => updateIngredient(i, 'amount', e.target.value)}
placeholder="Menge"
className="w-16 px-2 py-1 border border-gray-300 rounded text-sm"
/>
<input
type="text"
value={ing.unit}
onChange={e => updateIngredient(i, 'unit', e.target.value)}
placeholder="Einheit"
className="w-14 px-2 py-1 border border-gray-300 rounded text-sm"
/>
<input
type="text"
value={ing.name}
onChange={e => updateIngredient(i, 'name', e.target.value)}
placeholder="Zutat"
className="flex-1 px-2 py-1 border border-gray-300 rounded text-sm"
/>
<button onClick={() => removeIngredient(i)} className="text-red-400 hover:text-red-600 text-lg">&times;</button>
</>
) : (
<span className={`${checked[i] ? 'line-through text-gray-400' : ''}`}>
{ing.amount && `${ing.amount} `}
{ing.unit && `${ing.unit} `}
{ing.name}
</span>
)}
</li>
))}
</ul>
{editable && (
<button
onClick={addIngredient}
className="mt-2 text-sm text-blue-600 hover:text-blue-700 font-medium"
>
+ Zutat hinzufügen
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { Link } from 'react-router-dom';
import CategoryBadge from './CategoryBadge';
export default function RecipeCard({ recipe }) {
return (
<Link
to={`/recipe/${recipe.id}`}
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow"
>
{recipe.thumbnail_url && (
<img
src={recipe.thumbnail_url}
alt={recipe.name}
className="w-full h-48 object-cover"
/>
)}
<div className="p-4">
<div className="flex items-start justify-between gap-2 mb-2">
<h3 className="font-semibold text-lg leading-tight">{recipe.name}</h3>
<CategoryBadge category={recipe.category} />
</div>
{recipe.description && (
<p className="text-gray-500 text-sm line-clamp-2 mb-2">
{recipe.description}
</p>
)}
<div className="flex items-center gap-4 text-xs text-gray-400">
{recipe.tiktok_author && <span>@{recipe.tiktok_author}</span>}
<span>{recipe.ingredients?.length || 0} Zutaten</span>
<span>{recipe.steps?.length || 0} Schritte</span>
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,76 @@
import { useState } from 'react';
export default function StepList({ steps, editable, onChange }) {
const [completed, setCompleted] = useState({});
const toggle = (i) => setCompleted(prev => ({ ...prev, [i]: !prev[i] }));
const updateStep = (i, value) => {
const updated = steps.map((s, idx) => (idx === i ? value : s));
onChange(updated);
};
const addStep = () => onChange([...steps, '']);
const removeStep = (i) => onChange(steps.filter((_, idx) => idx !== i));
const moveStep = (i, direction) => {
const arr = [...steps];
const j = i + direction;
if (j < 0 || j >= arr.length) return;
[arr[i], arr[j]] = [arr[j], arr[i]];
onChange(arr);
};
return (
<div>
<h3 className="font-semibold text-lg mb-3">Zubereitung</h3>
<ol className="space-y-3">
{steps.map((step, i) => (
<li key={i} className="flex items-start gap-3">
{!editable && (
<button
onClick={() => toggle(i)}
className={`mt-1 w-6 h-6 rounded-full border-2 flex-shrink-0 flex items-center justify-center text-xs font-bold transition-colors ${
completed[i]
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-300 text-gray-400'
}`}
>
{completed[i] ? '✓' : i + 1}
</button>
)}
{editable ? (
<div className="flex-1 flex gap-2">
<span className="mt-2 text-sm font-bold text-gray-400 w-5">{i + 1}.</span>
<textarea
value={step}
onChange={e => updateStep(i, e.target.value)}
rows={2}
className="flex-1 px-2 py-1 border border-gray-300 rounded text-sm resize-none"
/>
<div className="flex flex-col gap-1">
<button onClick={() => moveStep(i, -1)} className="text-gray-400 hover:text-gray-600 text-xs">&uarr;</button>
<button onClick={() => moveStep(i, 1)} className="text-gray-400 hover:text-gray-600 text-xs">&darr;</button>
<button onClick={() => removeStep(i)} className="text-red-400 hover:text-red-600 text-xs">&times;</button>
</div>
</div>
) : (
<p className={`${completed[i] ? 'line-through text-gray-400' : ''}`}>
{step}
</p>
)}
</li>
))}
</ol>
{editable && (
<button
onClick={addStep}
className="mt-2 text-sm text-blue-600 hover:text-blue-700 font-medium"
>
+ Schritt hinzufügen
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { createContext, useContext, useState, useEffect } from 'react';
import { fetchCurrentUser } from '../auth';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [token, setToken] = useState(() => localStorage.getItem('token'));
const [loading, setLoading] = useState(true);
useEffect(() => {
if (token) {
fetchCurrentUser(token)
.then(u => setUser(u))
.catch(() => {
localStorage.removeItem('token');
setToken(null);
})
.finally(() => setLoading(false));
} else {
setLoading(false);
}
}, [token]);
const loginUser = (userData, userToken) => {
localStorage.setItem('token', userToken);
setToken(userToken);
setUser(userData);
};
const logout = () => {
localStorage.removeItem('token');
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider value={{ user, token, loading, loginUser, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}

5
frontend/src/index.css Normal file
View File

@@ -0,0 +1,5 @@
@import "tailwindcss";
body {
@apply bg-gray-50 text-gray-900;
}

13
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,194 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { extractFromTikTok, saveRecipe } from '../api';
import IngredientList from '../components/IngredientList';
import StepList from '../components/StepList';
const CATEGORIES = ['Frühstück', 'Hauptgericht', 'Dessert', 'Snack', 'Getränk', 'Sonstiges'];
export default function AddRecipe() {
const navigate = useNavigate();
const [url, setUrl] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [category, setCategory] = useState('Sonstiges');
const [ingredients, setIngredients] = useState([]);
const [steps, setSteps] = useState([]);
const [thumbnail, setThumbnail] = useState('');
const [tiktokAuthor, setTiktokAuthor] = useState('');
const [hasData, setHasData] = useState(false);
const [aiStatus, setAiStatus] = useState(null);
const handleExtract = async () => {
if (!url.trim()) return;
setLoading(true);
setError('');
setAiStatus(null);
try {
const data = await extractFromTikTok(url);
if (data.tiktok) {
setThumbnail(data.tiktok.thumbnail);
setTiktokAuthor(data.tiktok.author);
}
if (data.extracted) {
setName(data.extracted.name || '');
setDescription(data.extracted.description || '');
setCategory(data.extracted.category || 'Sonstiges');
setIngredients(data.extracted.ingredients || []);
setSteps(data.extracted.steps || []);
} else {
setName(data.tiktok?.title || '');
}
setAiStatus(data.aiStatus || (data.extracted ? 'success' : 'unavailable'));
setHasData(true);
} catch (e) {
setError('Fehler beim Abrufen der TikTok-Daten. Überprüfe die URL.');
}
setLoading(false);
};
const handleSave = async () => {
if (!name.trim()) return;
setSaving(true);
try {
const recipe = await saveRecipe({
name, description, category, ingredients, steps,
tiktok_url: url, tiktok_author: tiktokAuthor, thumbnail_url: thumbnail,
});
navigate(`/recipe/${recipe.id}`);
} catch (e) {
setError('Fehler beim Speichern.');
}
setSaving(false);
};
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Rezept hinzufügen</h1>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
TikTok Video-URL
</label>
<div className="flex gap-2">
<input
type="url"
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="https://www.tiktok.com/@user/video/..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
onKeyDown={e => e.key === 'Enter' && handleExtract()}
/>
<button
onClick={handleExtract}
disabled={loading}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-6 py-2 rounded-lg font-medium transition-colors"
>
{loading ? 'Laden...' : 'Extrahieren'}
</button>
</div>
{error && <p className="text-red-500 text-sm mt-2">{error}</p>}
</div>
{hasData && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
{thumbnail && (
<img src={thumbnail} alt="" className="w-full h-48 object-cover rounded-lg" />
)}
{aiStatus && (
<div className={`flex items-center gap-2 text-sm px-3 py-2 rounded-lg ${
aiStatus === 'success'
? 'bg-green-50 text-green-700 border border-green-200'
: aiStatus === 'error'
? 'bg-yellow-50 text-yellow-700 border border-yellow-200'
: 'bg-gray-50 text-gray-500 border border-gray-200'
}`}>
{aiStatus === 'success' && (
<>
<svg className="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Zutaten und Zubereitung wurden per KI extrahiert überprüfe und passe bei Bedarf an.
</>
)}
{aiStatus === 'error' && (
<>
<svg className="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
KI konnte nicht extrahieren füll die Felder manuell aus.
</>
)}
{aiStatus === 'unavailable' && (
<>
<svg className="w-4 h-4 text-gray-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
<span>
KI (Ollama) nicht erreichbar.{' '}
<span className="text-gray-400">Starte Ollama oder füll die Felder manuell aus.</span>
</span>
</>
)}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
<select
value={category}
onChange={e => setCategory(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<IngredientList ingredients={ingredients} editable onChange={setIngredients} />
<StepList steps={steps} editable onChange={setSteps} />
<div className="flex gap-3 pt-2">
<button
onClick={handleSave}
disabled={saving || !name.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white py-3 rounded-lg font-semibold transition-colors"
>
{saving ? 'Speichern...' : 'Rezept speichern'}
</button>
<button
onClick={() => navigate('/')}
className="px-6 py-3 border border-gray-300 rounded-lg font-medium hover:bg-gray-50 transition-colors"
>
Abbrechen
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,215 @@
import { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import { createInvites, fetchInvites, fetchUsers, deleteUser as apiDeleteUser } from '../auth';
export default function Admin() {
const { token, user: authUser } = useAuth();
const [invites, setInvites] = useState([]);
const [stats, setStats] = useState({ total: 0, used: 0, available: 0 });
const [users, setUsers] = useState([]);
const [inviteCount, setInviteCount] = useState(1);
const [newTokens, setNewTokens] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [copied, setCopied] = useState('');
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [invitesData, usersData] = await Promise.all([
fetchInvites(token),
fetchUsers(token),
]);
setInvites(invitesData.invites);
setStats(invitesData.stats);
setUsers(usersData);
} catch (err) {
setError(err.message);
}
};
const handleCreateInvites = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const data = await createInvites(token, inviteCount);
setNewTokens(data.tokens);
await loadData();
} catch (err) {
setError(err.message);
}
setLoading(false);
};
const copyToClipboard = (text) => {
const url = `${window.location.origin}/register?invite=${text}`;
navigator.clipboard.writeText(url);
setCopied(text);
setTimeout(() => setCopied(''), 2000);
};
const handleDeleteUser = async (userId, username) => {
if (!confirm(`Benutzer "${username}" wirklich löschen?`)) return;
try {
await apiDeleteUser(token, userId);
await loadData();
} catch (err) {
setError(err.message);
}
};
return (
<div className="max-w-4xl mx-auto space-y-8">
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-xl">
{error}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<p className="text-sm text-gray-500">Einladungen erstellt</p>
<p className="text-3xl font-bold text-blue-600">{stats.total}</p>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<p className="text-sm text-gray-500">Verwendet</p>
<p className="text-3xl font-bold text-green-600">{stats.used}</p>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<p className="text-sm text-gray-500">Offen</p>
<p className="text-3xl font-bold text-orange-500">{stats.available}</p>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold mb-4">Neue Einladung erstellen</h2>
<form onSubmit={handleCreateInvites} className="flex items-end gap-3">
<div className="flex-1 max-w-xs">
<label className="block text-sm font-medium text-gray-700 mb-1">Anzahl</label>
<input
type="number"
min={1}
max={20}
value={inviteCount}
onChange={e => setInviteCount(parseInt(e.target.value) || 1)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
type="submit"
disabled={loading}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-6 py-2 rounded-lg font-medium transition-colors"
>
{loading ? 'Erstellen...' : 'Erstellen'}
</button>
</form>
{newTokens.length > 0 && (
<div className="mt-4 space-y-2">
<p className="text-sm font-medium text-gray-700">Neue Einladungslinks:</p>
{newTokens.map((t, i) => (
<div key={i} className="flex items-center gap-2 bg-blue-50 rounded-lg px-3 py-2">
<code className="flex-1 text-sm text-blue-800 break-all">{t}</code>
<button
onClick={() => copyToClipboard(t)}
className="text-sm bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded-lg font-medium transition-colors whitespace-nowrap"
>
{copied === t ? 'Kopiert!' : 'Link kopieren'}
</button>
</div>
))}
</div>
)}
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold mb-4">Alle Einladungen</h2>
{invites.length === 0 ? (
<p className="text-gray-400 text-sm">Noch keine Einladungen erstellt.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 font-medium text-gray-500">Code</th>
<th className="text-left py-2 font-medium text-gray-500">Erstellt</th>
<th className="text-left py-2 font-medium text-gray-500">Status</th>
<th className="text-left py-2 font-medium text-gray-500">Genutzt von</th>
</tr>
</thead>
<tbody>
{invites.map(inv => (
<tr key={inv.id} className="border-b border-gray-100">
<td className="py-2">
<code className="text-xs bg-gray-100 px-2 py-1 rounded">{inv.token.slice(0, 12)}...</code>
</td>
<td className="py-2 text-gray-500">{new Date(inv.created_at).toLocaleDateString('de-DE')}</td>
<td className="py-2">
{inv.used_by ? (
<span className="text-green-600 font-medium">Verwendet</span>
) : (
<span className="text-blue-600 font-medium">Offen</span>
)}
</td>
<td className="py-2 text-gray-500">{inv.used_by_username || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold mb-4">Registrierte Benutzer</h2>
{users.length === 0 ? (
<p className="text-gray-400 text-sm">Keine Benutzer gefunden.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 font-medium text-gray-500">Benutzername</th>
<th className="text-left py-2 font-medium text-gray-500">Rolle</th>
<th className="text-left py-2 font-medium text-gray-500">Registriert</th>
<th className="text-right py-2 font-medium text-gray-500"></th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} className="border-b border-gray-100">
<td className="py-2 font-medium">{u.username}</td>
<td className="py-2">
{u.is_admin ? (
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded-full">Admin</span>
) : (
<span className="bg-gray-100 text-gray-600 text-xs font-medium px-2 py-1 rounded-full">User</span>
)}
</td>
<td className="py-2 text-gray-500">{new Date(u.created_at).toLocaleDateString('de-DE')}</td>
<td className="py-2 text-right">
{u.id !== authUser?.id && (
<button
onClick={() => handleDeleteUser(u.id, u.username)}
className="text-sm text-red-400 hover:text-red-600 font-medium transition-colors"
>
Löschen
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { useState, useEffect } from 'react';
import { fetchRecipes, fetchCategories } from '../api';
import RecipeCard from '../components/RecipeCard';
const ALL_CATEGORIES = ['Alle', 'Frühstück', 'Hauptgericht', 'Dessert', 'Snack', 'Getränk', 'Sonstiges'];
export default function Home() {
const [recipes, setRecipes] = useState([]);
const [categories, setCategories] = useState([]);
const [search, setSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState('Alle');
const [loading, setLoading] = useState(true);
useEffect(() => {
loadRecipes();
loadCategories();
}, [search, selectedCategory]);
const loadRecipes = async () => {
setLoading(true);
try {
const data = await fetchRecipes(search, selectedCategory);
setRecipes(data);
} catch (e) {
console.error('Fehler beim Laden:', e);
}
setLoading(false);
};
const loadCategories = async () => {
try {
const data = await fetchCategories();
setCategories(data);
} catch (e) {
console.error('Fehler beim Laden der Kategorien:', e);
}
};
const getCategoryCount = (cat) => {
if (cat === 'Alle') return categories.reduce((sum, c) => sum + c.count, 0);
const found = categories.find(c => c.category === cat);
return found ? found.count : 0;
};
return (
<div>
<div className="mb-6">
<input
type="text"
placeholder="Rezepte suchen..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-xl text-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="flex flex-wrap gap-2 mb-6">
{ALL_CATEGORIES.map(cat => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
selectedCategory === cat
? 'bg-blue-600 text-white'
: 'bg-white border border-gray-200 text-gray-600 hover:border-blue-300'
}`}
>
{cat} ({getCategoryCount(cat)})
</button>
))}
</div>
{loading ? (
<div className="text-center py-12 text-gray-400">Laden...</div>
) : recipes.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-400 text-lg mb-2">Keine Rezepte gefunden</p>
<p className="text-gray-400 text-sm">
Füge dein erstes Rezept über TikTok-Links hinzu!
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{recipes.map(recipe => (
<RecipeCard key={recipe.id} recipe={recipe} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { useState } from 'react';
import { loginUser as apiLogin } from '../auth';
import { useAuth } from '../context/AuthContext';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { loginUser } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const data = await apiLogin(username, password);
loginUser(data.user, data.token);
} catch (err) {
setError(err.message);
}
setLoading(false);
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-100 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600 text-white text-3xl mb-4 shadow-lg shadow-blue-600/30">
🍳
</div>
<h1 className="text-3xl font-bold text-gray-900">TikTok Rezepte</h1>
<p className="text-gray-500 mt-1">Deine Rezepte, immer griffbereit</p>
</div>
<div className="bg-white rounded-2xl shadow-xl shadow-blue-900/5 border border-blue-100 p-8">
<h2 className="text-xl font-semibold text-gray-800 mb-6">Anmelden</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Benutzername
</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="Benutzername eingeben"
required
className="w-full px-4 py-3 border border-gray-300 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Passwort
</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Passwort eingeben"
required
className="w-full px-4 py-3 border border-gray-300 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-xl">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white py-3 rounded-xl font-semibold transition-colors shadow-lg shadow-blue-600/25 hover:shadow-blue-600/40"
>
{loading ? 'Laden...' : 'Anmelden'}
</button>
</form>
<p className="mt-6 text-center text-sm text-gray-400">
Nur für eingeladene Benutzer
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import { useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { updateCredentials } from '../auth';
export default function Profile() {
const { token, user: authUser, loginUser } = useAuth();
const [currentPassword, setCurrentPassword] = useState('');
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
setError('');
setSuccess('');
try {
const data = await updateCredentials(token, {
currentPassword,
newUsername: newUsername || undefined,
newPassword: newPassword || undefined,
});
loginUser(data.user, data.token);
setCurrentPassword('');
setNewUsername('');
setNewPassword('');
setSuccess('Daten erfolgreich geändert!');
} catch (err) {
setError(err.message);
}
setSaving(false);
};
return (
<div className="max-w-lg mx-auto">
<h1 className="text-2xl font-bold mb-6">Profil</h1>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<p className="text-sm text-gray-500 mb-6">
Angemeldet als <span className="font-medium text-gray-700">{authUser?.username}</span>
{authUser?.is_admin && <span className="ml-2 text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded-full">Admin</span>}
</p>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-xl mb-4">
{error}
</div>
)}
{success && (
<div className="bg-green-50 border border-green-200 text-green-700 text-sm px-4 py-3 rounded-xl mb-4">
{success}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Aktuelles Passwort</label>
<input
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
placeholder="Aktuelles Passwort eingeben"
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Neuer Benutzername (optional)</label>
<input
type="text"
value={newUsername}
onChange={e => setNewUsername(e.target.value)}
placeholder="Neuen Benutzernamen wählen"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Neues Passwort (optional)</label>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder="Mindestens 6 Zeichen"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
type="submit"
disabled={saving}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-6 py-2 rounded-lg font-medium transition-colors"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,174 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { fetchRecipe, updateRecipe, deleteRecipe } from '../api';
import CategoryBadge from '../components/CategoryBadge';
import IngredientList from '../components/IngredientList';
import StepList from '../components/StepList';
const CATEGORIES = ['Frühstück', 'Hauptgericht', 'Dessert', 'Snack', 'Getränk', 'Sonstiges'];
export default function RecipeDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [recipe, setRecipe] = useState(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({});
useEffect(() => { load(); }, [id]);
const load = async () => {
try {
const data = await fetchRecipe(id);
setRecipe(data);
setForm(data);
} catch (e) {
navigate('/');
}
setLoading(false);
};
const handleSave = async () => {
await updateRecipe(id, form);
setRecipe(form);
setEditing(false);
};
const handleDelete = async () => {
if (!confirm('Rezept wirklich löschen?')) return;
await deleteRecipe(id);
navigate('/');
};
const updateField = (field, value) => setForm(prev => ({ ...prev, [field]: value }));
if (loading) return <div className="text-center py-12 text-gray-400">Laden...</div>;
if (!recipe) return null;
const videoId = recipe.tiktok_url?.match(/video\/(\d+)/)?.[1];
return (
<div className="max-w-2xl mx-auto">
<Link to="/" className="text-blue-600 hover:text-blue-700 text-sm font-medium mb-4 inline-block">
&larr; Zurück
</Link>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{videoId && (
<div className="w-full" style={{ paddingBottom: '177.78%', position: 'relative' }}>
<iframe
src={`https://www.tiktok.com/embed/v2/${videoId}`}
className="absolute inset-0 w-full h-full"
allowFullScreen
/>
</div>
)}
<div className="p-6 space-y-6">
<div className="flex items-start justify-between gap-3">
{editing ? (
<input
type="text"
value={form.name}
onChange={e => updateField('name', e.target.value)}
className="text-2xl font-bold flex-1 px-2 py-1 border border-gray-300 rounded"
/>
) : (
<h1 className="text-2xl font-bold">{recipe.name}</h1>
)}
<CategoryBadge category={editing ? form.category : recipe.category} />
</div>
{editing && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
<select
value={form.category}
onChange={e => updateField('category', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
)}
{!editing && recipe.description && (
<p className="text-gray-500">{recipe.description}</p>
)}
{editing && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
value={form.description}
onChange={e => updateField('description', e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none"
/>
</div>
)}
{editing ? (
<IngredientList
ingredients={form.ingredients}
editable
onChange={ingredients => updateField('ingredients', ingredients)}
/>
) : (
<IngredientList ingredients={recipe.ingredients} />
)}
{editing ? (
<StepList
steps={form.steps}
editable
onChange={steps => updateField('steps', steps)}
/>
) : (
<StepList steps={recipe.steps} />
)}
{recipe.tiktok_author && (
<p className="text-xs text-gray-400">
Von: @{recipe.tiktok_author}
</p>
)}
<div className="flex gap-3 pt-2 border-t border-gray-100">
{editing ? (
<>
<button
onClick={handleSave}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white py-2 rounded-lg font-medium transition-colors"
>
Speichern
</button>
<button
onClick={() => { setEditing(false); setForm(recipe); }}
className="px-4 py-2 border border-gray-300 rounded-lg font-medium hover:bg-gray-50 transition-colors"
>
Abbrechen
</button>
</>
) : (
<>
<button
onClick={() => setEditing(true)}
className="flex-1 border border-blue-300 text-blue-600 hover:bg-blue-50 py-2 rounded-lg font-medium transition-colors"
>
Bearbeiten
</button>
<button
onClick={handleDelete}
className="px-4 py-2 border border-red-300 text-red-600 hover:bg-red-50 rounded-lg font-medium transition-colors"
>
Löschen
</button>
</>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { registerUser } from '../auth';
import { useAuth } from '../context/AuthContext';
export default function Register() {
const [searchParams] = useSearchParams();
const prefillToken = searchParams.get('invite') || '';
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [inviteToken, setInviteToken] = useState(prefillToken);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { loginUser } = useAuth();
useEffect(() => {
if (prefillToken) setInviteToken(prefillToken);
}, [prefillToken]);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const data = await registerUser(username, password, inviteToken);
loginUser(data.user, data.token);
} catch (err) {
setError(err.message);
}
setLoading(false);
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-100 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600 text-white text-3xl mb-4 shadow-lg shadow-blue-600/30">
🍳
</div>
<h1 className="text-3xl font-bold text-gray-900">TikTok Rezepte</h1>
<p className="text-gray-500 mt-1">Konto erstellen</p>
</div>
<div className="bg-white rounded-2xl shadow-xl shadow-blue-900/5 border border-blue-100 p-8">
<h2 className="text-xl font-semibold text-gray-800 mb-6">Registrieren</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Einladungscode
</label>
<input
type="text"
value={inviteToken}
onChange={e => setInviteToken(e.target.value)}
placeholder="Einladungscode vom Admin"
required
readOnly={!!prefillToken}
className="w-full px-4 py-3 border border-gray-300 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors read-only:bg-gray-50 read-only:text-gray-500"
/>
{prefillToken && (
<p className="text-xs text-green-600 mt-1">Code aus Einladungslink übernommen</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Benutzername
</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="Benutzername wählen"
required
minLength={3}
className="w-full px-4 py-3 border border-gray-300 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Passwort
</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Mindestens 6 Zeichen"
required
minLength={6}
className="w-full px-4 py-3 border border-gray-300 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-xl">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white py-3 rounded-xl font-semibold transition-colors shadow-lg shadow-blue-600/25 hover:shadow-blue-600/40"
>
{loading ? 'Laden...' : 'Registrieren'}
</button>
</form>
</div>
</div>
</div>
);
}