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

- @@ -240,6 +297,7 @@ export default function RecipeFinder() { onAdd={handleAdd} servings={servings} onServingsChange={setServings} + suggestions={suggestions} />
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 ( + + ); + })} +
+
+ )} + {inputError &&

{inputError}

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