diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml index 6ca4331..4a97d92 100644 --- a/.idea/sqldialects.xml +++ b/.idea/sqldialects.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/README.md b/README.md index f644c44..978af29 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ LazyCook soll eine Webanwendung sein. Die es ermöglichen seine vorhanden Zutaten einzutragen und darauf soll LazyCook dir mögliche Rezepte/Gerichte vorschlagen die du aus diesen Zutaten gemacht werden können. ## Features -- [ ] [#23](https://github.com/GalacticCodeGambit/LazyCook/issues/23) Zutaten hinzufügen können -- [ ] [#24](https://github.com/GalacticCodeGambit/LazyCook/issues/24) Zutaten entfernen können +- [x] [#23](https://github.com/GalacticCodeGambit/LazyCook/issues/23) Zutaten hinzufügen können +- [x] [#24](https://github.com/GalacticCodeGambit/LazyCook/issues/24) Zutaten entfernen können - [ ] [#25](https://github.com/GalacticCodeGambit/LazyCook/issues/25) Rezepte/Gerichte automatisch vorgeschlagen bekommen auf Grundlage der eingetragenen Zutaten - [ ] [#30](https://github.com/GalacticCodeGambit/LazyCook/issues/30) Personenzahl hinzufügen können und vorgeschlagene Rezept/Gericht Größe automatisch anpassen - [x] [#27](https://github.com/GalacticCodeGambit/LazyCook/issues/27) Als User mit Konto anmelden können @@ -18,8 +18,8 @@ LazyCook soll eine Webanwendung sein. Die es ermöglichen seine vorhanden Zutate - [ ] [#46](https://github.com/GalacticCodeGambit/LazyCook/issues/46) Konto löschen/bearbeiten können - [ ] Fehlende Zutaten werden von einem Rezept in einer Einkaufliste aufgelistet - [ ] Der Nutzer kann Rezepte in die Datenbank hinzufügen/erstellen -- [ ] [#47](https://github.com/GalacticCodeGambit/LazyCook/issues/47) Der Nutzer kann bereits hinzugefügte Zutaten bearbeiten, wie z.B. Menge, Einheit -- [ ] [#45](https://github.com/GalacticCodeGambit/LazyCook/issues/45) Passwörter "sicher" in Datenbank Abspeichern als Hash und vom Frontend/Backend "sicher" behandeln +- [x] [#47](https://github.com/GalacticCodeGambit/LazyCook/issues/47) Der Nutzer kann bereits hinzugefügte Zutaten bearbeiten, wie z.B. Menge, Einheit +- [x] [#45](https://github.com/GalacticCodeGambit/LazyCook/issues/45) Passwörter "sicher" in Datenbank Abspeichern als Hash und vom Frontend/Backend "sicher" behandeln ## Verwendete Technologien - Frontend: HTML, CSS, TypeScript/React diff --git a/docs/RMMM-Liste.pdf b/docs/RMMM-Liste.pdf deleted file mode 100644 index 25c55db..0000000 Binary files a/docs/RMMM-Liste.pdf and /dev/null differ diff --git a/docs/Risikomanagement/RMMM-Liste.pdf b/docs/Risikomanagement/RMMM-Liste.pdf new file mode 100644 index 0000000..ef0130c Binary files /dev/null and b/docs/Risikomanagement/RMMM-Liste.pdf differ diff --git a/docs/Risikomanagement/RMMM-Liste.xlsx b/docs/Risikomanagement/RMMM-Liste.xlsx new file mode 100644 index 0000000..a0acf2c Binary files /dev/null and b/docs/Risikomanagement/RMMM-Liste.xlsx differ diff --git a/project/backend/auth.py b/project/backend/auth.py index d1529f1..8996f22 100644 --- a/project/backend/auth.py +++ b/project/backend/auth.py @@ -13,6 +13,8 @@ import bcrypt from pydantic import BaseModel +from models import Token, User + from Datenbank import get_konto_by_email, save_refresh_token, get_refresh_token # ── Konfiguration ────────────────────────────────────────────── @@ -21,26 +23,8 @@ ACCESS_TOKEN_EXPIRE_MINUTES = 10 # kurze Laufzeit für Access Token REFRESH_TOKEN_EXPIRE_DAYS = 7 # lange Laufzeit für Refresh Token -# ── Modelle ──────────────────────────────────────────────────── -class Token(BaseModel): - access_token: str - refresh_token: str - token_type: str - -class User(BaseModel): - email: str - name: str - -class UserCreate(BaseModel): - email: str - name: str - password: str -class RefreshRequest(BaseModel): - refresh_token: str -class LogoutRequest(BaseModel): - refresh_token: str # ── Passwort-Hashing ────────────────────────────────────────── def hash_password(password: str) -> str: diff --git a/project/backend/models.py b/project/backend/models.py index b37f549..a4fafcc 100644 --- a/project/backend/models.py +++ b/project/backend/models.py @@ -12,4 +12,10 @@ class User(BaseModel): class UserCreate(BaseModel): email: str name: str - password: str \ No newline at end of file + password: str + +class RefreshRequest(BaseModel): + refresh_token: str + +class LogoutRequest(BaseModel): + refresh_token: str \ No newline at end of file diff --git a/project/backend/routes.py b/project/backend/routes.py index 1a12df5..7fa9312 100644 --- a/project/backend/routes.py +++ b/project/backend/routes.py @@ -4,11 +4,6 @@ from fastapi.security import OAuth2PasswordRequestForm from auth import ( - Token, - User, - UserCreate, - RefreshRequest, - LogoutRequest, create_token_pair, get_current_user, hash_password, @@ -16,16 +11,16 @@ validate_password, validate_refresh_token, verify_password, - create_access_token, ) from Datenbank import ( create_konto, get_konto_by_email, delete_refresh_token, - delete_all_refresh_tokens, delete_konto, ) +from models import Token, User, RefreshRequest, LogoutRequest, UserCreate + router = APIRouter() @@ -100,4 +95,8 @@ async def read_current_user(current_user: Annotated[User, Depends(get_current_us @router.delete("/users/me", status_code=status.HTTP_204_NO_CONTENT) async def delete_current_user(current_user: Annotated[User, Depends(get_current_user)]): """Löscht das eigene Konto inkl. aller Refresh Tokens (CASCADE).""" - delete_konto(current_user.email) \ No newline at end of file + delete_konto(current_user.email) + +@router.post("/recipes/search") +async def searchFittingRecipes(current_user: Annotated[User, Depends(get_current_user)]): + pass diff --git a/project/frontend/app/components/modal.tsx b/project/frontend/app/components/modal.tsx index b7fa542..dded141 100644 --- a/project/frontend/app/components/modal.tsx +++ b/project/frontend/app/components/modal.tsx @@ -3,7 +3,7 @@ import { useEffect, type ReactNode } from "react"; import styles from "../homepage/page.module.css"; -export default function Modal({ open, onCloseAction, children }: { open: boolean; onCloseAction: () => void; children: ReactNode }) { +export default function Modal({ open, onCloseAction, children}: { open: boolean; onCloseAction: () => void; children: ReactNode;}) { useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => e.key === "Escape" && onCloseAction(); diff --git a/project/frontend/app/homepage/homepage.tsx b/project/frontend/app/homepage/homepage.tsx index bd8c06e..5c04f67 100644 --- a/project/frontend/app/homepage/homepage.tsx +++ b/project/frontend/app/homepage/homepage.tsx @@ -11,7 +11,7 @@ import RegisterForm from "@/app/homepage/signup"; export default function Homepage() { - const [modal, setModal] = useState<"login" | "register" | null>(null); + const [modal, setModal] = useState<"login" | "register" | null>(null); const close = useCallback(() => setModal(null), []); diff --git a/project/frontend/app/homepage/page.module.css b/project/frontend/app/homepage/page.module.css index 1ecbc91..f148a16 100644 --- a/project/frontend/app/homepage/page.module.css +++ b/project/frontend/app/homepage/page.module.css @@ -25,8 +25,8 @@ background: #fff; border-radius: 12px; padding: 2rem 2rem 1.75rem; - width: 100%; - max-width: 420px; + width: auto; + max-width: 520px; position: relative; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.12); animation: slideUp 0.2s ease-out; diff --git a/project/frontend/app/recipeFinder/page.tsx b/project/frontend/app/recipeFinder/page.tsx index 0300efb..af0ec13 100644 --- a/project/frontend/app/recipeFinder/page.tsx +++ b/project/frontend/app/recipeFinder/page.tsx @@ -2,10 +2,20 @@ import {useEffect, useRef, useState} from "react"; import { useRouter } from "next/navigation"; -import { useAuth } from "@/lib/auth"; -import {ChefHat, LogOut} from "lucide-react"; -import { User, UserCircle } from "lucide-react"; +import {fetchWithAuth, useAuth} from "@/lib/auth"; +import {ChefHat, LogOut, X, User, UserCircle, Search, Plus} from "lucide-react"; import "./style.css" +import {Button} from "@/app/components/ui/button"; +import Modal from "@/app/components/modal"; +import AddIngredientsPopup from "@/app/recipeFinder/popup"; + +const EINHEITEN = ["Stück", "g", "kg", "ml", "l", "EL", "TL", "Prise"]; + +interface IngredientInput { + name: string; + amount: number; + unit: string; +} export default function RecipeFinder() { const { user, loading, logout } = useAuth(); @@ -14,7 +24,28 @@ export default function RecipeFinder() { const [open, setOpen] = useState(false); const menuRef = useRef(null); - // Nicht eingeloggt → zurück zur Startseite + const [ingredients, setIngredients] = useState(() => { + try { + const saved = localStorage.getItem("ingredients"); + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } + }); + + const [servings, setServings] = useState(1); + + const [searching, setSearching] = useState(false); + const [results, setResults] = useState(null); + + const [editingIngredient, setEditingIngredient] = useState(null); + const [editAmount, setEditAmount] = useState(""); + const [editUnit, setEditUnit] = useState("Stück"); + + const [searchError, setSearchError] = useState(""); + + const [modalOpen, setModalOpen] = useState(false); + useEffect(() => { if (!loading && !user) { router.replace("/"); @@ -31,6 +62,10 @@ export default function RecipeFinder() { return () => document.removeEventListener("mousedown", handleClickOutside); }, []); + useEffect(() => { + localStorage.setItem("ingredients", JSON.stringify(ingredients)); + }, [ingredients]); + if (loading) { return (
@@ -41,12 +76,61 @@ export default function RecipeFinder() { if (!user) return null; + const handleLogout = () => { + logout(); + router.replace("/"); + } const handleProfil = () => { setOpen(false); console.log("Profil ansehen"); router.push("/profile"); }; + const handleAdd = (ingredient: IngredientInput) => { + setIngredients(prev => [...prev, ingredient]); + }; + + const handleRemoveIngredient = (name: string) => { + setIngredients(prev => prev.filter(z => z.name !== name)); + } + + const handleEditSave = (name: string) => { + const amountNum = parseFloat(editAmount); + if (!editAmount || isNaN(amountNum) || amountNum <= 0) return; + setIngredients(prev => prev.map(i => + i.name === name ? { ...i, amount: amountNum, unit: editUnit } : i + )); + setEditingIngredient(null); + }; + + const handleEditStart = (i: IngredientInput) => { + setEditingIngredient(i.name); + setEditAmount(String(i.amount)); + setEditUnit(i.unit); + }; + + const handleSearch = async () => { + if (ingredients.length === 0) { + setSearchError("Bitte mindestens eine Zutat hinzufügen."); + return; + } + setSearchError(""); + setSearching(true); + try { + const res = await fetchWithAuth('/recipes/search', { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({zutaten: ingredients, servings}), + }); + const data = await res.json(); + setResults(data.rezepte ?? []); + } catch { + setSearchError("Suche fehlgeschlagen."); + } finally { + setSearching(false); + } + }; + return (
{/* Navbar */} @@ -58,9 +142,7 @@ export default function RecipeFinder() {
@@ -85,7 +167,7 @@ export default function RecipeFinder() { Profil ansehen
- {/* Dashboard Content */} -
-

Dashboard

-

Willkommen zurück, {user.name}!

+
+ {/* Sidebar */} +
+ + {/* Personenanzahl */} +
+

Personen

+
+ +
+
{servings}
+
Personen
+
+ +
+
+ + {/* Suche starten */} +
+ {searchError &&

{searchError}

} + +
+ + + setModalOpen(false)}> + + +
); } \ No newline at end of file diff --git a/project/frontend/app/recipeFinder/popup.tsx b/project/frontend/app/recipeFinder/popup.tsx new file mode 100644 index 0000000..ec2f81b --- /dev/null +++ b/project/frontend/app/recipeFinder/popup.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import "./style.css"; + +const EINHEITEN = ["Stück", "g", "kg", "ml", "l", "EL", "TL", "Prise"]; + +export interface IngredientInput { + name: string; + amount: number; + unit: string; +} + +interface Props { + ingredients: IngredientInput[]; + onAdd: (ingredient: IngredientInput) => void; + servings: number; + onServingsChange: (s: number) => void; +} + +export default function AddIngredientsPopup({ ingredients, onAdd}: Props) { + const [ingredientName, setIngredientName] = useState(""); + const [ingredientAmount, setIngredientAmount] = useState(""); + const [ingredientUnit, setIngredientUnit] = useState("Stück"); + const [inputError, setInputError] = useState(""); + + const handleAddIngredient = () => { + const trimmedName = ingredientName.trim(); + const amountNum = parseFloat(ingredientAmount); + + if (!trimmedName) { setInputError("Bitte eine Zutat eingeben."); return; } + if (!ingredientAmount || isNaN(amountNum) || amountNum <= 0) { setInputError("Bitte eine gültige Menge eingeben."); return; } + if (ingredients.some(z => z.name.toLowerCase() === trimmedName.toLowerCase())) { + setInputError("Diese Zutat wurde bereits hinzugefügt."); + return; + } + + onAdd({ name: trimmedName, amount: amountNum, unit: ingredientUnit }); + setIngredientName(""); + setIngredientAmount(""); + setIngredientUnit("Stück"); + setInputError(""); + }; + + return ( +
+

Zutaten Hinzufügen

+ +
+ setIngredientName(e.target.value)} + onKeyDown={e => e.key === "Enter" && handleAddIngredient()} + placeholder="z.B. Tomaten" + className="popup__input" + /> + setIngredientAmount(e.target.value)} + onKeyDown={e => e.key === "Enter" && handleAddIngredient()} + placeholder="Menge" + min={0} + className="popup__input popup__input--amount" + /> + +
+ + {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 888974b..fdc8996 100644 --- a/project/frontend/app/recipeFinder/style.css +++ b/project/frontend/app/recipeFinder/style.css @@ -71,4 +71,406 @@ opacity: 1; transform: translateY(0); } +} + +/* ── AddIngredientsPopup ───────────────────────────────────── */ +.popup { + display: flex; + flex-direction: column; + gap: 20px; +} + +.popup__title { + font-size: 20px; + font-weight: 600; + color: #111; + font-family: system-ui; + text-align: center; + margin: 0; +} + +.popup__fields { + display: flex; + gap: 10px; +} + +.popup__input { + flex: 1; + padding: 10px 12px; + border-radius: 6px; + border: 1px solid #ddd; + font-size: 14px; + font-family: system-ui; + outline: none; + transition: border-color 0.15s; + background: #f3f3f5; + box-sizing: border-box; +} + +.popup__input:focus { + border-color: #030213; + background: #fff; +} + +.popup__input--amount { + flex: 0 0 90px; + width: 90px; + min-width: 0; +} + +.popup__select { + padding: 10px 12px; + border-radius: 6px; + border: 1px solid #ddd; + font-size: 14px; + font-family: system-ui; + outline: none; + background: #f3f3f5; + cursor: pointer; + transition: border-color 0.15s; +} + +.popup__select:focus { + border-color: #030213; + background: #fff; +} + +.popup__error { + font-size: 13px; + color: #b91c1c; + font-family: system-ui; + text-align: center; + margin: 0; +} + +.popup__btn { + display: block; + margin: 0 auto; + padding: 10px 32px; + background: #030213; + color: #fff; + border: none; + border-radius: 6px; + font-size: 14px; + font-family: system-ui; + cursor: pointer; + transition: background-color 0.15s; +} + +.popup__btn:hover { + background: rgba(3, 2, 19, 0.85); +} + +/* ── RecipeFinder Layout ───────────────────────────────────── */ +.finder-layout { + display: flex; + height: calc(100vh - 57px); +} + +.finder-sidebar { + width: 300px; + flex-shrink: 0; + border-right: 1px solid #e5e7eb; + display: flex; + flex-direction: column; + gap: 0; + overflow-y: auto; + background: #fff; +} + +.finder-sidebar__section { + padding: 20px; + border-bottom: 1px solid #e5e7eb; +} + +.finder-sidebar__title { + font-size: 13px; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 12px 0; + font-family: system-ui; +} + +.finder-sidebar__add-btn { + flex: 1; + padding: 10px; + background: #030213; + color: #fff; + border: none; + border-radius: 8px; + font-size: 14px; + font-family: system-ui; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: background 0.15s; +} + +.finder-sidebar__add-btn:hover { + background: rgba(3, 2, 19, 0.85); +} + +.finder-sidebar__ingredient-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 12px; +} + +.finder-sidebar__ingredient { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + background: #f3f3f5; + border-radius: 8px; + font-size: 13px; + font-family: system-ui; +} + +.finder-sidebar__ingredient-name { + font-weight: 500; + color: #111; +} + +.finder-sidebar__ingredient-amount { + color: #6b7280; + font-size: 12px; +} + +.finder-sidebar__ingredient-remove { + background: none; + border: none; + cursor: pointer; + color: #9ca3af; + display: flex; + align-items: center; + padding: 0; + transition: color 0.1s; +} + +.finder-sidebar__ingredient-remove:hover { + color: #ef4444; +} + +.finder-sidebar__empty { + font-size: 13px; + color: #9ca3af; + font-family: system-ui; + margin: 8px 0 0 0; +} + +/* Personenanzahl */ +.finder-sidebar__persons { + display: flex; + align-items: center; + gap: 16px; +} + +.finder-sidebar__persons-btn { + width: 32px; + height: 32px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.1s; +} + +.finder-sidebar__persons-btn:hover { + background: #f3f4f6; +} + +.finder-sidebar__persons-count { + font-size: 22px; + font-weight: 600; + font-family: system-ui; + color: #111; + min-width: 24px; + text-align: center; +} + +.finder-sidebar__persons-label { + font-size: 12px; + color: #6b7280; + font-family: system-ui; +} + +/* Suchen Button */ +.finder-sidebar__search-btn { + width: 100%; + padding: 12px; + background: #030213; + color: #fff; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + font-family: system-ui; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: background 0.15s; +} + +.finder-sidebar__search-btn:hover:not(:disabled) { + background: rgba(3, 2, 19, 0.85); +} + +.finder-sidebar__search-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Rezepte Bereich */ +.finder-content { + flex: 1; + overflow-y: auto; + padding: 24px; + background: #fafafa; +} + +.finder-content__empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #9ca3af; + font-size: 14px; + font-family: system-ui; +} + +.finder-content__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 16px; +} + +.finder-content__card { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 16px; + font-family: system-ui; +} + +.finder-content__card-name { + font-size: 15px; + font-weight: 600; + color: #111; + margin: 0; +} + +.finder-sidebar__clear-btn { + flex-shrink: 0; + padding: 10px; + background: #fff; + color: #6b7280; + border: 1px solid #e5e7eb; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, color 0.15s; +} + +.finder-sidebar__clear-btn:hover { + background: #fef2f2; + color: #ef4444; + border-color: #ef4444; +} +.finder-sidebar__ingredient-right { + display: flex; + align-items: center; + gap: 8px; +} + +.finder-sidebar__ingredient-amount { + color: #6b7280; + font-size: 12px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + transition: background 0.1s; +} + +.finder-sidebar__ingredient-amount:hover { + background: #e5e7eb; + color: #111; +} + +.finder-sidebar__ingredient-edit { + display: flex; + align-items: center; + gap: 4px; +} + +.finder-sidebar__edit-input { + width: 52px; + padding: 3px 6px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 12px; + font-family: system-ui; + outline: none; +} + +.finder-sidebar__edit-input:focus { + border-color: #030213; +} + +.finder-sidebar__edit-select { + padding: 3px 4px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 12px; + font-family: system-ui; + background: #fff; + outline: none; +} + +.finder-sidebar__edit-select:focus { + border-color: #030213; +} + +.finder-sidebar__edit-save { + background: #030213; + color: #fff; + border: none; + border-radius: 6px; + padding: 3px 7px; + font-size: 12px; + cursor: pointer; + transition: background 0.15s; +} + +.finder-sidebar__edit-save:hover { + background: rgba(3, 2, 19, 0.8); +} + +.finder-sidebar__edit-cancel { + background: none; + color: #9ca3af; + border: 1px solid #e5e7eb; + border-radius: 6px; + padding: 3px 7px; + font-size: 12px; + cursor: pointer; + transition: color 0.1s; +} + +.finder-sidebar__edit-cancel:hover { + color: #ef4444; + border-color: #ef4444; } \ No newline at end of file diff --git a/project/frontend/lib/auth.tsx b/project/frontend/lib/auth.tsx index 03361db..cae660e 100644 --- a/project/frontend/lib/auth.tsx +++ b/project/frontend/lib/auth.tsx @@ -1,6 +1,3 @@ -// lib/auth.ts – Auth-Kontext mit Access + Refresh Token -// ────────────────────────────────────────────────────── - "use client"; import { @@ -13,7 +10,7 @@ import { type ReactNode, } from "react"; -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; +const API_URL = "http://localhost:3000"; // ── Typen ───────────────────────────────────────────────────── export interface User { @@ -49,6 +46,7 @@ function saveTokens(accessToken: string, refreshToken: string) { function clearTokens() { sessionStorage.removeItem("access_token"); localStorage.removeItem("refresh_token"); + localStorage.removeItem("ingredients"); } // ── API-Aufrufe ─────────────────────────────────────────────── @@ -125,7 +123,7 @@ async function fetchWithAuth(url: string, options: RequestInit = {}): Promise