init push
This commit is contained in:
104
frontend/src/App.jsx
Normal file
104
frontend/src/App.jsx
Normal 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
60
frontend/src/api.js
Normal 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
70
frontend/src/auth.js
Normal 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}` },
|
||||
});
|
||||
}
|
||||
28
frontend/src/components/CategoryBadge.jsx
Normal file
28
frontend/src/components/CategoryBadge.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
frontend/src/components/IngredientList.jsx
Normal file
82
frontend/src/components/IngredientList.jsx
Normal 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">×</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>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/RecipeCard.jsx
Normal file
35
frontend/src/components/RecipeCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
frontend/src/components/StepList.jsx
Normal file
76
frontend/src/components/StepList.jsx
Normal 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">↑</button>
|
||||
<button onClick={() => moveStep(i, 1)} className="text-gray-400 hover:text-gray-600 text-xs">↓</button>
|
||||
<button onClick={() => removeStep(i)} className="text-red-400 hover:text-red-600 text-xs">×</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>
|
||||
);
|
||||
}
|
||||
46
frontend/src/context/AuthContext.jsx
Normal file
46
frontend/src/context/AuthContext.jsx
Normal 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
5
frontend/src/index.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
}
|
||||
13
frontend/src/main.jsx
Normal file
13
frontend/src/main.jsx
Normal 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>
|
||||
);
|
||||
194
frontend/src/pages/AddRecipe.jsx
Normal file
194
frontend/src/pages/AddRecipe.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
215
frontend/src/pages/Admin.jsx
Normal file
215
frontend/src/pages/Admin.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
frontend/src/pages/Home.jsx
Normal file
91
frontend/src/pages/Home.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
frontend/src/pages/Login.jsx
Normal file
90
frontend/src/pages/Login.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
frontend/src/pages/Profile.jsx
Normal file
100
frontend/src/pages/Profile.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
frontend/src/pages/RecipeDetail.jsx
Normal file
174
frontend/src/pages/RecipeDetail.jsx
Normal 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">
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
114
frontend/src/pages/Register.jsx
Normal file
114
frontend/src/pages/Register.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user