init push
This commit is contained in:
11
.env.example
Normal file
11
.env.example
Normal 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
249
README.md
Normal 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
4
backend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
data.json
|
||||||
|
recipes.db*
|
||||||
|
.env
|
||||||
12
backend/Dockerfile
Normal file
12
backend/Dockerfile
Normal 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
42
backend/data.json
Normal 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
202
backend/db.js
Normal 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
1073
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
backend/package.json
Normal file
16
backend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/prompts/extractRecipe.js
Normal file
25
backend/prompts/extractRecipe.js
Normal 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
BIN
backend/recipes.db-shm
Normal file
Binary file not shown.
BIN
backend/recipes.db-wal
Normal file
BIN
backend/recipes.db-wal
Normal file
Binary file not shown.
214
backend/routes/auth.js
Normal file
214
backend/routes/auth.js
Normal 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
91
backend/routes/recipes.js
Normal 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
30
backend/server.js
Normal 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
39
backend/services/ai.js
Normal 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);
|
||||||
|
}
|
||||||
20
backend/services/tiktok.js
Normal file
20
backend/services/tiktok.js
Normal 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
33
docker-compose.yml
Normal 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
18
frontend/Dockerfile
Normal 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;"]
|
||||||
1
frontend/dist/assets/index-CV-7lATv.css
vendored
Normal file
1
frontend/dist/assets/index-CV-7lATv.css
vendored
Normal file
File diff suppressed because one or more lines are too long
67
frontend/dist/assets/index-CeRXDRLQ.js
vendored
Normal file
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
13
frontend/dist/index.html
vendored
Normal 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
12
frontend/index.html
Normal 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
23
frontend/nginx.conf
Normal 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
104
frontend/src/App.jsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Routes, Route, Link, Navigate } from 'react-router-dom';
|
||||||
|
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
|
import Home from './pages/Home';
|
||||||
|
import AddRecipe from './pages/AddRecipe';
|
||||||
|
import RecipeDetail from './pages/RecipeDetail';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Register from './pages/Register';
|
||||||
|
import Admin from './pages/Admin';
|
||||||
|
import Profile from './pages/Profile';
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
if (loading) return <div className="text-center py-12 text-gray-400">Laden...</div>;
|
||||||
|
if (!user) return <Navigate to="/login" />;
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminRoute({ children }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
if (loading) return <div className="text-center py-12 text-gray-400">Laden...</div>;
|
||||||
|
if (!user) return <Navigate to="/login" />;
|
||||||
|
if (!user.is_admin) return <Navigate to="/" />;
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="*" element={<Navigate to="/login" />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<nav className="bg-white shadow-sm border-b border-gray-200">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||||
|
<Link to="/" className="text-xl font-bold text-blue-600">
|
||||||
|
TikTok Rezepte
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
to="/add"
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
+ Rezept hinzufügen
|
||||||
|
</Link>
|
||||||
|
{user.is_admin && (
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to="/profile"
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Profil
|
||||||
|
</Link>
|
||||||
|
<span className="text-sm text-gray-400">|</span>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{user.username}
|
||||||
|
{user.is_admin && <span className="ml-1 text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded-full">Admin</span>}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="text-sm text-gray-400 hover:text-gray-600 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="max-w-6xl mx-auto px-4 py-6">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
|
||||||
|
<Route path="/add" element={<ProtectedRoute><AddRecipe /></ProtectedRoute>} />
|
||||||
|
<Route path="/recipe/:id" element={<ProtectedRoute><RecipeDetail /></ProtectedRoute>} />
|
||||||
|
<Route path="/admin" element={<AdminRoute><Admin /></AdminRoute>} />
|
||||||
|
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
||||||
|
<Route path="/login" element={<Navigate to="/" />} />
|
||||||
|
<Route path="/register" element={<Navigate to="/" />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AppContent />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/api.js
Normal file
60
frontend/src/api.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
const API_BASE = '/api/recipes';
|
||||||
|
|
||||||
|
async function apiFetch(url, options = {}) {
|
||||||
|
const res = await fetch(url, options);
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `Server-Fehler (${res.status})`;
|
||||||
|
try { msg = JSON.parse(text).error || msg; } catch {}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
if (!text) throw new Error('Leere Antwort vom Server – Backend läuft nicht?');
|
||||||
|
return JSON.parse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRecipes(search, category) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
if (category && category !== 'Alle') params.set('category', category);
|
||||||
|
return apiFetch(`${API_BASE}?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRecipe(id) {
|
||||||
|
return apiFetch(`${API_BASE}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveRecipe(recipe) {
|
||||||
|
return apiFetch(API_BASE, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(recipe),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRecipe(id, recipe) {
|
||||||
|
return apiFetch(`${API_BASE}/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(recipe),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRecipe(id) {
|
||||||
|
return apiFetch(`${API_BASE}/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractFromTikTok(url) {
|
||||||
|
return apiFetch(`${API_BASE}/from-tiktok`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCategories() {
|
||||||
|
return apiFetch(`${API_BASE}/categories`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkAiStatus() {
|
||||||
|
return apiFetch(`${API_BASE}/ai-status`);
|
||||||
|
}
|
||||||
70
frontend/src/auth.js
Normal file
70
frontend/src/auth.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
const AUTH_BASE = '/api/auth';
|
||||||
|
|
||||||
|
async function authFetch(url, options = {}) {
|
||||||
|
const res = await fetch(url, options);
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) throw new Error('Leere Antwort vom Server – Backend läuft nicht?');
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
if (!res.ok) throw new Error(data.error || `Server-Fehler (${res.status})`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginUser(username, password) {
|
||||||
|
return authFetch(`${AUTH_BASE}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerUser(username, password, inviteToken) {
|
||||||
|
return authFetch(`${AUTH_BASE}/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password, inviteToken }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCurrentUser(token) {
|
||||||
|
const res = await fetch(`${AUTH_BASE}/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok || !text) throw new Error('Nicht authentifiziert');
|
||||||
|
return JSON.parse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInvites(token, count = 1) {
|
||||||
|
return authFetch(`${AUTH_BASE}/invite`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ count }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInvites(token) {
|
||||||
|
return authFetch(`${AUTH_BASE}/invites`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUsers(token) {
|
||||||
|
return authFetch(`${AUTH_BASE}/users`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCredentials(token, { currentPassword, newUsername, newPassword }) {
|
||||||
|
return authFetch(`${AUTH_BASE}/credentials`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ currentPassword, newUsername, newPassword }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(token, userId) {
|
||||||
|
return authFetch(`${AUTH_BASE}/users/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
28
frontend/src/components/CategoryBadge.jsx
Normal file
28
frontend/src/components/CategoryBadge.jsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const CATEGORY_COLORS = {
|
||||||
|
'Frühstück': 'bg-yellow-100 text-yellow-800',
|
||||||
|
'Hauptgericht': 'bg-red-100 text-red-800',
|
||||||
|
'Dessert': 'bg-pink-100 text-pink-800',
|
||||||
|
'Snack': 'bg-green-100 text-green-800',
|
||||||
|
'Getränk': 'bg-blue-100 text-blue-800',
|
||||||
|
'Sonstiges': 'bg-gray-100 text-gray-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_ICONS = {
|
||||||
|
'Frühstück': '🍳',
|
||||||
|
'Hauptgericht': '🍽️',
|
||||||
|
'Dessert': '🍰',
|
||||||
|
'Snack': '🍿',
|
||||||
|
'Getränk': '🥤',
|
||||||
|
'Sonstiges': '📋',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CategoryBadge({ category }) {
|
||||||
|
const colorClass = CATEGORY_COLORS[category] || CATEGORY_COLORS['Sonstiges'];
|
||||||
|
const icon = CATEGORY_ICONS[category] || CATEGORY_ICONS['Sonstiges'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`text-xs font-medium px-2 py-1 rounded-full whitespace-nowrap ${colorClass}`}>
|
||||||
|
{icon} {category}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
frontend/src/components/IngredientList.jsx
Normal file
82
frontend/src/components/IngredientList.jsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export default function IngredientList({ ingredients, editable, onChange }) {
|
||||||
|
const [checked, setChecked] = useState({});
|
||||||
|
|
||||||
|
const toggle = (i) => setChecked(prev => ({ ...prev, [i]: !prev[i] }));
|
||||||
|
|
||||||
|
const updateIngredient = (i, field, value) => {
|
||||||
|
const updated = ingredients.map((ing, idx) =>
|
||||||
|
idx === i ? { ...ing, [field]: value } : ing
|
||||||
|
);
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addIngredient = () => {
|
||||||
|
onChange([...ingredients, { name: '', amount: '', unit: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeIngredient = (i) => {
|
||||||
|
onChange(ingredients.filter((_, idx) => idx !== i));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg mb-3">Zutaten</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{ingredients.map((ing, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-2">
|
||||||
|
{!editable && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!checked[i]}
|
||||||
|
onChange={() => toggle(i)}
|
||||||
|
className="w-4 h-4 accent-blue-600"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editable ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ing.amount}
|
||||||
|
onChange={e => updateIngredient(i, 'amount', e.target.value)}
|
||||||
|
placeholder="Menge"
|
||||||
|
className="w-16 px-2 py-1 border border-gray-300 rounded text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ing.unit}
|
||||||
|
onChange={e => updateIngredient(i, 'unit', e.target.value)}
|
||||||
|
placeholder="Einheit"
|
||||||
|
className="w-14 px-2 py-1 border border-gray-300 rounded text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ing.name}
|
||||||
|
onChange={e => updateIngredient(i, 'name', e.target.value)}
|
||||||
|
placeholder="Zutat"
|
||||||
|
className="flex-1 px-2 py-1 border border-gray-300 rounded text-sm"
|
||||||
|
/>
|
||||||
|
<button onClick={() => removeIngredient(i)} className="text-red-400 hover:text-red-600 text-lg">×</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className={`${checked[i] ? 'line-through text-gray-400' : ''}`}>
|
||||||
|
{ing.amount && `${ing.amount} `}
|
||||||
|
{ing.unit && `${ing.unit} `}
|
||||||
|
{ing.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{editable && (
|
||||||
|
<button
|
||||||
|
onClick={addIngredient}
|
||||||
|
className="mt-2 text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
+ Zutat hinzufügen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/components/RecipeCard.jsx
Normal file
35
frontend/src/components/RecipeCard.jsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import CategoryBadge from './CategoryBadge';
|
||||||
|
|
||||||
|
export default function RecipeCard({ recipe }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/recipe/${recipe.id}`}
|
||||||
|
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
{recipe.thumbnail_url && (
|
||||||
|
<img
|
||||||
|
src={recipe.thumbnail_url}
|
||||||
|
alt={recipe.name}
|
||||||
|
className="w-full h-48 object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<h3 className="font-semibold text-lg leading-tight">{recipe.name}</h3>
|
||||||
|
<CategoryBadge category={recipe.category} />
|
||||||
|
</div>
|
||||||
|
{recipe.description && (
|
||||||
|
<p className="text-gray-500 text-sm line-clamp-2 mb-2">
|
||||||
|
{recipe.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-gray-400">
|
||||||
|
{recipe.tiktok_author && <span>@{recipe.tiktok_author}</span>}
|
||||||
|
<span>{recipe.ingredients?.length || 0} Zutaten</span>
|
||||||
|
<span>{recipe.steps?.length || 0} Schritte</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
frontend/src/components/StepList.jsx
Normal file
76
frontend/src/components/StepList.jsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export default function StepList({ steps, editable, onChange }) {
|
||||||
|
const [completed, setCompleted] = useState({});
|
||||||
|
|
||||||
|
const toggle = (i) => setCompleted(prev => ({ ...prev, [i]: !prev[i] }));
|
||||||
|
|
||||||
|
const updateStep = (i, value) => {
|
||||||
|
const updated = steps.map((s, idx) => (idx === i ? value : s));
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addStep = () => onChange([...steps, '']);
|
||||||
|
|
||||||
|
const removeStep = (i) => onChange(steps.filter((_, idx) => idx !== i));
|
||||||
|
|
||||||
|
const moveStep = (i, direction) => {
|
||||||
|
const arr = [...steps];
|
||||||
|
const j = i + direction;
|
||||||
|
if (j < 0 || j >= arr.length) return;
|
||||||
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||||
|
onChange(arr);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg mb-3">Zubereitung</h3>
|
||||||
|
<ol className="space-y-3">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-3">
|
||||||
|
{!editable && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(i)}
|
||||||
|
className={`mt-1 w-6 h-6 rounded-full border-2 flex-shrink-0 flex items-center justify-center text-xs font-bold transition-colors ${
|
||||||
|
completed[i]
|
||||||
|
? 'bg-blue-600 border-blue-600 text-white'
|
||||||
|
: 'border-gray-300 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{completed[i] ? '✓' : i + 1}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{editable ? (
|
||||||
|
<div className="flex-1 flex gap-2">
|
||||||
|
<span className="mt-2 text-sm font-bold text-gray-400 w-5">{i + 1}.</span>
|
||||||
|
<textarea
|
||||||
|
value={step}
|
||||||
|
onChange={e => updateStep(i, e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="flex-1 px-2 py-1 border border-gray-300 rounded text-sm resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<button onClick={() => moveStep(i, -1)} className="text-gray-400 hover:text-gray-600 text-xs">↑</button>
|
||||||
|
<button onClick={() => moveStep(i, 1)} className="text-gray-400 hover:text-gray-600 text-xs">↓</button>
|
||||||
|
<button onClick={() => removeStep(i)} className="text-red-400 hover:text-red-600 text-xs">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className={`${completed[i] ? 'line-through text-gray-400' : ''}`}>
|
||||||
|
{step}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
{editable && (
|
||||||
|
<button
|
||||||
|
onClick={addStep}
|
||||||
|
className="mt-2 text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
+ Schritt hinzufügen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/context/AuthContext.jsx
Normal file
46
frontend/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { fetchCurrentUser } from '../auth';
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [token, setToken] = useState(() => localStorage.getItem('token'));
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
fetchCurrentUser(token)
|
||||||
|
.then(u => setUser(u))
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
setToken(null);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const loginUser = (userData, userToken) => {
|
||||||
|
localStorage.setItem('token', userToken);
|
||||||
|
setToken(userToken);
|
||||||
|
setUser(userData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, token, loading, loginUser, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
5
frontend/src/index.css
Normal file
5
frontend/src/index.css
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-gray-50 text-gray-900;
|
||||||
|
}
|
||||||
13
frontend/src/main.jsx
Normal file
13
frontend/src/main.jsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
194
frontend/src/pages/AddRecipe.jsx
Normal file
194
frontend/src/pages/AddRecipe.jsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { extractFromTikTok, saveRecipe } from '../api';
|
||||||
|
import IngredientList from '../components/IngredientList';
|
||||||
|
import StepList from '../components/StepList';
|
||||||
|
|
||||||
|
const CATEGORIES = ['Frühstück', 'Hauptgericht', 'Dessert', 'Snack', 'Getränk', 'Sonstiges'];
|
||||||
|
|
||||||
|
export default function AddRecipe() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [category, setCategory] = useState('Sonstiges');
|
||||||
|
const [ingredients, setIngredients] = useState([]);
|
||||||
|
const [steps, setSteps] = useState([]);
|
||||||
|
const [thumbnail, setThumbnail] = useState('');
|
||||||
|
const [tiktokAuthor, setTiktokAuthor] = useState('');
|
||||||
|
const [hasData, setHasData] = useState(false);
|
||||||
|
const [aiStatus, setAiStatus] = useState(null);
|
||||||
|
|
||||||
|
const handleExtract = async () => {
|
||||||
|
if (!url.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setAiStatus(null);
|
||||||
|
try {
|
||||||
|
const data = await extractFromTikTok(url);
|
||||||
|
if (data.tiktok) {
|
||||||
|
setThumbnail(data.tiktok.thumbnail);
|
||||||
|
setTiktokAuthor(data.tiktok.author);
|
||||||
|
}
|
||||||
|
if (data.extracted) {
|
||||||
|
setName(data.extracted.name || '');
|
||||||
|
setDescription(data.extracted.description || '');
|
||||||
|
setCategory(data.extracted.category || 'Sonstiges');
|
||||||
|
setIngredients(data.extracted.ingredients || []);
|
||||||
|
setSteps(data.extracted.steps || []);
|
||||||
|
} else {
|
||||||
|
setName(data.tiktok?.title || '');
|
||||||
|
}
|
||||||
|
setAiStatus(data.aiStatus || (data.extracted ? 'success' : 'unavailable'));
|
||||||
|
setHasData(true);
|
||||||
|
} catch (e) {
|
||||||
|
setError('Fehler beim Abrufen der TikTok-Daten. Überprüfe die URL.');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const recipe = await saveRecipe({
|
||||||
|
name, description, category, ingredients, steps,
|
||||||
|
tiktok_url: url, tiktok_author: tiktokAuthor, thumbnail_url: thumbnail,
|
||||||
|
});
|
||||||
|
navigate(`/recipe/${recipe.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
setError('Fehler beim Speichern.');
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Rezept hinzufügen</h1>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
TikTok Video-URL
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={e => setUrl(e.target.value)}
|
||||||
|
placeholder="https://www.tiktok.com/@user/video/..."
|
||||||
|
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleExtract()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleExtract}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-6 py-2 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Laden...' : 'Extrahieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-500 text-sm mt-2">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasData && (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
|
||||||
|
{thumbnail && (
|
||||||
|
<img src={thumbnail} alt="" className="w-full h-48 object-cover rounded-lg" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{aiStatus && (
|
||||||
|
<div className={`flex items-center gap-2 text-sm px-3 py-2 rounded-lg ${
|
||||||
|
aiStatus === 'success'
|
||||||
|
? 'bg-green-50 text-green-700 border border-green-200'
|
||||||
|
: aiStatus === 'error'
|
||||||
|
? 'bg-yellow-50 text-yellow-700 border border-yellow-200'
|
||||||
|
: 'bg-gray-50 text-gray-500 border border-gray-200'
|
||||||
|
}`}>
|
||||||
|
{aiStatus === 'success' && (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Zutaten und Zubereitung wurden per KI extrahiert — überprüfe und passe bei Bedarf an.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{aiStatus === 'error' && (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
KI konnte nicht extrahieren — füll die Felder manuell aus.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{aiStatus === 'unavailable' && (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4 text-gray-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
KI (Ollama) nicht erreichbar.{' '}
|
||||||
|
<span className="text-gray-400">Starte Ollama oder füll die Felder manuell aus.</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={e => setCategory(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IngredientList ingredients={ingredients} editable onChange={setIngredients} />
|
||||||
|
<StepList steps={steps} editable onChange={setSteps} />
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !name.trim()}
|
||||||
|
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white py-3 rounded-lg font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichern...' : 'Rezept speichern'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="px-6 py-3 border border-gray-300 rounded-lg font-medium hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
frontend/src/pages/Admin.jsx
Normal file
215
frontend/src/pages/Admin.jsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { createInvites, fetchInvites, fetchUsers, deleteUser as apiDeleteUser } from '../auth';
|
||||||
|
|
||||||
|
export default function Admin() {
|
||||||
|
const { token, user: authUser } = useAuth();
|
||||||
|
const [invites, setInvites] = useState([]);
|
||||||
|
const [stats, setStats] = useState({ total: 0, used: 0, available: 0 });
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [inviteCount, setInviteCount] = useState(1);
|
||||||
|
const [newTokens, setNewTokens] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [copied, setCopied] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [invitesData, usersData] = await Promise.all([
|
||||||
|
fetchInvites(token),
|
||||||
|
fetchUsers(token),
|
||||||
|
]);
|
||||||
|
setInvites(invitesData.invites);
|
||||||
|
setStats(invitesData.stats);
|
||||||
|
setUsers(usersData);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateInvites = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await createInvites(token, inviteCount);
|
||||||
|
setNewTokens(data.tokens);
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text) => {
|
||||||
|
const url = `${window.location.origin}/register?invite=${text}`;
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
setCopied(text);
|
||||||
|
setTimeout(() => setCopied(''), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (userId, username) => {
|
||||||
|
if (!confirm(`Benutzer "${username}" wirklich löschen?`)) return;
|
||||||
|
try {
|
||||||
|
await apiDeleteUser(token, userId);
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-xl">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<p className="text-sm text-gray-500">Einladungen erstellt</p>
|
||||||
|
<p className="text-3xl font-bold text-blue-600">{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<p className="text-sm text-gray-500">Verwendet</p>
|
||||||
|
<p className="text-3xl font-bold text-green-600">{stats.used}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<p className="text-sm text-gray-500">Offen</p>
|
||||||
|
<p className="text-3xl font-bold text-orange-500">{stats.available}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Neue Einladung erstellen</h2>
|
||||||
|
<form onSubmit={handleCreateInvites} className="flex items-end gap-3">
|
||||||
|
<div className="flex-1 max-w-xs">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Anzahl</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={inviteCount}
|
||||||
|
onChange={e => setInviteCount(parseInt(e.target.value) || 1)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-6 py-2 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Erstellen...' : 'Erstellen'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{newTokens.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<p className="text-sm font-medium text-gray-700">Neue Einladungslinks:</p>
|
||||||
|
{newTokens.map((t, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 bg-blue-50 rounded-lg px-3 py-2">
|
||||||
|
<code className="flex-1 text-sm text-blue-800 break-all">{t}</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(t)}
|
||||||
|
className="text-sm bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded-lg font-medium transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{copied === t ? 'Kopiert!' : 'Link kopieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Alle Einladungen</h2>
|
||||||
|
{invites.length === 0 ? (
|
||||||
|
<p className="text-gray-400 text-sm">Noch keine Einladungen erstellt.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="text-left py-2 font-medium text-gray-500">Code</th>
|
||||||
|
<th className="text-left py-2 font-medium text-gray-500">Erstellt</th>
|
||||||
|
<th className="text-left py-2 font-medium text-gray-500">Status</th>
|
||||||
|
<th className="text-left py-2 font-medium text-gray-500">Genutzt von</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invites.map(inv => (
|
||||||
|
<tr key={inv.id} className="border-b border-gray-100">
|
||||||
|
<td className="py-2">
|
||||||
|
<code className="text-xs bg-gray-100 px-2 py-1 rounded">{inv.token.slice(0, 12)}...</code>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-gray-500">{new Date(inv.created_at).toLocaleDateString('de-DE')}</td>
|
||||||
|
<td className="py-2">
|
||||||
|
{inv.used_by ? (
|
||||||
|
<span className="text-green-600 font-medium">Verwendet</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-blue-600 font-medium">Offen</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-gray-500">{inv.used_by_username || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Registrierte Benutzer</h2>
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<p className="text-gray-400 text-sm">Keine Benutzer gefunden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="text-left py-2 font-medium text-gray-500">Benutzername</th>
|
||||||
|
<th className="text-left py-2 font-medium text-gray-500">Rolle</th>
|
||||||
|
<th className="text-left py-2 font-medium text-gray-500">Registriert</th>
|
||||||
|
<th className="text-right py-2 font-medium text-gray-500"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map(u => (
|
||||||
|
<tr key={u.id} className="border-b border-gray-100">
|
||||||
|
<td className="py-2 font-medium">{u.username}</td>
|
||||||
|
<td className="py-2">
|
||||||
|
{u.is_admin ? (
|
||||||
|
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded-full">Admin</span>
|
||||||
|
) : (
|
||||||
|
<span className="bg-gray-100 text-gray-600 text-xs font-medium px-2 py-1 rounded-full">User</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-gray-500">{new Date(u.created_at).toLocaleDateString('de-DE')}</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
{u.id !== authUser?.id && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUser(u.id, u.username)}
|
||||||
|
className="text-sm text-red-400 hover:text-red-600 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
frontend/src/pages/Home.jsx
Normal file
91
frontend/src/pages/Home.jsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { fetchRecipes, fetchCategories } from '../api';
|
||||||
|
import RecipeCard from '../components/RecipeCard';
|
||||||
|
|
||||||
|
const ALL_CATEGORIES = ['Alle', 'Frühstück', 'Hauptgericht', 'Dessert', 'Snack', 'Getränk', 'Sonstiges'];
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [recipes, setRecipes] = useState([]);
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState('Alle');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRecipes();
|
||||||
|
loadCategories();
|
||||||
|
}, [search, selectedCategory]);
|
||||||
|
|
||||||
|
const loadRecipes = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchRecipes(search, selectedCategory);
|
||||||
|
setRecipes(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Laden:', e);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCategories = async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchCategories();
|
||||||
|
setCategories(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Laden der Kategorien:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryCount = (cat) => {
|
||||||
|
if (cat === 'Alle') return categories.reduce((sum, c) => sum + c.count, 0);
|
||||||
|
const found = categories.find(c => c.category === cat);
|
||||||
|
return found ? found.count : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Rezepte suchen..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl text-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-6">
|
||||||
|
{ALL_CATEGORIES.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
onClick={() => setSelectedCategory(cat)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||||
|
selectedCategory === cat
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-white border border-gray-200 text-gray-600 hover:border-blue-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat} ({getCategoryCount(cat)})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-gray-400">Laden...</div>
|
||||||
|
) : recipes.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-400 text-lg mb-2">Keine Rezepte gefunden</p>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
Füge dein erstes Rezept über TikTok-Links hinzu!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{recipes.map(recipe => (
|
||||||
|
<RecipeCard key={recipe.id} recipe={recipe} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
frontend/src/pages/Login.jsx
Normal file
90
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { loginUser as apiLogin } from '../auth';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { loginUser } = useAuth();
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await apiLogin(username, password);
|
||||||
|
loginUser(data.user, data.token);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-100 flex items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600 text-white text-3xl mb-4 shadow-lg shadow-blue-600/30">
|
||||||
|
🍳
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">TikTok Rezepte</h1>
|
||||||
|
<p className="text-gray-500 mt-1">Deine Rezepte, immer griffbereit</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl shadow-blue-900/5 border border-blue-100 p-8">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-6">Anmelden</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Benutzername
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
placeholder="Benutzername eingeben"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
placeholder="Passwort eingeben"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-xl">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white py-3 rounded-xl font-semibold transition-colors shadow-lg shadow-blue-600/25 hover:shadow-blue-600/40"
|
||||||
|
>
|
||||||
|
{loading ? 'Laden...' : 'Anmelden'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-gray-400">
|
||||||
|
Nur für eingeladene Benutzer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
frontend/src/pages/Profile.jsx
Normal file
100
frontend/src/pages/Profile.jsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { updateCredentials } from '../auth';
|
||||||
|
|
||||||
|
export default function Profile() {
|
||||||
|
const { token, user: authUser, loginUser } = useAuth();
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newUsername, setNewUsername] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
try {
|
||||||
|
const data = await updateCredentials(token, {
|
||||||
|
currentPassword,
|
||||||
|
newUsername: newUsername || undefined,
|
||||||
|
newPassword: newPassword || undefined,
|
||||||
|
});
|
||||||
|
loginUser(data.user, data.token);
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewUsername('');
|
||||||
|
setNewPassword('');
|
||||||
|
setSuccess('Daten erfolgreich geändert!');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Profil</h1>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
Angemeldet als <span className="font-medium text-gray-700">{authUser?.username}</span>
|
||||||
|
{authUser?.is_admin && <span className="ml-2 text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded-full">Admin</span>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-xl mb-4">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="bg-green-50 border border-green-200 text-green-700 text-sm px-4 py-3 rounded-xl mb-4">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Aktuelles Passwort</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={e => setCurrentPassword(e.target.value)}
|
||||||
|
placeholder="Aktuelles Passwort eingeben"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Neuer Benutzername (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newUsername}
|
||||||
|
onChange={e => setNewUsername(e.target.value)}
|
||||||
|
placeholder="Neuen Benutzernamen wählen"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Neues Passwort (optional)</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Mindestens 6 Zeichen"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-6 py-2 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichern...' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
frontend/src/pages/RecipeDetail.jsx
Normal file
174
frontend/src/pages/RecipeDetail.jsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { fetchRecipe, updateRecipe, deleteRecipe } from '../api';
|
||||||
|
import CategoryBadge from '../components/CategoryBadge';
|
||||||
|
import IngredientList from '../components/IngredientList';
|
||||||
|
import StepList from '../components/StepList';
|
||||||
|
|
||||||
|
const CATEGORIES = ['Frühstück', 'Hauptgericht', 'Dessert', 'Snack', 'Getränk', 'Sonstiges'];
|
||||||
|
|
||||||
|
export default function RecipeDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [recipe, setRecipe] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [form, setForm] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [id]);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchRecipe(id);
|
||||||
|
setRecipe(data);
|
||||||
|
setForm(data);
|
||||||
|
} catch (e) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
await updateRecipe(id, form);
|
||||||
|
setRecipe(form);
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirm('Rezept wirklich löschen?')) return;
|
||||||
|
await deleteRecipe(id);
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateField = (field, value) => setForm(prev => ({ ...prev, [field]: value }));
|
||||||
|
|
||||||
|
if (loading) return <div className="text-center py-12 text-gray-400">Laden...</div>;
|
||||||
|
if (!recipe) return null;
|
||||||
|
|
||||||
|
const videoId = recipe.tiktok_url?.match(/video\/(\d+)/)?.[1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<Link to="/" className="text-blue-600 hover:text-blue-700 text-sm font-medium mb-4 inline-block">
|
||||||
|
← Zurück
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
{videoId && (
|
||||||
|
<div className="w-full" style={{ paddingBottom: '177.78%', position: 'relative' }}>
|
||||||
|
<iframe
|
||||||
|
src={`https://www.tiktok.com/embed/v2/${videoId}`}
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => updateField('name', e.target.value)}
|
||||||
|
className="text-2xl font-bold flex-1 px-2 py-1 border border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h1 className="text-2xl font-bold">{recipe.name}</h1>
|
||||||
|
)}
|
||||||
|
<CategoryBadge category={editing ? form.category : recipe.category} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||||
|
<select
|
||||||
|
value={form.category}
|
||||||
|
onChange={e => updateField('category', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!editing && recipe.description && (
|
||||||
|
<p className="text-gray-500">{recipe.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={e => updateField('description', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<IngredientList
|
||||||
|
ingredients={form.ingredients}
|
||||||
|
editable
|
||||||
|
onChange={ingredients => updateField('ingredients', ingredients)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IngredientList ingredients={recipe.ingredients} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<StepList
|
||||||
|
steps={form.steps}
|
||||||
|
editable
|
||||||
|
onChange={steps => updateField('steps', steps)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StepList steps={recipe.steps} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipe.tiktok_author && (
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Von: @{recipe.tiktok_author}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2 border-t border-gray-100">
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white py-2 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditing(false); setForm(recipe); }}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-lg font-medium hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="flex-1 border border-blue-300 text-blue-600 hover:bg-blue-50 py-2 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="px-4 py-2 border border-red-300 text-red-600 hover:bg-red-50 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
frontend/src/pages/Register.jsx
Normal file
114
frontend/src/pages/Register.jsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { registerUser } from '../auth';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export default function Register() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const prefillToken = searchParams.get('invite') || '';
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [inviteToken, setInviteToken] = useState(prefillToken);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { loginUser } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prefillToken) setInviteToken(prefillToken);
|
||||||
|
}, [prefillToken]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await registerUser(username, password, inviteToken);
|
||||||
|
loginUser(data.user, data.token);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-100 flex items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600 text-white text-3xl mb-4 shadow-lg shadow-blue-600/30">
|
||||||
|
🍳
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">TikTok Rezepte</h1>
|
||||||
|
<p className="text-gray-500 mt-1">Konto erstellen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl shadow-blue-900/5 border border-blue-100 p-8">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-6">Registrieren</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Einladungscode
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inviteToken}
|
||||||
|
onChange={e => setInviteToken(e.target.value)}
|
||||||
|
placeholder="Einladungscode vom Admin"
|
||||||
|
required
|
||||||
|
readOnly={!!prefillToken}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors read-only:bg-gray-50 read-only:text-gray-500"
|
||||||
|
/>
|
||||||
|
{prefillToken && (
|
||||||
|
<p className="text-xs text-green-600 mt-1">Code aus Einladungslink übernommen</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Benutzername
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
placeholder="Benutzername wählen"
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
placeholder="Mindestens 6 Zeichen"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-xl">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white py-3 rounded-xl font-semibold transition-colors shadow-lg shadow-blue-600/25 hover:shadow-blue-600/40"
|
||||||
|
>
|
||||||
|
{loading ? 'Laden...' : 'Registrieren'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
package-lock.json
generated
Normal file
257
package-lock.json
generated
Normal 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
17
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user