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

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
# WICHTIG: Erstelle eine Kopie als ".env" und ändere die Werte!
# Geheimnis für JWT-Tokens (mind. 32 Zeichen)
JWT_SECRET=super-langes-geheimnis-hier-aendern
# Admin-Account (wird beim ersten Start erstellt)
ADMIN_USER=admin
ADMIN_PASS=DEIN-SICHERES-PASSWORT
# Ollama KI-Modell (optional, Standard: mistral)
# OLLAMA_MODEL=mistral

249
README.md Normal file
View File

@@ -0,0 +1,249 @@
# TikTok Rezepte
Deine Rezepte, immer griffbereit. Speichere, organisiere und durchsuche Rezepte aus TikTok-Videos. Mit KI-gestützter Extraktion von Zutaten und Zubereitungsschritten.
## Features
- **TikTok-Extraktion** — URL einfügen, Rezept wird automatisch erkannt
- **KI-Unterstützung** — Ollama extrahiert Zutaten und Schritte aus dem Video-Titel (optional)
- **Suche & Filter** — Rezepte nach Name, Zutaten oder Kategorie durchsuchen
- **Einkaufsliste** — Zutaten mit Haken abhaken beim Kochen
- **Schritt für Schritt** — Zubereitung mit Fortschrittsanzeige
- **Invite-System** — Geschlossene Registrierung, nur eingeladene Nutzer
- **Admin-Dashboard** — Einladungen verwalten, Nutzer einsehen und löschen
- **Profil** — Username und Passwort ändern
- **Responsive** — Funktioniert auf Desktop, Tablet und Handy
## Screenshots
| Startseite | Rezept hinzufügen | Admin Dashboard |
|---|---|---|
| Rezepte als Karten mit Suche und Kategorien | TikTok-URL + KI-Extraktion | Einladungen + Nutzerverwaltung |
## Tech Stack
| Bereich | Technologie |
|---|---|
| Frontend | React 18, Vite, Tailwind CSS, React Router |
| Backend | Node.js, Express, bcryptjs, jsonwebtoken |
| Datenbank | JSON-Datei (keine Datenbank-Installation nötig) |
| KI | Ollama mit Mistral-Modell (optional) |
| Deployment | Docker Compose (Nginx + Node.js + Ollama) |
## Projektstruktur
```
tiktok-rezepte/
├── docker-compose.yml # Docker Deployment
├── .env.example # Umgebungsvariablen-Vorlage
├── backend/ # Express API Server
│ ├── Dockerfile
│ ├── server.js # Einstiegspunkt
│ ├── db.js # JSON-Datenbank
│ ├── routes/
│ │ ├── auth.js # Authentifizierung & Benutzerverwaltung
│ │ └── recipes.js # Rezepte CRUD + TikTok-Extraktion
│ ├── services/
│ │ ├── tiktok.js # TikTok oEmbed API
│ │ └── ai.js # Ollama KI-Integration
│ └── prompts/
│ └── extractRecipe.js # System-Prompt für KI-Extraktion
└── frontend/ # React SPA
├── Dockerfile
├── nginx.conf # Nginx-Konfiguration
├── vite.config.js # Vite + Dev-Server Proxy
└── src/
├── App.jsx # Routen & Navigation
├── context/
│ └── AuthContext.jsx # Auth-Zustand (JWT)
├── pages/
│ ├── Home.jsx # Rezeptübersicht mit Suche
│ ├── AddRecipe.jsx # TikTok-URL + KI-Extraktion
│ ├── RecipeDetail.jsx # Rezept ansehen/bearbeiten
│ ├── Login.jsx # Anmeldung
│ ├── Register.jsx # Registrierung (Invite)
│ ├── Profile.jsx # Profil bearbeiten
│ └── Admin.jsx # Admin Dashboard
└── components/
├── RecipeCard.jsx # Rezept-Karte
├── IngredientList.jsx # Zutaten (Lesen/Bearbeiten)
├── StepList.jsx # Schritte (Lesen/Bearbeiten)
└── CategoryBadge.jsx # Kategorie-Badge
```
## Schnellstart (lokal)
### Voraussetzungen
- Node.js 18+
- (Optional) Ollama für KI-Extraktion
### Installation
```bash
# Repository klonen
git clone <deine-gitea-url>/dein-user/tiktok-rezepte.git
cd tiktok-rezepte
# Backend installieren
cd backend
npm install
# Frontend installieren
cd ../frontend
npm install
```
### Starten
```bash
# Backend starten (Port 3001)
cd backend
npm run dev
# Frontend starten (Port 5173, in anderem Terminal)
cd frontend
npm run dev
```
Oder über das Root-Projekt:
```bash
npm run dev
```
Die App ist dann unter `http://localhost:5173` erreichbar.
### Standard-Login
| Benutzername | Passwort |
|---|---|
| `admin` | `admin123` |
> **Wichtig:** Passwort nach dem ersten Login ändern!
### Ollama (optional)
Für KI-gestützte Rezept-Extraktion:
```bash
# Ollama installieren (https://ollama.com)
ollama pull mistral
```
Ohne Ollama funktioniert die App vollständig — TikTok-Titel werden übernommen, Zutaten und Schritte werden manuell ausgefüllt.
## Docker Deployment
### Voraussetzungen
- Docker & Docker Compose
- (Optional) Synology NAS mit Container Manager
### Schritte
```bash
# 1. .env-Datei anlegen
cp .env.example .env
# Werte in .env ändern (JWT_SECRET, ADMIN_PASS)!
# 2. Docker starten
docker compose up -d
# 3. Ollama Modell herunterladen
docker exec -it <ollama-container> ollama pull mistral
```
### Umgebungsvariablen
| Variable | Beschreibung | Standard |
|---|---|---|
| `JWT_SECRET` | Geheimnis für JWT-Tokens | `tiktok-rezepte-secret-key-...` |
| `ADMIN_USER` | Initialer Admin-Username | `admin` |
| `ADMIN_PASS` | Initial admin password | `admin123` |
| `PORT` | Backend-Port | `3001` |
| `OLLAMA_URL` | Ollama API-URL | `http://localhost:11434` |
| `OLLAMA_MODEL` | KI-Modell | `mistral` |
| `DB_PATH` | Pfad zur Datenbank-Datei | `./data.json` |
### Synology NAS mit Reverse Proxy
1. Code nach Gitea pushen
2. Container Manager → Projekt → "Aus Git-Repository"
3. Gitea-URL angeben, `docker-compose.yml` wählen
4. Environment-Variablen setzen
5. Deploy starten
6. Systemsteuerung → Login-Dienste → Reverse Proxy einrichten
7. SSL-Zertifikat über Let's Encrypt beantragen
## API Endpoints
### Authentifizierung (`/api/auth`)
| Methode | Pfad | Beschreibung |
|---|---|---|
| POST | `/register` | Registrierung (Invite-Token erforderlich) |
| POST | `/login` | Anmeldung, gibt JWT zurück |
| GET | `/me` | Aktuellen Benutzer abrufen |
| POST | `/invite` | Einladung erstellen (Admin) |
| GET | `/invites` | Alle Einladungen anzeigen (Admin) |
| GET | `/users` | Alle Benutzer anzeigen (Admin) |
| DELETE | `/users/:id` | Benutzer löschen (Admin) |
| PUT | `/credentials` | Username/Passwort ändern |
### Rezepte (`/api/recipes`)
| Methode | Pfad | Beschreibung |
|---|---|---|
| GET | `/` | Rezepte auflisten (optional: `?search=&category=`) |
| GET | `/categories` | Kategorien mit Anzahl |
| GET | `/ai-status` | Ollama-Verfügbarkeit prüfen |
| GET | `/:id` | Einzelnes Rezept |
| POST | `/` | Rezept erstellen |
| PUT | `/:id` | Rezept aktualisieren |
| DELETE | `/:id` | Rezept löschen |
| POST | `/from-tiktok` | Rezept aus TikTok-URL extrahieren |
## Kategorien
| Kategorie | Emoji |
|---|---|
| Frühstück | 🍳 |
| Hauptgericht | 🍽️ |
| Dessert | 🍰 |
| Snack | 🍿 |
| Getränk | ☕ |
| Sonstiges | 📋 |
## Wartung
### Daten sichern
Die gesamte Datenbank liegt in einer einzigen Datei:
```bash
# Bei Docker:
cp data/data.json data/data.json.backup
# Bei lokaler Entwicklung:
cp backend/data.json backend/data.json.backup
```
### Datenbank zurücksetzen
```bash
# Vorsicht: Alle Rezepte, User und Einladungen gehen verloren!
rm data/data.json
# Beim Neustart wird die Admin-Config neu erstellt
```
### Logs anzeigen
```bash
docker compose logs -f backend
docker compose logs -f nginx
```
## Lizenz
MIT

4
backend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
data.json
recipes.db*
.env

12
backend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3001
CMD ["node", "server.js"]

42
backend/data.json Normal file
View File

@@ -0,0 +1,42 @@
{
"users": [
{
"id": 1,
"username": "Duda",
"password": "$2a$10$CZau8N7du7NSSn5Vvzb7dOma089slkoX.2rqjryod3AlZ2DOY9uAe",
"is_admin": 1,
"created_at": "2026-08-25 15:45:56"
},
{
"id": 3,
"username": "admin",
"password": "$2a$10$nEk2A5poqbBKGYHOeJ/Bkub1LqTohLeanHNhxHbXMtIRQ3.mb4jwS",
"is_admin": 1,
"created_at": "2026-08-25 18:18:57"
}
],
"recipes": [],
"invite_tokens": [
{
"id": 1,
"token": "50015008cb92902ba41e716702b90467",
"created_by": 1,
"used_by": null,
"created_at": "2026-08-25 15:50:10",
"used_at": null
},
{
"id": 2,
"token": "1c781250ad364d850b6e9084d3fd3d9c",
"created_by": 1,
"used_by": 2,
"created_at": "2026-08-25 16:02:44",
"used_at": "2026-08-25 16:03:26"
}
],
"_counters": {
"users": 3,
"recipes": 1,
"invite_tokens": 2
}
}

202
backend/db.js Normal file
View File

@@ -0,0 +1,202 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_FILE = process.env.DB_PATH || join(__dirname, 'data.json');
function loadDB() {
if (existsSync(DB_FILE)) {
return JSON.parse(readFileSync(DB_FILE, 'utf-8'));
}
return { users: [], recipes: [], invite_tokens: [], _counters: { users: 0, recipes: 0, invite_tokens: 0 } };
}
function saveDB(data) {
writeFileSync(DB_FILE, JSON.stringify(data, null, 2));
}
let db = loadDB();
function nextId(table) {
db._counters[table] = (db._counters[table] || 0) + 1;
return db._counters[table];
}
function now() {
return new Date().toISOString().replace('T', ' ').slice(0, 19);
}
export function getDb() {
return db;
}
export function getAllRecipes(search, category) {
let results = [...db.recipes];
if (search) {
const term = search.toLowerCase();
results = results.filter(r =>
(r.name && r.name.toLowerCase().includes(term)) ||
(r.description && r.description.toLowerCase().includes(term)) ||
(r.ingredients && r.ingredients.toLowerCase().includes(term))
);
}
if (category && category !== 'Alle') {
results = results.filter(r => r.category === category);
}
results.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
return results;
}
export function getRecipeById(id) {
return db.recipes.find(r => r.id === Number(id)) || undefined;
}
export function createRecipe(recipe) {
const id = nextId('recipes');
const nowStr = now();
const row = {
id,
name: recipe.name,
description: recipe.description || '',
ingredients: JSON.stringify(recipe.ingredients || []),
steps: JSON.stringify(recipe.steps || []),
category: recipe.category || 'Sonstiges',
tiktok_url: recipe.tiktok_url || null,
tiktok_author: recipe.tiktok_author || null,
thumbnail_url: recipe.thumbnail_url || null,
user_id: recipe.user_id || null,
created_at: nowStr,
updated_at: nowStr,
};
db.recipes.push(row);
saveDB(db);
return row;
}
export function updateRecipe(id, recipe) {
const idx = db.recipes.findIndex(r => r.id === Number(id));
if (idx === -1) return undefined;
db.recipes[idx] = {
...db.recipes[idx],
name: recipe.name,
description: recipe.description || '',
ingredients: JSON.stringify(recipe.ingredients || []),
steps: JSON.stringify(recipe.steps || []),
category: recipe.category || 'Sonstiges',
tiktok_url: recipe.tiktok_url || null,
tiktok_author: recipe.tiktok_author || null,
thumbnail_url: recipe.thumbnail_url || null,
updated_at: now(),
};
saveDB(db);
return db.recipes[idx];
}
export function deleteRecipe(id) {
const idx = db.recipes.findIndex(r => r.id === Number(id));
if (idx === -1) return;
db.recipes.splice(idx, 1);
saveDB(db);
}
export function getCategories() {
const counts = {};
for (const r of db.recipes) {
counts[r.category] = (counts[r.category] || 0) + 1;
}
return Object.entries(counts)
.map(([category, count]) => ({ category, count }))
.sort((a, b) => b.count - a.count);
}
export function getUserByUsername(username) {
return db.users.find(u => u.username === username) || undefined;
}
export function getUserById(id) {
return db.users.find(u => u.id === Number(id)) || undefined;
}
export function createUser(username, hashedPassword, isAdmin = false) {
const id = nextId('users');
const user = {
id,
username,
password: hashedPassword,
is_admin: isAdmin ? 1 : 0,
created_at: now(),
};
db.users.push(user);
saveDB(db);
return { id, username, is_admin: user.is_admin, created_at: user.created_at };
}
export function updateUserCredentials(userId, { username, password }) {
const user = db.users.find(u => u.id === Number(userId));
if (!user) return null;
if (username) user.username = username;
if (password) user.password = password;
saveDB(db);
return { id: user.id, username: user.username, is_admin: user.is_admin, created_at: user.created_at };
}
export function createInviteToken(token, createdBy) {
const id = nextId('invite_tokens');
db.invite_tokens.push({
id,
token,
created_by: createdBy,
used_by: null,
created_at: now(),
used_at: null,
});
saveDB(db);
}
export function getInviteToken(token) {
return db.invite_tokens.find(i => i.token === token) || undefined;
}
export function useInviteToken(token, userId) {
const inv = db.invite_tokens.find(i => i.token === token);
if (inv) {
inv.used_by = userId;
inv.used_at = now();
saveDB(db);
}
}
export function getInvitesByUser(userId) {
return db.invite_tokens
.filter(i => i.created_by === Number(userId))
.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''))
.map(i => {
const usedByUser = i.used_by ? db.users.find(u => u.id === i.used_by) : null;
return { ...i, used_by_username: usedByUser ? usedByUser.username : null };
});
}
export function getAllUsers() {
return db.users
.map(u => ({ id: u.id, username: u.username, is_admin: u.is_admin, created_at: u.created_at }))
.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
}
export function deleteUser(id) {
const idx = db.users.findIndex(u => u.id === Number(id));
if (idx === -1) return false;
db.users.splice(idx, 1);
db.invite_tokens = db.invite_tokens.filter(i => i.created_by !== Number(id));
saveDB(db);
return true;
}
export function getInviteStats() {
const total = db.invite_tokens.length;
const used = db.invite_tokens.filter(i => i.used_by !== null).length;
return { total, used, available: total - used };
}

1073
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
backend/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "tiktok-recipe-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"node-fetch": "^3.3.2"
}
}

View File

@@ -0,0 +1,25 @@
export const SYSTEM_PROMPT = `Du bist ein Assistent der aus TikTok-Videobeschreibungen Rezepte extrahiert.
Du antwortest NUR mit validem JSON, keinem anderen Text.
Das JSON muss dieses Schema haben:
{
"name": "Name des Rezepts",
"description": "Kurze Beschreibung des Gerichts",
"category": "Frühstück|Hauptgericht|Dessert|Snack|Getränk|Sonstiges",
"ingredients": [
{ "name": "Zutat", "amount": "Menge", "unit": "Einheit" }
],
"steps": [
"Schritt 1 Beschreibung",
"Schritt 2 Beschreibung"
]
}
Regeln:
- Extrahiere alle Zutaten mit Menge und Einheit aus dem Text
- Wenn keine Menge angegeben ist, setze amount auf ""
- Wenn keine Einheit angegeben ist, setze unit auf ""
- Kategorie muss eines dieser sein: Frühstück, Hauptgericht, Dessert, Snack, Getränk, Sonstiges
- steps ist eine sortierte Liste der Zubereitungsschritte
- Wenn du die Zubereitung nicht erkennen kannst, gib einen sinnvollen Standard basierend auf dem Rezeptnamen
- Gib NUR das JSON-Objekt zurück, kein Markdown, kein Code-Block`;

BIN
backend/recipes.db-shm Normal file

Binary file not shown.

BIN
backend/recipes.db-wal Normal file

Binary file not shown.

214
backend/routes/auth.js Normal file
View File

@@ -0,0 +1,214 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import {
getUserByUsername, getUserById, createUser, updateUserCredentials, deleteUser,
createInviteToken, getInviteToken, useInviteToken,
getInvitesByUser, getAllUsers, getInviteStats
} from '../db.js';
const router = Router();
const JWT_SECRET = process.env.JWT_SECRET || 'tiktok-rezepte-secret-key-change-in-production';
const JWT_EXPIRES_IN = '7d';
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Nicht authentifiziert' });
}
try {
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, JWT_SECRET);
const user = getUserByUsername(decoded.username);
if (!user) return res.status(401).json({ error: 'Benutzer nicht gefunden' });
req.user = user;
next();
} catch {
res.status(401).json({ error: 'Ungültiges Token' });
}
}
function adminMiddleware(req, res, next) {
if (!req.user || !req.user.is_admin) {
return res.status(403).json({ error: 'Keine Berechtigung' });
}
next();
}
router.post('/register', async (req, res) => {
try {
const { username, password, inviteToken } = req.body;
if (!username || !password || !inviteToken) {
return res.status(400).json({ error: 'Benutzername, Passwort und Einladungscode erforderlich' });
}
if (username.length < 3) {
return res.status(400).json({ error: 'Benutzername muss mindestens 3 Zeichen lang sein' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
}
const invite = getInviteToken(inviteToken);
if (!invite) {
return res.status(400).json({ error: 'Ungültiger Einladungscode' });
}
if (invite.used_by) {
return res.status(400).json({ error: 'Einladungscode wurde bereits verwendet' });
}
const existing = getUserByUsername(username);
if (existing) {
return res.status(409).json({ error: 'Benutzername ist bereits vergeben' });
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = createUser(username, hashedPassword);
useInviteToken(inviteToken, user.id);
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
res.status(201).json({ user, token });
} catch (error) {
res.status(500).json({ error: 'Registrierung fehlgeschlagen' });
}
});
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' });
}
const user = getUserByUsername(username);
if (!user) {
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
}
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
res.json({
user: { id: user.id, username: user.username, is_admin: !!user.is_admin, created_at: user.created_at },
token,
});
} catch (error) {
res.status(500).json({ error: 'Anmeldung fehlgeschlagen' });
}
});
router.get('/me', (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Nicht authentifiziert' });
}
try {
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, JWT_SECRET);
const user = getUserByUsername(decoded.username);
if (!user) return res.status(401).json({ error: 'Benutzer nicht gefunden' });
res.json({ id: user.id, username: user.username, is_admin: !!user.is_admin, created_at: user.created_at });
} catch {
res.status(401).json({ error: 'Ungültiges Token' });
}
});
router.post('/invite', authMiddleware, adminMiddleware, (req, res) => {
try {
const { count = 1 } = req.body;
const amount = Math.min(Math.max(parseInt(count) || 1, 1), 20);
const tokens = [];
for (let i = 0; i < amount; i++) {
const token = crypto.randomBytes(16).toString('hex');
createInviteToken(token, req.user.id);
tokens.push(token);
}
res.json({ tokens });
} catch (error) {
res.status(500).json({ error: 'Einladung fehlgeschlagen' });
}
});
router.get('/invites', authMiddleware, adminMiddleware, (req, res) => {
try {
const invites = getInvitesByUser(req.user.id);
const stats = getInviteStats();
res.json({ invites, stats });
} catch (error) {
res.status(500).json({ error: 'Fehler beim Laden der Einladungen' });
}
});
router.get('/users', authMiddleware, adminMiddleware, (req, res) => {
try {
const users = getAllUsers();
res.json(users);
} catch (error) {
res.status(500).json({ error: 'Fehler beim Laden der Benutzer' });
}
});
router.delete('/users/:id', authMiddleware, adminMiddleware, (req, res) => {
try {
const userId = Number(req.params.id);
if (userId === req.user.id) {
return res.status(400).json({ error: 'Du kannst dich nicht selbst löschen' });
}
const user = getUserById(userId);
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
deleteUser(userId);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Fehler beim Löschen' });
}
});
router.put('/credentials', authMiddleware, async (req, res) => {
try {
const { currentPassword, newUsername, newPassword } = req.body;
const user = getUserByUsername(req.user.username);
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
if (currentPassword) {
const valid = await bcrypt.compare(currentPassword, user.password);
if (!valid) return res.status(401).json({ error: 'Aktuelles Passwort ist falsch' });
}
if (newUsername && newUsername !== user.username) {
const exists = getUserByUsername(newUsername);
if (exists) return res.status(409).json({ error: 'Benutzername ist bereits vergeben' });
}
const updates = {};
if (newUsername) updates.username = newUsername;
if (newPassword) {
if (newPassword.length < 6) return res.status(400).json({ error: 'Neues Passwort muss mindestens 6 Zeichen lang sein' });
updates.password = await bcrypt.hash(newPassword, 10);
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: 'Keine Änderungen angegeben' });
}
const updated = updateUserCredentials(user.id, updates);
const token = jwt.sign({ id: updated.id, username: updated.username }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
res.json({ user: updated, token });
} catch (error) {
res.status(500).json({ error: 'Fehler beim Aktualisieren' });
}
});
export default router;

91
backend/routes/recipes.js Normal file
View File

@@ -0,0 +1,91 @@
import { Router } from 'express';
import {
getAllRecipes, getRecipeById, createRecipe,
updateRecipe, deleteRecipe, getCategories
} from '../db.js';
import { getTikTokData } from '../services/tiktok.js';
import { isOllamaAvailable, extractRecipe } from '../services/ai.js';
const router = Router();
router.get('/', (req, res) => {
const { search, category } = req.query;
const recipes = getAllRecipes(search, category);
res.json(recipes.map(r => ({
...r,
ingredients: JSON.parse(r.ingredients),
steps: JSON.parse(r.steps),
})));
});
router.get('/categories', (req, res) => {
res.json(getCategories());
});
router.get('/ai-status', async (req, res) => {
const available = await isOllamaAvailable();
res.json({ ollama: available });
});
router.get('/:id', (req, res) => {
const recipe = getRecipeById(req.params.id);
if (!recipe) return res.status(404).json({ error: 'Rezept nicht gefunden' });
res.json({
...recipe,
ingredients: JSON.parse(recipe.ingredients),
steps: JSON.parse(recipe.steps),
});
});
router.post('/', (req, res) => {
const recipe = createRecipe(req.body);
res.status(201).json(recipe);
});
router.put('/:id', (req, res) => {
const existing = getRecipeById(req.params.id);
if (!existing) return res.status(404).json({ error: 'Rezept nicht gefunden' });
const updated = updateRecipe(req.params.id, req.body);
res.json(updated);
});
router.delete('/:id', (req, res) => {
const existing = getRecipeById(req.params.id);
if (!existing) return res.status(404).json({ error: 'Rezept nicht gefunden' });
deleteRecipe(req.params.id);
res.json({ success: true });
});
router.post('/from-tiktok', async (req, res) => {
try {
const { url } = req.body;
if (!url) return res.status(400).json({ error: 'URL erforderlich' });
const tiktokData = await getTikTokData(url);
const ollamaAvailable = await isOllamaAvailable();
let extracted = null;
let aiStatus = ollamaAvailable ? 'available' : 'unavailable';
if (ollamaAvailable && tiktokData.title) {
try {
extracted = await extractRecipe(tiktokData.title);
aiStatus = 'success';
} catch (e) {
console.error('Ollama Extraktion fehlgeschlagen:', e.message);
aiStatus = 'error';
}
}
res.json({
tiktok: tiktokData,
ollama: ollamaAvailable,
aiStatus,
extracted,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;

30
backend/server.js Normal file
View File

@@ -0,0 +1,30 @@
import express from 'express';
import cors from 'cors';
import bcrypt from 'bcryptjs';
import recipesRouter from './routes/recipes.js';
import authRouter from './routes/auth.js';
import { getUserByUsername, createUser } from './db.js';
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json());
app.use('/api/auth', authRouter);
app.use('/api/recipes', recipesRouter);
const adminUser = process.env.ADMIN_USER || 'admin';
const adminPass = process.env.ADMIN_PASS || 'admin123';
const existing = getUserByUsername(adminUser);
if (!existing) {
const hashed = bcrypt.hashSync(adminPass, 10);
createUser(adminUser, hashed, true);
console.log(`Admin erstellt: ${adminUser} / ${adminPass}`);
console.log('WICHTIG: Passwort nach dem ersten Login ändern!');
}
app.listen(PORT, () => {
console.log(`Backend läuft auf http://localhost:${PORT}`);
});

39
backend/services/ai.js Normal file
View File

@@ -0,0 +1,39 @@
import { SYSTEM_PROMPT } from '../prompts/extractRecipe.js';
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
const MODEL = process.env.OLLAMA_MODEL || 'mistral';
export async function isOllamaAvailable() {
try {
const res = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(3000) });
return res.ok;
} catch {
return false;
}
}
export async function extractRecipe(description) {
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: MODEL,
system: SYSTEM_PROMPT,
prompt: `Extrahiere ein Rezept aus dieser TikTok-Videobeschreibung:\n\n${description}`,
stream: false,
}),
});
if (!response.ok) {
throw new Error(`Ollama Fehler: ${response.status}`);
}
const data = await response.json();
let text = data.response.trim();
if (text.startsWith('```')) {
text = text.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
}
return JSON.parse(text);
}

View File

@@ -0,0 +1,20 @@
import fetch from 'node-fetch';
export async function getTikTokData(url) {
const oembedUrl = `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`;
const response = await fetch(oembedUrl);
if (!response.ok) {
throw new Error(`TikTok oEmbed Fehler: ${response.status}`);
}
const data = await response.json();
return {
title: data.title || '',
author: data.author_name || '',
authorUrl: data.author_url || '',
thumbnail: data.thumbnail_url || '',
embedHtml: data.html || '',
};
}

33
docker-compose.yml Normal file
View File

@@ -0,0 +1,33 @@
services:
nginx:
build: ./frontend
ports:
- "80:80"
depends_on:
- backend
restart: unless-stopped
backend:
build: ./backend
environment:
- PORT=3001
- DB_PATH=/app/data/data.json
- OLLAMA_URL=http://ollama:11434
- OLLAMA_MODEL=${OLLAMA_MODEL:-mistral}
- JWT_SECRET=${JWT_SECRET}
- ADMIN_USER=${ADMIN_USER:-admin}
- ADMIN_PASS=${ADMIN_PASS}
volumes:
- ./data:/app/data
depends_on:
- ollama
restart: unless-stopped
ollama:
image: ollama/ollama
volumes:
- ollama_data:/root/.ollama
restart: unless-stopped
volumes:
ollama_data:

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

257
package-lock.json generated Normal file
View File

@@ -0,0 +1,257 @@
{
"name": "tiktok-rezepte",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tiktok-rezepte",
"version": "1.0.0",
"dependencies": {
"concurrently": "^10.0.5"
}
},
"node_modules/ansi-regex": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/concurrently": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz",
"integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==",
"license": "MIT",
"dependencies": {
"chalk": "5.6.2",
"rxjs": "7.8.2",
"shell-quote": "1.9.0",
"supports-color": "10.2.2",
"tree-kill": "1.2.2",
"yargs": "18.0.0"
},
"bin": {
"conc": "dist/bin/index.js",
"concurrently": "dist/bin/index.js"
},
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^7.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
}
}
}

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "tiktok-rezepte",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"dev:backend": "cd backend && npm run dev",
"dev:frontend": "cd frontend && npm run dev",
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
"start:backend": "cd backend && npm start",
"start:frontend": "cd frontend && npm run build && npm run preview",
"install:all": "cd backend && npm install && cd ../frontend && npm install"
},
"dependencies": {
"concurrently": "^10.0.5"
}
}