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

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>
);
}