diff --git a/project/backend/Database.py b/project/backend/Database.py
index 2da1bb3..a20ad40 100644
--- a/project/backend/Database.py
+++ b/project/backend/Database.py
@@ -9,8 +9,8 @@ def getConnection() -> sqlite3.Connection:
"""Erstellt eine neue SQLite-Connection mit Row-Factory."""
con = sqlite3.connect(str(DB_PATH), check_same_thread=False)
con.row_factory = sqlite3.Row
- con.execute("PRAGMA foreign_keys = ON")
con.execute("PRAGMA journal_mode = WAL")
+ con.execute("PRAGMA foreign_keys = ON")
return con
@@ -99,6 +99,19 @@ def initDB():
UNIQUE (AccountID, rid)
)
""")
+ cur.execute("""
+ CREATE TABLE IF NOT EXISTS IngredientUsage (
+ AccountID INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ displayName TEXT NOT NULL,
+ count INTEGER NOT NULL DEFAULT 1,
+ lastUnit TEXT,
+ lastUsedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (AccountID, name),
+ FOREIGN KEY (AccountID) REFERENCES Account (id) ON DELETE CASCADE
+ )
+ """)
+
cur.execute("""
CREATE TABLE IF NOT EXISTS PasswordResetToken (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -269,6 +282,56 @@ def updateKontoPassword(konto_id: int, hashed_password: str) -> None:
)
+# ── Ingredient-Usage-Operationen ───────────────────────────────
+
+def incrementIngredientUsage(AccountID: int, name: str, unit: str | None) -> None:
+ """Erhöht den Usage-Counter für eine Zutat um 1, aktualisiert lastUnit und lastUsedAt.
+ Legt einen neuen Eintrag an, falls die Zutat für diesen Account noch nicht existiert.
+ Der Name wird normalisiert (trim + lowercase) für den Primary Key, displayName behält
+ die Originalschreibweise der letzten Eingabe.
+ """
+ displayName = (name or "").strip()
+ if not displayName:
+ return
+ normalizedName = displayName.lower()
+ with getDB() as con:
+ cur = con.cursor()
+ cur.execute(
+ """
+ INSERT INTO IngredientUsage (AccountID, name, displayName, count, lastUnit, lastUsedAt)
+ VALUES (?, ?, ?, 1, ?, CURRENT_TIMESTAMP)
+ ON CONFLICT(AccountID, name) DO UPDATE SET
+ count = count + 1,
+ displayName = excluded.displayName,
+ lastUnit = excluded.lastUnit,
+ lastUsedAt = CURRENT_TIMESTAMP
+ """,
+ (AccountID, normalizedName, displayName, unit),
+ )
+
+
+def getTopIngredients(AccountID: int, limit: int = 5) -> list[dict]:
+ """Liefert die meistgenutzten Zutaten des Users.
+ Sortierung Hybrid: erst nach count DESC, dann nach lastUsedAt DESC als Tie-Breaker.
+ """
+ con = getConnection()
+ try:
+ cur = con.cursor()
+ cur.execute(
+ """
+ SELECT displayName, lastUnit, count, lastUsedAt
+ FROM IngredientUsage
+ WHERE AccountID = ?
+ ORDER BY count DESC, lastUsedAt DESC
+ LIMIT ?
+ """,
+ (AccountID, limit),
+ )
+ return [dict(row) for row in cur.fetchall()]
+ finally:
+ con.close()
+
+
def getAccountById(konto_id: int) -> dict | None:
"""Gibt Account-Daten anhand der ID zurück, oder None."""
con = getConnection()
diff --git a/project/backend/Models.py b/project/backend/Models.py
index 492d829..12d4943 100644
--- a/project/backend/Models.py
+++ b/project/backend/Models.py
@@ -36,4 +36,15 @@ class ResetPasswordRequest(BaseModel):
class UpdateUser(BaseModel):
email: str | None = None
currentPassword: str | None = None
- newPassword: str | None = None
\ No newline at end of file
+ newPassword: str | None = None
+
+
+class IngredientSearch(BaseModel):
+ name: str
+ amount: float
+ unit: str
+
+
+class RecipeSearchRequest(BaseModel):
+ zutaten: list[IngredientSearch]
+ servings: int
\ No newline at end of file
diff --git a/project/backend/Routes.py b/project/backend/Routes.py
index 924071d..e2107e7 100644
--- a/project/backend/Routes.py
+++ b/project/backend/Routes.py
@@ -1,6 +1,6 @@
import os
from typing import Annotated
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi.security import OAuth2PasswordRequestForm
from EmailService import sendPasswordChangedEmail, sendPasswordResetEmail
@@ -26,9 +26,11 @@
updateAccount,
markResetTokenUsed,
updateKontoPassword,
+ incrementIngredientUsage,
+ getTopIngredients,
)
-from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest, ForgotPasswordRequest, ResetPasswordRequest, UpdateUser
+from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest, ForgotPasswordRequest, ResetPasswordRequest, UpdateUser, RecipeSearchRequest
# Frontend-URL aus Env, mit Dev-Fallback (Frontend läuft per compose.yaml auf Port 8000)
FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:8000")
@@ -196,3 +198,62 @@ async def resetPassword(body: ResetPasswordRequest):
print(f"Bestätigungsmail konnte nicht gesendet werden: {e}")
return {"detail": "Passwort erfolgreich zurückgesetzt."}
+
+
+# ── Recipe-Suche ───────────────────────────────────────────────
+
+@router.post("/recipes/search")
+async def searchRecipes(
+ body: RecipeSearchRequest,
+ currentUser: Annotated[User, Depends(getCurrentUser)],
+):
+ """Sucht Rezepte basierend auf den übergebenen Zutaten.
+
+ Hinweis: Aktuell nur Usage-Tracking implementiert – die eigentliche
+ Rezept-Suche folgt. Jede gesuchte Zutat erhöht den persönlichen Counter
+ des Users (IngredientUsage), damit später die Top-5-Vorschläge daraus
+ abgeleitet werden können.
+ """
+ Account = getAccountByEmail(currentUser.email)
+ if Account is None:
+ raise HTTPException(status_code=404, detail="Account nicht gefunden")
+
+ # Usage-Tracking: jede gesuchte Zutat hochzählen (Hybrid: count + lastUsedAt + lastUnit)
+ for zutat in body.zutaten:
+ incrementIngredientUsage(Account["id"], zutat.name, zutat.unit)
+
+ # Aktualisierte Top 5 direkt mit zurückgeben → Frontend muss keinen Extra-Request machen
+ topRows = getTopIngredients(Account["id"], limit=5)
+ topIngredients = [
+ {"name": r["displayName"], "unit": r["lastUnit"]}
+ for r in topRows
+ ]
+
+ # TODO: eigentliche Rezept-Suche implementieren
+ return {"rezepte": [], "topIngredients": topIngredients}
+
+
+# ── Ingredient-Vorschläge ──────────────────────────────────────
+
+@router.get("/ingredients/top")
+async def getTopIngredientsForUser(
+ response: Response,
+ currentUser: Annotated[User, Depends(getCurrentUser)],
+ limit: int = 5,
+):
+ """Liefert die meistgenutzten Zutaten des aktuellen Users für die Vorschlags-Badges."""
+ Account = getAccountByEmail(currentUser.email)
+ if Account is None:
+ raise HTTPException(status_code=404, detail="Account nicht gefunden")
+
+ # Caching unterbinden – die Liste ändert sich mit jeder Suche
+ response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
+ response.headers["Pragma"] = "no-cache"
+
+ rows = getTopIngredients(Account["id"], limit=limit)
+ return {
+ "ingredients": [
+ {"name": r["displayName"], "unit": r["lastUnit"]}
+ for r in rows
+ ]
+ }
diff --git a/project/frontend/app/homepage/forgotPassword.tsx b/project/frontend/app/homepage/forgotPassword.tsx
index 0186cac..cd2ec07 100644
--- a/project/frontend/app/homepage/forgotPassword.tsx
+++ b/project/frontend/app/homepage/forgotPassword.tsx
@@ -9,20 +9,34 @@ export default function ForgotPasswordForm({ onClose, onBack,}: { onClose: () =>
const [busy, setBusy] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [emailBlurred, setEmailBlurred] = useState(false);
+ const [error, setError] = useState("");
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const handleSubmit = async () => {
setEmailBlurred(true);
+ setError("");
if (!emailValid) return;
setBusy(true);
try {
- await fetch(`${API_URL}/auth/forgot-password`, {
+ const res = await fetch(`${API_URL}/auth/forgot-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
+ if (!res.ok) {
+ const data = await res.json().catch(() => null);
+ setError(data?.detail ?? `Fehler beim Anfordern (Status ${res.status}).`);
+ return;
+ }
setSubmitted(true);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : "";
+ setError(
+ msg.includes("Failed to fetch") || msg === "Network Error"
+ ? "Backend nicht erreichbar."
+ : (msg || "Unbekannter Fehler.")
+ );
} finally {
setBusy(false);
}
@@ -50,6 +64,7 @@ export default function ForgotPasswordForm({ onClose, onBack,}: { onClose: () =>
Gib deine E-Mail-Adresse ein. Wir schicken dir einen Link zum
Zurücksetzen.
+ {error && {error}
}
void;
placeholder: string;
onKeyDown?: (e: React.KeyboardEvent) => void;
+ ariaLabel?: string;
}) {
const [show, setShow] = useState(false);
return (
@@ -19,6 +20,7 @@ function PasswordInput({ value, onChange, placeholder, onKeyDown }: {
onChange(e.target.value)}
onKeyDown={onKeyDown}
@@ -29,7 +31,7 @@ function PasswordInput({ value, onChange, placeholder, onKeyDown }: {
type="button"
onClick={() => setShow((s) => !s)}
aria-label={show ? "Passwort verbergen" : "Passwort anzeigen"}
- tabIndex={-1}
+ aria-pressed={show}
style={{
position: "absolute",
right: 10,
@@ -70,7 +72,21 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps
async function handlePasswordChange() {
setPasswordMsg("");
- // Bestätigung prüfen (nur im forgot-Modus relevant, aber sinnvoll auch beim Ändern)
+ // Pflichtfelder im "change"-Modus
+ if (!isForgot && !currentPassword) {
+ setPasswordMsg("Bitte das aktuelle Passwort eingeben.");
+ return;
+ }
+ if (!newPassword) {
+ setPasswordMsg("Bitte ein neues Passwort eingeben.");
+ return;
+ }
+ if (!confirmPassword) {
+ setPasswordMsg("Bitte das neue Passwort bestätigen.");
+ return;
+ }
+
+ // Bestätigung prüfen
if (newPassword !== confirmPassword) {
setPasswordMsg("Die Passwörter stimmen nicht überein.");
return;
diff --git a/project/frontend/app/profile/page.tsx b/project/frontend/app/profile/page.tsx
index d4ac84f..a6be68f 100644
--- a/project/frontend/app/profile/page.tsx
+++ b/project/frontend/app/profile/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import {useEffect, useRef, useState} from "react";
+import {useEffect, useState} from "react";
import { useAuth, fetchWithAuth } from "@/lib/auth";
import { useRouter } from "next/navigation";
import {ChefHat} from "lucide-react";
@@ -17,7 +17,6 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000";
export default function Profile() {
const { user, loading, logout } = useAuth();
const router = useRouter();
- const menuRef = useRef(null);
const [showConfirm, setShowConfirm] = useState(false);
@@ -94,7 +93,7 @@ export default function Profile() {
diff --git a/project/frontend/app/recipeFinder/page.tsx b/project/frontend/app/recipeFinder/page.tsx
index a0a276b..3221f2f 100644
--- a/project/frontend/app/recipeFinder/page.tsx
+++ b/project/frontend/app/recipeFinder/page.tsx
@@ -1,9 +1,9 @@
"use client";
-import {useEffect, useRef, useState} from "react";
+import {useCallback, useEffect, useRef, useState} from "react";
import { useRouter } from "next/navigation";
import {fetchWithAuth, useAuth} from "@/lib/auth";
-import {ChefHat, LogOut, X, User, UserCircle, Search, Plus} from "lucide-react";
+import {ChefHat, X, Search, Plus} from "lucide-react";
import "./style.css"
import {Button} from "@/app/components/ui/button";
import Modal from "@/app/components/modal";
@@ -18,8 +18,13 @@ interface IngredientInput {
unit: string;
}
+interface Suggestion {
+ name: string;
+ unit: string | null;
+}
+
export default function RecipeFinder() {
- const { user, loading, logout } = useAuth();
+ const { user, loading } = useAuth();
const router = useRouter();
const [open, setOpen] = useState(false);
@@ -47,12 +52,54 @@ export default function RecipeFinder() {
const [modalOpen, setModalOpen] = useState(false);
+ // Vorschläge: localStorage als sofortiger Initialwert (instant beim Öffnen),
+ // im Hintergrund per useEffect aktualisiert.
+ const [suggestions, setSuggestions] = useState(() => {
+ try {
+ const saved = localStorage.getItem("ingredientSuggestions");
+ return saved ? JSON.parse(saved) : [];
+ } catch {
+ return [];
+ }
+ });
+
useEffect(() => {
if (!loading && !user) {
router.replace("/");
}
}, [loading, user, router]);
+ // Top 5 Zutaten vom Backend laden – als wiederverwendbare Funktion,
+ // damit wir sie sowohl beim Page-Load als auch beim Popup-Öffnen aufrufen können.
+ // cache: "no-store" + Cache-Buster, damit wir wirklich frische Daten bekommen
+ // und nicht eine vom Browser gecachte Antwort.
+ const fetchSuggestions = useCallback(async () => {
+ try {
+ const res = await fetchWithAuth(`/ingredients/top?limit=5&_=${Date.now()}`, {
+ cache: "no-store",
+ headers: { "Cache-Control": "no-cache" },
+ });
+ if (!res.ok) return;
+ const data = await res.json();
+ if (Array.isArray(data.ingredients)) {
+ setSuggestions(data.ingredients);
+ localStorage.setItem("ingredientSuggestions", JSON.stringify(data.ingredients));
+ }
+ } catch {
+ // bei Fehler den localStorage-Wert behalten
+ }
+ }, []);
+
+ // Initial nach dem Login laden (damit sie beim ersten Popup-Öffnen sofort da sind)
+ useEffect(() => {
+ if (user) fetchSuggestions();
+ }, [user, fetchSuggestions]);
+
+ // Bei jedem Öffnen des Popups erneut laden – aktuelle Daten nach jeder Suche
+ useEffect(() => {
+ if (modalOpen && user) fetchSuggestions();
+ }, [modalOpen, user, fetchSuggestions]);
+
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
@@ -115,6 +162,12 @@ export default function RecipeFinder() {
});
const data = await res.json();
setResults(data.rezepte ?? []);
+ // Aktualisierte Top 5 direkt aus der Search-Response übernehmen –
+ // beim nächsten Popup-Öffnen sind die Vorschläge sofort aktuell, ohne extra Roundtrip
+ if (Array.isArray(data.topIngredients)) {
+ setSuggestions(data.topIngredients);
+ localStorage.setItem("ingredientSuggestions", JSON.stringify(data.topIngredients));
+ }
} catch {
setSearchError("Suche fehlgeschlagen.");
} finally {
@@ -150,7 +203,11 @@ export default function RecipeFinder() {
Zutaten
-
diff --git a/project/frontend/app/recipeFinder/popup.tsx b/project/frontend/app/recipeFinder/popup.tsx
index ec2f81b..444545b 100644
--- a/project/frontend/app/recipeFinder/popup.tsx
+++ b/project/frontend/app/recipeFinder/popup.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useRef, useState } from "react";
import "./style.css";
const EINHEITEN = ["Stück", "g", "kg", "ml", "l", "EL", "TL", "Prise"];
@@ -9,18 +9,25 @@ export interface IngredientInput {
unit: string;
}
+export interface Suggestion {
+ name: string;
+ unit: string | null;
+}
+
interface Props {
ingredients: IngredientInput[];
onAdd: (ingredient: IngredientInput) => void;
servings: number;
onServingsChange: (s: number) => void;
+ suggestions?: Suggestion[];
}
-export default function AddIngredientsPopup({ ingredients, onAdd}: Props) {
+export default function AddIngredientsPopup({ ingredients, onAdd, suggestions = [] }: Props) {
const [ingredientName, setIngredientName] = useState("");
const [ingredientAmount, setIngredientAmount] = useState("");
const [ingredientUnit, setIngredientUnit] = useState("Stück");
const [inputError, setInputError] = useState("");
+ const amountInputRef = useRef
(null);
const handleAddIngredient = () => {
const trimmedName = ingredientName.trim();
@@ -40,6 +47,21 @@ export default function AddIngredientsPopup({ ingredients, onAdd}: Props) {
setInputError("");
};
+ const handleSuggestionClick = (s: Suggestion) => {
+ setIngredientName(s.name);
+ if (s.unit && EINHEITEN.includes(s.unit)) {
+ setIngredientUnit(s.unit);
+ }
+ setInputError("");
+ // Fokus aufs Mengen-Feld – User muss nur noch die Menge tippen
+ setTimeout(() => amountInputRef.current?.focus(), 0);
+ };
+
+ // Vorschläge bleiben sichtbar – bereits hinzugefügte werden disabled markiert,
+ // damit das Popup seine Größe behält.
+ const isAlreadyAdded = (s: Suggestion) =>
+ ingredients.some(z => z.name.toLowerCase() === s.name.toLowerCase());
+
return (
Zutaten Hinzufügen
@@ -54,6 +76,7 @@ export default function AddIngredientsPopup({ ingredients, onAdd}: Props) {
className="popup__input"
/>
setIngredientAmount(e.target.value)}
@@ -71,6 +94,29 @@ export default function AddIngredientsPopup({ ingredients, onAdd}: Props) {
+ {suggestions.length > 0 && (
+
+
Häufig verwendet
+
+ {suggestions.map(s => {
+ const added = isAlreadyAdded(s);
+ return (
+ !added && handleSuggestionClick(s)}
+ className={`popup__suggestion-badge${added ? " popup__suggestion-badge--added" : ""}`}
+ title={added ? "Bereits hinzugefügt" : (s.unit ? `${s.name} (${s.unit})` : s.name)}
+ >
+ {s.name}
+
+ );
+ })}
+
+
+ )}
+
{inputError && {inputError}
}
@@ -78,4 +124,4 @@ export default function AddIngredientsPopup({ ingredients, onAdd}: Props) {
);
-}
\ No newline at end of file
+}
diff --git a/project/frontend/app/recipeFinder/style.css b/project/frontend/app/recipeFinder/style.css
index f32da98..63041c9 100644
--- a/project/frontend/app/recipeFinder/style.css
+++ b/project/frontend/app/recipeFinder/style.css
@@ -147,6 +147,62 @@
margin: 0;
}
+/* ── Zutaten-Vorschläge (Top 5 Badges) ─────────────────────── */
+.popup__suggestions {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.popup__suggestions-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: #6b7280;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin: 0;
+ font-family: system-ui;
+}
+
+.popup__suggestions-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.popup__suggestion-badge {
+ padding: 5px 12px;
+ background: #f3f3f5;
+ border: 1px solid #e5e7eb;
+ border-radius: 999px;
+ font-size: 13px;
+ font-family: system-ui;
+ color: #111;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
+}
+
+.popup__suggestion-badge:hover {
+ background: #030213;
+ border-color: #030213;
+ color: #fff;
+}
+
+.popup__suggestion-badge:focus-visible {
+ outline: 2px solid #030213;
+ outline-offset: 2px;
+}
+
+.popup__suggestion-badge--added,
+.popup__suggestion-badge--added:hover {
+ background: #f3f3f5;
+ border-color: #e5e7eb;
+ color: #9ca3af;
+ cursor: default;
+ text-decoration: line-through;
+ opacity: 0.6;
+}
+
.popup__btn {
display: block;
margin: 0 auto;
diff --git a/project/frontend/lib/auth.tsx b/project/frontend/lib/auth.tsx
index 2d6c1ca..64c79d9 100644
--- a/project/frontend/lib/auth.tsx
+++ b/project/frontend/lib/auth.tsx
@@ -49,6 +49,7 @@ function clearTokens() {
sessionStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("ingredients");
+ localStorage.removeItem("ingredientSuggestions");
}
// ── API-Aufrufe ───────────────────────────────────────────────
@@ -107,7 +108,7 @@ async function fetchWithAuth(url: string, options: RequestInit = {}): Promise