add Analyse

This commit is contained in:
nadja
2026-08-26 12:18:33 +02:00
parent 28f47c5712
commit 18cf24ff93
12 changed files with 279 additions and 58 deletions

View File

@@ -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="/" />} />

View File

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

View File

@@ -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>

View File

@@ -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()}
/>

View File

@@ -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>
) : (

View File

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