add Analyse
This commit is contained in:
@@ -9,3 +9,7 @@ ADMIN_PASS=DEIN-SICHERES-PASSWORT
|
||||
|
||||
# Ollama KI-Modell (optional, Standard: mistral)
|
||||
# OLLAMA_MODEL=mistral
|
||||
|
||||
# Instagram oEmbed Token (optional, für Instagram Reels Extraktion)
|
||||
# Benötigt ein Facebook App Access Token
|
||||
# INSTAGRAM_TOKEN=dein-token-hier
|
||||
|
||||
@@ -31,7 +31,7 @@ export function getDb() {
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getAllRecipes(search, category) {
|
||||
export function getAllRecipes(search, category, userId) {
|
||||
let results = [...db.recipes];
|
||||
|
||||
if (search) {
|
||||
@@ -47,6 +47,10 @@ export function getAllRecipes(search, category) {
|
||||
results = results.filter(r => r.category === category);
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
results = results.filter(r => r.user_id === Number(userId));
|
||||
}
|
||||
|
||||
results.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
|
||||
return results;
|
||||
}
|
||||
@@ -65,8 +69,11 @@ export function createRecipe(recipe) {
|
||||
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,
|
||||
source: recipe.source || 'tiktok',
|
||||
source_url: recipe.source_url || recipe.tiktok_url || null,
|
||||
source_author: recipe.source_author || recipe.tiktok_author || null,
|
||||
tiktok_url: recipe.tiktok_url || recipe.source_url || null,
|
||||
tiktok_author: recipe.tiktok_author || recipe.source_author || null,
|
||||
thumbnail_url: recipe.thumbnail_url || null,
|
||||
user_id: recipe.user_id || null,
|
||||
created_at: nowStr,
|
||||
@@ -87,8 +94,11 @@ export function updateRecipe(id, recipe) {
|
||||
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,
|
||||
source: recipe.source || db.recipes[idx].source || 'tiktok',
|
||||
source_url: recipe.source_url || recipe.tiktok_url || db.recipes[idx].source_url || null,
|
||||
source_author: recipe.source_author || recipe.tiktok_author || db.recipes[idx].source_author || null,
|
||||
tiktok_url: recipe.tiktok_url || recipe.source_url || db.recipes[idx].tiktok_url || null,
|
||||
tiktok_author: recipe.tiktok_author || recipe.source_author || db.recipes[idx].tiktok_author || null,
|
||||
thumbnail_url: recipe.thumbnail_url || null,
|
||||
updated_at: now(),
|
||||
};
|
||||
|
||||
@@ -7,35 +7,13 @@ import {
|
||||
createInviteToken, getInviteToken, useInviteToken,
|
||||
getInvitesByUser, getAllUsers, getInviteStats
|
||||
} from '../db.js';
|
||||
import { authMiddleware, adminMiddleware } from '../middleware.js';
|
||||
import { trackEvent } from '../analytics-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;
|
||||
@@ -69,6 +47,8 @@ router.post('/register', async (req, res) => {
|
||||
const user = createUser(username, hashedPassword);
|
||||
useInviteToken(inviteToken, user.id);
|
||||
|
||||
trackEvent('user_register', user.id, { username: user.username });
|
||||
|
||||
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
||||
|
||||
res.status(201).json({ user, token });
|
||||
@@ -97,6 +77,8 @@ router.post('/login', async (req, res) => {
|
||||
|
||||
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
||||
|
||||
trackEvent('user_login', user.id, { username: user.username });
|
||||
|
||||
res.json({
|
||||
user: { id: user.id, username: user.username, is_admin: !!user.is_admin, created_at: user.created_at },
|
||||
token,
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
getAllRecipes, getRecipeById, createRecipe,
|
||||
updateRecipe, deleteRecipe, getCategories
|
||||
updateRecipe, deleteRecipe, getCategories, getDb
|
||||
} from '../db.js';
|
||||
import { getTikTokData } from '../services/tiktok.js';
|
||||
import { getInstagramData } from '../services/instagram.js';
|
||||
import { isOllamaAvailable, extractRecipe } from '../services/ai.js';
|
||||
import { trackEvent } from '../analytics-db.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const { search, category } = req.query;
|
||||
const recipes = getAllRecipes(search, category);
|
||||
const { search, category, user_id } = req.query;
|
||||
const recipes = getAllRecipes(search, category, user_id);
|
||||
res.json(recipes.map(r => ({
|
||||
...r,
|
||||
ingredients: JSON.parse(r.ingredients),
|
||||
@@ -22,6 +24,16 @@ router.get('/categories', (req, res) => {
|
||||
res.json(getCategories());
|
||||
});
|
||||
|
||||
router.get('/authors', (req, res) => {
|
||||
const db = getDb();
|
||||
const authorIds = [...new Set(db.recipes.filter(r => r.user_id).map(r => r.user_id))];
|
||||
const authors = authorIds.map(id => {
|
||||
const user = db.users.find(u => u.id === id);
|
||||
return { id, username: user ? user.username : `User #${id}` };
|
||||
}).sort((a, b) => a.username.localeCompare(b.username));
|
||||
res.json(authors);
|
||||
});
|
||||
|
||||
router.get('/ai-status', async (req, res) => {
|
||||
const available = await isOllamaAvailable();
|
||||
res.json({ ollama: available });
|
||||
@@ -39,6 +51,8 @@ router.get('/:id', (req, res) => {
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const recipe = createRecipe(req.body);
|
||||
const userId = req.body.user_id || null;
|
||||
trackEvent('recipe_created', userId, { recipe_id: recipe.id, recipe_name: recipe.name });
|
||||
res.status(201).json(recipe);
|
||||
});
|
||||
|
||||
@@ -46,6 +60,7 @@ 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);
|
||||
trackEvent('recipe_updated', req.body.user_id || null, { recipe_id: Number(req.params.id), recipe_name: updated.name });
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
@@ -53,6 +68,7 @@ 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);
|
||||
trackEvent('recipe_deleted', null, { recipe_id: Number(req.params.id) });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -88,4 +104,36 @@ router.post('/from-tiktok', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/from-instagram', async (req, res) => {
|
||||
try {
|
||||
const { url } = req.body;
|
||||
if (!url) return res.status(400).json({ error: 'URL erforderlich' });
|
||||
|
||||
const instagramData = await getInstagramData(url);
|
||||
|
||||
const ollamaAvailable = await isOllamaAvailable();
|
||||
let extracted = null;
|
||||
let aiStatus = ollamaAvailable ? 'available' : 'unavailable';
|
||||
|
||||
if (ollamaAvailable && instagramData.title) {
|
||||
try {
|
||||
extracted = await extractRecipe(instagramData.title);
|
||||
aiStatus = 'success';
|
||||
} catch (e) {
|
||||
console.error('Ollama Extraktion fehlgeschlagen:', e.message);
|
||||
aiStatus = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
instagram: instagramData,
|
||||
ollama: ollamaAvailable,
|
||||
aiStatus,
|
||||
extracted,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,18 +1,42 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import recipesRouter from './routes/recipes.js';
|
||||
import authRouter from './routes/auth.js';
|
||||
import analyticsRouter from './routes/analytics.js';
|
||||
import { getUserByUsername, createUser } from './db.js';
|
||||
import { trackEvent } from './analytics-db.js';
|
||||
import { authMiddleware, adminMiddleware } from './middleware.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'tiktok-rezepte-secret-key-change-in-production';
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.method === 'OPTIONS' || !req.path.startsWith('/api/')) return next();
|
||||
if (req.path.startsWith('/api/analytics')) return next();
|
||||
|
||||
let userId = null;
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
try {
|
||||
const decoded = jwt.verify(authHeader.split(' ')[1], JWT_SECRET);
|
||||
const user = getUserByUsername(decoded.username);
|
||||
if (user) userId = user.id;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
trackEvent('page_view', userId, { path: req.path, method: req.method });
|
||||
next();
|
||||
});
|
||||
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/recipes', recipesRouter);
|
||||
app.use('/api/analytics', authMiddleware, adminMiddleware, analyticsRouter);
|
||||
|
||||
const adminUser = process.env.ADMIN_USER || 'admin';
|
||||
const adminPass = process.env.ADMIN_PASS || 'admin123';
|
||||
|
||||
@@ -12,11 +12,13 @@ services:
|
||||
environment:
|
||||
- PORT=3001
|
||||
- DB_PATH=/app/data/data.json
|
||||
- ANALYTICS_PATH=/app/data/analytics.json
|
||||
- OLLAMA_URL=http://ollama:11434
|
||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-mistral}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- ADMIN_USER=${ADMIN_USER:-admin}
|
||||
- ADMIN_PASS=${ADMIN_PASS}
|
||||
- INSTAGRAM_TOKEN=${INSTAGRAM_TOKEN:-}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
depends_on:
|
||||
|
||||
@@ -7,6 +7,7 @@ import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import Admin from './pages/Admin';
|
||||
import Profile from './pages/Profile';
|
||||
import Analytics from './pages/Analytics';
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const { user, loading } = useAuth();
|
||||
@@ -41,7 +42,7 @@ function AppContent() {
|
||||
<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
|
||||
Rezepte
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
@@ -58,6 +59,14 @@ function AppContent() {
|
||||
Dashboard
|
||||
</Link>
|
||||
)}
|
||||
{user.is_admin && (
|
||||
<Link
|
||||
to="/analytics"
|
||||
className="text-sm text-gray-500 hover:text-gray-700 font-medium transition-colors"
|
||||
>
|
||||
Analytics
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to="/profile"
|
||||
className="text-sm text-gray-500 hover:text-gray-700 font-medium transition-colors"
|
||||
@@ -85,6 +94,7 @@ function AppContent() {
|
||||
<Route path="/add" element={<ProtectedRoute><AddRecipe /></ProtectedRoute>} />
|
||||
<Route path="/recipe/:id" element={<ProtectedRoute><RecipeDetail /></ProtectedRoute>} />
|
||||
<Route path="/admin" element={<AdminRoute><Admin /></AdminRoute>} />
|
||||
<Route path="/analytics" element={<AdminRoute><Analytics /></AdminRoute>} />
|
||||
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
||||
<Route path="/login" element={<Navigate to="/" />} />
|
||||
<Route path="/register" element={<Navigate to="/" />} />
|
||||
|
||||
@@ -12,10 +12,11 @@ async function apiFetch(url, options = {}) {
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
export async function fetchRecipes(search, category) {
|
||||
export async function fetchRecipes(search, category, userId) {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('search', search);
|
||||
if (category && category !== 'Alle') params.set('category', category);
|
||||
if (userId) params.set('user_id', userId);
|
||||
return apiFetch(`${API_BASE}?${params}`);
|
||||
}
|
||||
|
||||
@@ -51,10 +52,62 @@ export async function extractFromTikTok(url) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function extractFromInstagram(url) {
|
||||
return apiFetch(`${API_BASE}/from-instagram`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCategories() {
|
||||
return apiFetch(`${API_BASE}/categories`);
|
||||
}
|
||||
|
||||
export async function fetchAuthors() {
|
||||
return apiFetch(`${API_BASE}/authors`);
|
||||
}
|
||||
|
||||
export async function checkAiStatus() {
|
||||
return apiFetch(`${API_BASE}/ai-status`);
|
||||
}
|
||||
|
||||
export async function fetchAnalyticsOverview(token) {
|
||||
const res = await fetch('/api/analytics/overview', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchAnalyticsPageviews(token, from, to) {
|
||||
const params = new URLSearchParams();
|
||||
if (from) params.set('from', from);
|
||||
if (to) params.set('to', to);
|
||||
const res = await fetch(`/api/analytics/pageviews?${params}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchAnalyticsUsers(token) {
|
||||
const res = await fetch('/api/analytics/users', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchAnalyticsRecipes(token) {
|
||||
const res = await fetch('/api/analytics/recipes', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchAnalyticsActivity(token, userId) {
|
||||
const params = new URLSearchParams();
|
||||
if (userId) params.set('user_id', userId);
|
||||
const res = await fetch(`/api/analytics/activity?${params}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import { Link } from 'react-router-dom';
|
||||
import CategoryBadge from './CategoryBadge';
|
||||
|
||||
export default function RecipeCard({ recipe }) {
|
||||
const author = recipe.source_author || recipe.tiktok_author;
|
||||
const source = recipe.source || 'tiktok';
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/recipe/${recipe.id}`}
|
||||
@@ -25,7 +28,7 @@ export default function RecipeCard({ recipe }) {
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-gray-400">
|
||||
{recipe.tiktok_author && <span>@{recipe.tiktok_author}</span>}
|
||||
{author && <span>@{author} ({source === 'instagram' ? 'IG' : 'TT'})</span>}
|
||||
<span>{recipe.ingredients?.length || 0} Zutaten</span>
|
||||
<span>{recipe.steps?.length || 0} Schritte</span>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { extractFromTikTok, saveRecipe } from '../api';
|
||||
import { extractFromTikTok, extractFromInstagram, saveRecipe } from '../api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import IngredientList from '../components/IngredientList';
|
||||
import StepList from '../components/StepList';
|
||||
|
||||
const CATEGORIES = ['Frühstück', 'Hauptgericht', 'Dessert', 'Snack', 'Getränk', 'Sonstiges'];
|
||||
|
||||
function detectSource(url) {
|
||||
if (/instagram\.com\/(p|reel)\//.test(url)) return 'instagram';
|
||||
if (/tiktok\.com\//.test(url)) return 'tiktok';
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function AddRecipe() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [url, setUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -19,21 +27,38 @@ export default function AddRecipe() {
|
||||
const [ingredients, setIngredients] = useState([]);
|
||||
const [steps, setSteps] = useState([]);
|
||||
const [thumbnail, setThumbnail] = useState('');
|
||||
const [tiktokAuthor, setTiktokAuthor] = useState('');
|
||||
const [sourceAuthor, setSourceAuthor] = useState('');
|
||||
const [source, setSource] = useState('');
|
||||
const [hasData, setHasData] = useState(false);
|
||||
const [aiStatus, setAiStatus] = useState(null);
|
||||
|
||||
const handleExtract = async () => {
|
||||
if (!url.trim()) return;
|
||||
const detected = detectSource(url);
|
||||
if (!detected) {
|
||||
setError('URL muss von TikTok oder Instagram sein.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setAiStatus(null);
|
||||
try {
|
||||
const data = await extractFromTikTok(url);
|
||||
if (data.tiktok) {
|
||||
setThumbnail(data.tiktok.thumbnail);
|
||||
setTiktokAuthor(data.tiktok.author);
|
||||
let data;
|
||||
if (detected === 'instagram') {
|
||||
data = await extractFromInstagram(url);
|
||||
const ig = data.instagram;
|
||||
setThumbnail(ig.thumbnail);
|
||||
setSourceAuthor(ig.author);
|
||||
setSource('instagram');
|
||||
} else {
|
||||
data = await extractFromTikTok(url);
|
||||
const tt = data.tiktok;
|
||||
setThumbnail(tt.thumbnail);
|
||||
setSourceAuthor(tt.author);
|
||||
setSource('tiktok');
|
||||
}
|
||||
|
||||
if (data.extracted) {
|
||||
setName(data.extracted.name || '');
|
||||
setDescription(data.extracted.description || '');
|
||||
@@ -41,12 +66,12 @@ export default function AddRecipe() {
|
||||
setIngredients(data.extracted.ingredients || []);
|
||||
setSteps(data.extracted.steps || []);
|
||||
} else {
|
||||
setName(data.tiktok?.title || '');
|
||||
setName(detected === 'instagram' ? (data.instagram?.title || '') : (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.');
|
||||
setError('Fehler beim Abrufen der Video-Daten. Überprüfe die URL.');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -57,7 +82,13 @@ export default function AddRecipe() {
|
||||
try {
|
||||
const recipe = await saveRecipe({
|
||||
name, description, category, ingredients, steps,
|
||||
tiktok_url: url, tiktok_author: tiktokAuthor, thumbnail_url: thumbnail,
|
||||
source,
|
||||
source_url: url,
|
||||
source_author: sourceAuthor,
|
||||
tiktok_url: url,
|
||||
tiktok_author: sourceAuthor,
|
||||
thumbnail_url: thumbnail,
|
||||
user_id: user?.id || null,
|
||||
});
|
||||
navigate(`/recipe/${recipe.id}`);
|
||||
} catch (e) {
|
||||
@@ -72,14 +103,14 @@ export default function AddRecipe() {
|
||||
|
||||
<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
|
||||
Video-URL (TikTok oder Instagram Reel)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
placeholder="https://www.tiktok.com/@user/video/..."
|
||||
placeholder="https://www.tiktok.com/@user/video/... oder https://www.instagram.com/reel/..."
|
||||
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()}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fetchRecipes, fetchCategories } from '../api';
|
||||
import { fetchRecipes, fetchCategories, fetchAuthors } from '../api';
|
||||
import RecipeCard from '../components/RecipeCard';
|
||||
|
||||
const ALL_CATEGORIES = ['Alle', 'Frühstück', 'Hauptgericht', 'Dessert', 'Snack', 'Getränk', 'Sonstiges'];
|
||||
@@ -7,19 +7,25 @@ const ALL_CATEGORIES = ['Alle', 'Frühstück', 'Hauptgericht', 'Dessert', 'Snack
|
||||
export default function Home() {
|
||||
const [recipes, setRecipes] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [authors, setAuthors] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('Alle');
|
||||
const [selectedAuthor, setSelectedAuthor] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadRecipes();
|
||||
}, [search, selectedCategory, selectedAuthor]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, [search, selectedCategory]);
|
||||
loadAuthors();
|
||||
}, []);
|
||||
|
||||
const loadRecipes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchRecipes(search, selectedCategory);
|
||||
const data = await fetchRecipes(search, selectedCategory, selectedAuthor || undefined);
|
||||
setRecipes(data);
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden:', e);
|
||||
@@ -36,6 +42,15 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadAuthors = async () => {
|
||||
try {
|
||||
const data = await fetchAuthors();
|
||||
setAuthors(data);
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Autoren:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryCount = (cat) => {
|
||||
if (cat === 'Alle') return categories.reduce((sum, c) => sum + c.count, 0);
|
||||
const found = categories.find(c => c.category === cat);
|
||||
@@ -54,7 +69,7 @@ export default function Home() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{ALL_CATEGORIES.map(cat => (
|
||||
<button
|
||||
key={cat}
|
||||
@@ -70,13 +85,29 @@ export default function Home() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{authors.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Erstellt von</label>
|
||||
<select
|
||||
value={selectedAuthor}
|
||||
onChange={e => setSelectedAuthor(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle User</option>
|
||||
{authors.map(a => (
|
||||
<option key={a.id} value={a.id}>{a.username}</option>
|
||||
))}
|
||||
</select>
|
||||
</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!
|
||||
Füge dein erstes Rezept über TikTok- oder Instagram-Links hinzu!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { fetchRecipe, updateRecipe, deleteRecipe } from '../api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import CategoryBadge from '../components/CategoryBadge';
|
||||
import IngredientList from '../components/IngredientList';
|
||||
import StepList from '../components/StepList';
|
||||
@@ -10,6 +11,7 @@ const CATEGORIES = ['Frühstück', 'Hauptgericht', 'Dessert', 'Snack', 'Getränk
|
||||
export default function RecipeDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [recipe, setRecipe] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -29,7 +31,7 @@ export default function RecipeDetail() {
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
await updateRecipe(id, form);
|
||||
await updateRecipe(id, { ...form, user_id: user?.id || null });
|
||||
setRecipe(form);
|
||||
setEditing(false);
|
||||
};
|
||||
@@ -45,7 +47,28 @@ export default function RecipeDetail() {
|
||||
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];
|
||||
const sourceUrl = recipe.source_url || recipe.tiktok_url;
|
||||
const sourceType = recipe.source || 'tiktok';
|
||||
|
||||
let videoId = null;
|
||||
let embedUrl = null;
|
||||
if (sourceUrl) {
|
||||
if (sourceType === 'instagram') {
|
||||
const igMatch = sourceUrl.match(/instagram\.com\/(?:p|reel)\/([A-Za-z0-9_-]+)/);
|
||||
if (igMatch) {
|
||||
videoId = igMatch[1];
|
||||
embedUrl = `https://www.instagram.com/reel/${videoId}/embed/`;
|
||||
}
|
||||
} else {
|
||||
const ttMatch = sourceUrl.match(/video\/(\d+)/);
|
||||
if (ttMatch) {
|
||||
videoId = ttMatch[1];
|
||||
embedUrl = `https://www.tiktok.com/embed/v2/${videoId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const author = recipe.source_author || recipe.tiktok_author;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
@@ -54,10 +77,10 @@ export default function RecipeDetail() {
|
||||
</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' }}>
|
||||
{embedUrl && (
|
||||
<div className="w-full" style={{ paddingBottom: sourceType === 'instagram' ? '100%' : '177.78%', position: 'relative' }}>
|
||||
<iframe
|
||||
src={`https://www.tiktok.com/embed/v2/${videoId}`}
|
||||
src={embedUrl}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
allowFullScreen
|
||||
/>
|
||||
@@ -128,9 +151,9 @@ export default function RecipeDetail() {
|
||||
<StepList steps={recipe.steps} />
|
||||
)}
|
||||
|
||||
{recipe.tiktok_author && (
|
||||
{author && (
|
||||
<p className="text-xs text-gray-400">
|
||||
Von: @{recipe.tiktok_author}
|
||||
Quelle: @{author} ({sourceType === 'instagram' ? 'Instagram' : 'TikTok'})
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user