Build backend

This commit is contained in:
dvirlabs 2025-12-01 06:16:39 +02:00
parent 3d81a364dc
commit 0911b811c3
14 changed files with 1034 additions and 243 deletions

121
.woodpecker.yaml Normal file
View File

@ -0,0 +1,121 @@
steps:
# build-frontend:
# name: Build & Push Frontend
# image: woodpeckerci/plugin-kaniko
# when:
# branch: [ master, develop ]
# event: [ push, pull_request, tag ]
# path:
# include: [ frontend/** ]
# settings:
# registry: harbor.dvirlabs.com
# repo: my-apps/${CI_REPO_NAME}-frontend
# dockerfile: frontend/Dockerfile
# context: frontend
# tags:
# - latest
# - ${CI_COMMIT_TAG:-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}}
# username:
# from_secret: DOCKER_USERNAME
# password:
# from_secret: DOCKER_PASSWORD
build-backend:
name: Build & Push Backend
image: woodpeckerci/plugin-kaniko
when:
branch: [ master, develop ]
event: [ push, pull_request, tag ]
path:
include: [ backend/** ]
settings:
registry: harbor.dvirlabs.com
repo: my-apps/${CI_REPO_NAME}-backend
dockerfile: backend/Dockerfile
context: backend
tags:
- latest
- ${CI_COMMIT_TAG:-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}}
username:
from_secret: DOCKER_USERNAME
password:
from_secret: DOCKER_PASSWORD
# update-values-frontend:
# name: Update frontend tag in values.yaml
# image: alpine:3.19
# when:
# branch: [ master, develop ]
# event: [ push ]
# path:
# include: [ frontend/** ]
# environment:
# GIT_USERNAME:
# from_secret: GIT_USERNAME
# GIT_TOKEN:
# from_secret: GIT_TOKEN
# commands:
# - apk add --no-cache git yq
# - git config --global user.name "woodpecker-bot"
# - git config --global user.email "ci@dvirlabs.com"
# - git clone "https://$${GIT_USERNAME}:$${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/my-apps.git"
# - cd my-apps
# - |
# TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
# echo "💡 Setting frontend tag to: $TAG"
# yq -i ".frontend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
# git add manifests/${CI_REPO_NAME}/values.yaml
# git commit -m "frontend: update tag to $TAG" || echo "No changes"
# git push origin HEAD
update-values-backend:
name: Update backend tag in values.yaml
image: alpine:3.19
when:
branch: [ master, develop ]
event: [ push ]
path:
include: [ backend/** ]
environment:
GIT_USERNAME:
from_secret: GIT_USERNAME
GIT_TOKEN:
from_secret: GIT_TOKEN
commands:
- apk add --no-cache git yq
- git config --global user.name "woodpecker-bot"
- git config --global user.email "ci@dvirlabs.com"
- |
if [ ! -d "my-apps" ]; then
git clone "https://${GIT_USERNAME}:${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/my-apps.git"
fi
- cd my-apps
- |
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
echo "💡 Setting backend tag to: $TAG"
yq -i ".backend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
git add manifests/${CI_REPO_NAME}/values.yaml
git commit -m "backend: update tag to $TAG" || echo "No changes"
git push origin HEAD
trigger-gitops-via-push:
when:
branch: [ master, develop ]
event: [ push ]
name: Trigger apps-gitops via Git push
image: alpine/git
environment:
GIT_USERNAME:
from_secret: GIT_USERNAME
GIT_TOKEN:
from_secret: GIT_TOKEN
commands: |
git config --global user.name "woodpecker-bot"
git config --global user.email "ci@dvirlabs.com"
git clone "https://$${GIT_USERNAME}:$${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/apps-gitops.git"
cd apps-gitops
echo "# trigger at $(date) by $${CI_REPO_NAME}" >> .trigger
git add .trigger
git commit -m "ci: trigger apps-gitops build" || echo "no changes"
git push origin HEAD

View File

@ -1 +1 @@
DATABASE_URL=postgresql://recipes_user:recipes_password@localhost:5432/recipes_db DATABASE_URL=postgresql://recipes_user:Aa123456@localhost:5432/recipes_db

21
backend/Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# deps for psycopg2
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Binary file not shown.

Binary file not shown.

View File

@ -6,16 +6,39 @@ import psycopg2
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
from dotenv import load_dotenv from dotenv import load_dotenv
# load .env for local/dev; in K8s you'll use environment variables
load_dotenv() load_dotenv()
# Prefer explicit envs (K8s), fallback to DATABASE_URL (local dev)
DB_HOST = os.getenv("DB_HOST")
DB_PORT = os.getenv("DB_PORT", "5432")
DB_NAME = os.getenv("DB_NAME")
DB_USER = os.getenv("DB_USER")
DB_PASSWORD = os.getenv("DB_PASSWORD")
DATABASE_URL = os.getenv( DATABASE_URL = os.getenv(
"DATABASE_URL", "DATABASE_URL",
"postgresql://user:password@localhost:5432/recipes_db", "postgresql://user:password@localhost:5432/recipes_db",
) )
def _build_dsn() -> str:
if DB_HOST and DB_NAME and DB_USER and DB_PASSWORD:
return (
f"dbname={DB_NAME} user={DB_USER} password={DB_PASSWORD} "
f"host={DB_HOST} port={DB_PORT}"
)
if DATABASE_URL:
return DATABASE_URL
raise RuntimeError(
"No DB configuration found. Set DB_HOST/DB_NAME/DB_USER/DB_PASSWORD "
"or DATABASE_URL."
)
def get_conn(): def get_conn():
return psycopg2.connect(DATABASE_URL, cursor_factory=RealDictCursor) dsn = _build_dsn()
return psycopg2.connect(dsn, cursor_factory=RealDictCursor)
def list_recipes_db() -> List[Dict[str, Any]]: def list_recipes_db() -> List[Dict[str, Any]]:

View File

@ -7,7 +7,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: recipes_user POSTGRES_USER: recipes_user
POSTGRES_PASSWORD: recipes_password POSTGRES_PASSWORD: Aa123456
POSTGRES_DB: recipes_db POSTGRES_DB: recipes_db
ports: ports:
- "5432:5432" - "5432:5432"

View File

@ -1,85 +1,433 @@
:root {
--bg: #020617;
--bg-elevated: #020617;
--card: #0b1120;
--card-soft: #020617;
--border-subtle: rgba(148, 163, 184, 0.35);
--accent: #22c55e;
--accent-soft: rgba(34, 197, 94, 0.16);
--accent-strong: #16a34a;
--text-main: #e5e7eb;
--text-muted: #9ca3af;
--danger: #f97373;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
background: radial-gradient(circle at top, #0f172a 0, #020617 55%);
color: var(--text-main);
}
.app-root { .app-root {
max-width: 900px; min-height: 100vh;
max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 1.5rem; padding: 1.5rem;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
direction: rtl; direction: rtl;
text-align: right;
} }
h1 { /* Top bar */
text-align: center;
margin-bottom: 1.5rem; .topbar {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(90deg, #020617, #020617f2);
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.45);
padding: 0.8rem 1.2rem;
margin-bottom: 1.6rem;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.9);
} }
.card { .topbar-left {
border-radius: 12px; display: flex;
border: 1px solid #e5e7eb; align-items: center;
padding: 1.25rem; gap: 0.65rem;
margin-bottom: 1.5rem;
background: #ffffff;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.04);
} }
.grid { .logo-emoji {
font-size: 1.8rem;
}
.brand-title {
font-weight: 800;
font-size: 1.2rem;
}
.brand-subtitle {
font-size: 0.8rem;
color: var(--text-muted);
}
/* Layout */
.layout {
display: grid; display: grid;
gap: 1.4rem;
}
@media (min-width: 960px) {
.layout {
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
}
}
.sidebar,
.content {
display: flex;
flex-direction: column;
gap: 1rem; gap: 1rem;
} }
@media (min-width: 768px) { /* Panels */
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid .field:last-child { .panel {
grid-column: 1 / -1; background: var(--card);
border-radius: 18px;
padding: 1.1rem 1.2rem;
border: 1px solid var(--border-subtle);
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.7);
}
.panel.secondary {
background: var(--card-soft);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.badge {
background: rgba(148, 163, 184, 0.2);
border-radius: 999px;
padding: 0.1rem 0.55rem;
font-size: 0.75rem;
}
/* Filter panel */
.filter-panel h3 {
margin-top: 0;
margin-bottom: 0.6rem;
}
.panel-grid {
display: grid;
gap: 0.7rem;
margin-bottom: 0.8rem;
}
@media (min-width: 720px) {
.panel-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
.field { .field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 0.3rem;
} }
.field label { .field label {
font-weight: 600; font-size: 0.85rem;
font-size: 0.9rem; color: var(--text-muted);
}
.field-full {
grid-column: 1 / -1;
} }
input, input,
select, select {
textarea, border-radius: 10px;
button { border: 1px solid rgba(148, 163, 184, 0.6);
border-radius: 8px; background: #020617;
border: 1px solid #d1d5db; color: var(--text-main);
padding: 0.5rem 0.75rem; padding: 0.4rem 0.65rem;
font-size: 0.95rem; font-size: 0.9rem;
} }
textarea { /* Buttons */
resize: vertical;
}
button { .btn {
background: #2563eb; border-radius: 999px;
color: #ffffff; padding: 0.55rem 1.2rem;
border: none;
font-size: 0.9rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
border: none; transition: transform 0.08s ease, box-shadow 0.08s ease,
margin-top: 0.5rem; background-color 0.08s ease;
} }
button:disabled { .btn.full {
opacity: 0.7; width: 100%;
}
.btn.primary {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #f9fafb;
box-shadow: 0 12px 25px rgba(22, 163, 74, 0.6);
}
.btn.accent {
background: var(--accent-soft);
color: #bbf7d0;
}
.btn.ghost {
background: transparent;
color: var(--text-main);
border: 1px solid rgba(148, 163, 184, 0.5);
}
.btn.small {
padding: 0.25rem 0.7rem;
font-size: 0.8rem;
}
.btn:disabled {
opacity: 0.6;
cursor: default; cursor: default;
} }
.error { .btn:not(:disabled):hover {
color: #b91c1c; transform: translateY(-1px);
font-weight: 600; box-shadow: 0 15px 30px rgba(15, 23, 42, 0.75);
} }
.recipe-card h2 { /* Recipe list */
margin-top: 0;
.recipe-list {
list-style: none;
padding: 0;
margin: 0.6rem 0 0;
max-height: 280px;
overflow: auto;
}
.recipe-list-item {
display: flex;
padding: 0.55rem 0.4rem;
border-radius: 11px;
cursor: pointer;
transition: background-color 0.08s ease, transform 0.05s ease;
}
.recipe-list-item:hover {
background: rgba(15, 23, 42, 0.9);
transform: translateY(-1px);
}
.recipe-list-item.active {
background: rgba(34, 197, 94, 0.2);
border: 1px solid rgba(34, 197, 94, 0.6);
}
.recipe-list-main {
flex: 1;
}
.recipe-list-name {
font-size: 0.92rem;
font-weight: 500;
}
.recipe-list-meta {
display: flex;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--text-muted);
}
.muted {
font-size: 0.85rem;
color: var(--text-muted);
}
/* Recipe details */
.recipe-card {
min-height: 260px;
}
.recipe-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.8rem;
margin-bottom: 0.8rem;
}
.recipe-header h2 {
margin: 0;
font-size: 1.3rem;
}
.recipe-subtitle {
margin: 0.2rem 0 0;
font-size: 0.85rem;
color: var(--text-muted);
}
.pill-row {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.pill {
padding: 0.25rem 0.6rem;
border-radius: 999px;
background: rgba(15, 23, 42, 0.95);
border: 1px solid rgba(148, 163, 184, 0.7);
font-size: 0.78rem;
}
.recipe-body {
display: grid;
gap: 0.8rem;
}
@media (min-width: 720px) {
.recipe-body {
grid-template-columns: 1fr 1.2fr;
}
}
.recipe-column h3 {
margin: 0 0 0.3rem;
font-size: 0.95rem;
}
.recipe-column ul,
.recipe-column ol {
margin: 0;
padding-right: 1rem;
font-size: 0.9rem;
}
.tags {
margin-top: 0.6rem;
}
.tag {
display: inline-block;
margin-left: 0.25rem;
margin-bottom: 0.2rem;
padding: 0.2rem 0.6rem;
border-radius: 999px;
background: rgba(15, 23, 42, 0.9);
border: 1px solid rgba(75, 85, 99, 0.7);
font-size: 0.78rem;
}
/* Placeholder */
.placeholder {
text-align: center;
padding: 2rem 1rem;
color: var(--text-muted);
}
/* Error */
.error-banner {
background: rgba(248, 113, 113, 0.12);
border-radius: 12px;
border: 1px solid rgba(252, 165, 165, 0.7);
padding: 0.6rem 0.8rem;
font-size: 0.85rem;
color: #fecaca;
margin-bottom: 0.6rem;
}
/* Drawer */
.drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.8);
display: flex;
justify-content: flex-start;
align-items: stretch;
z-index: 40;
}
.drawer {
width: min(420px, 90vw);
background: #020617;
border-left: 1px solid var(--border-subtle);
padding: 1rem 1rem 1rem 1.2rem;
box-shadow: 18px 0 40px rgba(0, 0, 0, 0.7);
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.drawer-body {
max-height: calc(100vh - 4rem);
overflow: auto;
}
.drawer-footer {
margin-top: 0.7rem;
display: flex;
gap: 0.5rem;
}
.icon-btn {
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 1rem;
}
.icon-btn.small {
font-size: 0.9rem;
}
/* Dynamic lists */
.dynamic-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.dynamic-row {
display: flex;
gap: 0.3rem;
}
.dynamic-row input {
flex: 1;
}
.two-cols {
display: grid;
gap: 0.6rem;
}
@media (min-width: 600px) {
.two-cols {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }

View File

@ -1,212 +1,150 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { getRandomRecipe, createRecipe } from "./api";
import "./App.css"; import "./App.css";
import TopBar from "./components/TopBar";
import RecipeList from "./components/RecipeList";
import RecipeDetails from "./components/RecipeDetails";
import RecipeFormDrawer from "./components/RecipeFormDrawer";
import { getRecipes, getRandomRecipe, createRecipe } from "./api";
function App() { function App() {
const [mealType, setMealType] = useState(""); const [recipes, setRecipes] = useState([]);
const [maxTime, setMaxTime] = useState(""); const [selectedRecipe, setSelectedRecipe] = useState(null);
const [ingredients, setIngredients] = useState("");
const [recipe, setRecipe] = useState(null); const [mealTypeFilter, setMealTypeFilter] = useState("");
const [maxTimeFilter, setMaxTimeFilter] = useState("");
const [ingredientsFilter, setIngredientsFilter] = useState("");
const [loadingRandom, setLoadingRandom] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
// form for quick demo recipe const [drawerOpen, setDrawerOpen] = useState(false);
const [newName, setNewName] = useState("");
const [newMealType, setNewMealType] = useState("lunch");
const [newTime, setNewTime] = useState(10);
const [newIngredients, setNewIngredients] = useState("");
const [newSteps, setNewSteps] = useState("");
const [creating, setCreating] = useState(false);
const handleRandom = async () => { useEffect(() => {
setLoading(true); loadRecipes();
setError(""); }, []);
setRecipe(null);
const loadRecipes = async () => {
try { try {
const data = await getRandomRecipe({ const list = await getRecipes();
mealType: mealType || undefined, setRecipes(list);
maxTime: maxTime ? Number(maxTime) : undefined, if (!selectedRecipe && list.length > 0) {
ingredients: ingredients || undefined, setSelectedRecipe(list[0]);
});
setRecipe(data);
} catch (err) {
if (err.response?.status === 404) {
setError("לא נמצאו מתכונים מתאימים 💔");
} else {
setError("שגיאה בטעינת מתכון");
} }
} finally { } catch {
setLoading(false); setError("לא הצלחנו לטעון את רשימת המתכונים.");
} }
}; };
const handleCreate = async (e) => { const handleRandomClick = async () => {
e.preventDefault(); setLoadingRandom(true);
setCreating(true);
setError(""); setError("");
const ingArr = newIngredients
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const stepsArr = newSteps
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
try { try {
const created = await createRecipe({ const ingredientsArr = ingredientsFilter
name: newName, .split(",")
meal_type: newMealType, .map((s) => s.trim())
time_minutes: Number(newTime), .filter(Boolean);
tags: [],
ingredients: ingArr, const recipe = await getRandomRecipe({
steps: stepsArr, mealType: mealTypeFilter || undefined,
maxTime: maxTimeFilter ? Number(maxTimeFilter) : undefined,
ingredients: ingredientsArr,
}); });
setRecipe(created);
setSelectedRecipe(recipe);
} catch (err) { } catch (err) {
setError("שגיאה ביצירת מתכון"); if (err.response?.status === 404) {
setError("לא נמצאו מתכונים שעומדים בפילטרים שלך.");
} else {
setError("אירעה שגיאה בחיפוש מתכון.");
}
} finally { } finally {
setCreating(false); setLoadingRandom(false);
}
};
const handleCreateRecipe = async (payload) => {
try {
const created = await createRecipe(payload);
setDrawerOpen(false);
await loadRecipes();
setSelectedRecipe(created);
} catch {
setError("שגיאה בשמירת המתכון החדש.");
} }
}; };
return ( return (
<div className="app-root"> <div className="app-root">
<h1>מה לבשל היום? 🍽</h1> <TopBar onAddClick={() => setDrawerOpen(true)} />
<section className="card"> <main className="layout">
<h2>חיפוש מתכון רנדומלי</h2> <section className="sidebar">
<div className="grid"> <section className="panel filter-panel">
<div className="field"> <h3>חיפוש מתכון רנדומלי</h3>
<label>סוג ארוחה</label> <div className="panel-grid">
<select <div className="field">
value={mealType} <label>סוג ארוחה</label>
onChange={(e) => setMealType(e.target.value)} <select
value={mealTypeFilter}
onChange={(e) => setMealTypeFilter(e.target.value)}
>
<option value="">לא משנה</option>
<option value="breakfast">בוקר</option>
<option value="lunch">צהריים</option>
<option value="dinner">ערב</option>
<option value="snack">נשנוש</option>
</select>
</div>
<div className="field">
<label>זמן מקסימלי (דקות)</label>
<input
type="number"
min="1"
value={maxTimeFilter}
onChange={(e) => setMaxTimeFilter(e.target.value)}
placeholder="למשל 20"
/>
</div>
<div className="field field-full">
<label>מצרכים שיש בבית (מופרד בפסיקים)</label>
<input
value={ingredientsFilter}
onChange={(e) => setIngredientsFilter(e.target.value)}
placeholder="ביצה, עגבניה, פסטה..."
/>
</div>
</div>
<button
className="btn accent full"
onClick={handleRandomClick}
disabled={loadingRandom}
> >
<option value="">לא משנה</option> {loadingRandom ? "מחפש מתכון..." : "תן לי רעיון לארוחה 🎲"}
<option value="breakfast">בוקר</option> </button>
<option value="lunch">צהריים</option> </section>
<option value="dinner">ערב</option>
<option value="snack">נשנוש</option>
</select>
</div>
<div className="field"> <RecipeList
<label>זמן מקסימלי (דקות)</label> recipes={recipes}
<input selectedId={selectedRecipe?.id}
type="number" onSelect={setSelectedRecipe}
min="1" />
value={maxTime}
onChange={(e) => setMaxTime(e.target.value)}
placeholder="לדוגמה 20"
/>
</div>
<div className="field">
<label>מצרכים שיש בבית (מופרד בפסיקים)</label>
<input
type="text"
value={ingredients}
onChange={(e) => setIngredients(e.target.value)}
placeholder="ביצה, עגבניה, פסטה..."
/>
</div>
</div>
<button onClick={handleRandom} disabled={loading}>
{loading ? "חושב..." : "תן לי מתכון רנדומלי"}
</button>
</section>
<section className="card">
<h2>הוספת מתכון לדמו</h2>
<form onSubmit={handleCreate} className="grid">
<div className="field">
<label>שם מתכון</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
required
/>
</div>
<div className="field">
<label>סוג ארוחה</label>
<select
value={newMealType}
onChange={(e) => setNewMealType(e.target.value)}
>
<option value="breakfast">בוקר</option>
<option value="lunch">צהריים</option>
<option value="dinner">ערב</option>
<option value="snack">נשנוש</option>
</select>
</div>
<div className="field">
<label>זמן הכנה (דקות)</label>
<input
type="number"
min="1"
value={newTime}
onChange={(e) => setNewTime(e.target.value)}
required
/>
</div>
<div className="field">
<label>מצרכים (מופרד בפסיקים)</label>
<textarea
rows={2}
value={newIngredients}
onChange={(e) => setNewIngredients(e.target.value)}
placeholder="פסטה, שמנת, מלח..."
/>
</div>
<div className="field">
<label>שלבים (כל שורה שלב)</label>
<textarea
rows={4}
value={newSteps}
onChange={(e) => setNewSteps(e.target.value)}
placeholder={"לבשל פסטה במים רותחים\nלהכין רוטב שמנת במחבת\nלערבב ולהגיש"}
/>
</div>
<button type="submit" disabled={creating}>
{creating ? "שומר..." : "שמור מתכון"}
</button>
</form>
</section>
{error && <p className="error">{error}</p>}
{recipe && (
<section className="card recipe-card">
<h2>{recipe.name}</h2>
<p>
<strong>סוג ארוחה:</strong> {recipe.meal_type} ·{" "}
<strong>זמן הכנה:</strong> {recipe.time_minutes} דקות
</p>
<h3>מצרכים</h3>
<ul>
{recipe.ingredients.map((ing, idx) => (
<li key={idx}>{ing}</li>
))}
</ul>
<h3>שלבים</h3>
<ol>
{recipe.steps.map((step, idx) => (
<li key={idx}>{step}</li>
))}
</ol>
</section> </section>
)}
<section className="content">
{error && <div className="error-banner">{error}</div>}
<RecipeDetails recipe={selectedRecipe} />
</section>
</main>
<RecipeFormDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onSubmit={handleCreateRecipe}
/>
</div> </div>
); );
} }

View File

@ -2,18 +2,19 @@ import axios from "axios";
const API_BASE = "http://localhost:8000"; const API_BASE = "http://localhost:8000";
export async function getRandomRecipe({ mealType, maxTime, ingredients }) { export async function getRecipes() {
const params = {}; const res = await axios.get(`${API_BASE}/recipes`);
if (mealType) params.meal_type = mealType; return res.data;
if (maxTime) params.max_time = maxTime; }
if (ingredients) { export async function getRandomRecipe(filters) {
const list = ingredients const params = {};
.split(",") if (filters.mealType) params.meal_type = filters.mealType;
.map((s) => s.trim()) if (filters.maxTime) params.max_time = filters.maxTime;
.filter(Boolean);
list.forEach((ing) => { if (filters.ingredients && filters.ingredients.length > 0) {
if (!params.ingredients) params.ingredients = []; filters.ingredients.forEach((ing) => {
params.ingredients = params.ingredients || [];
params.ingredients.push(ing); params.ingredients.push(ing);
}); });
} }

View File

@ -0,0 +1,73 @@
function RecipeDetails({ recipe }) {
if (!recipe) {
return (
<section className="panel placeholder">
<p>עדיין לא נבחר מתכון. בחר מתכון מהרשימה או צור מתכון חדש.</p>
</section>
);
}
return (
<section className="panel recipe-card">
<header className="recipe-header">
<div>
<h2>{recipe.name}</h2>
<p className="recipe-subtitle">
{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה
</p>
</div>
<div className="pill-row">
<span className="pill"> {recipe.time_minutes} דק׳</span>
<span className="pill">🍽 {translateMealType(recipe.meal_type)}</span>
</div>
</header>
<div className="recipe-body">
<div className="recipe-column">
<h3>מצרכים</h3>
<ul>
{recipe.ingredients.map((ing, idx) => (
<li key={idx}>{ing}</li>
))}
</ul>
</div>
<div className="recipe-column">
<h3>שלבים</h3>
<ol>
{recipe.steps.map((step, idx) => (
<li key={idx}>{step}</li>
))}
</ol>
</div>
</div>
{recipe.tags && recipe.tags.length > 0 && (
<footer className="tags">
{recipe.tags.map((tag, idx) => (
<span key={idx} className="tag">
#{tag}
</span>
))}
</footer>
)}
</section>
);
}
function translateMealType(type) {
switch (type) {
case "breakfast":
return "בוקר";
case "lunch":
return "צהריים";
case "dinner":
return "ערב";
case "snack":
return "נשנוש";
default:
return type;
}
}
export default RecipeDetails;

View File

@ -0,0 +1,194 @@
import { useEffect, useState } from "react";
function RecipeFormDrawer({ open, onClose, onSubmit }) {
const [name, setName] = useState("");
const [mealType, setMealType] = useState("lunch");
const [timeMinutes, setTimeMinutes] = useState(15);
const [tags, setTags] = useState("");
const [ingredients, setIngredients] = useState([""]);
const [steps, setSteps] = useState([""]);
useEffect(() => {
if (open) {
setName("");
setMealType("lunch");
setTimeMinutes(15);
setTags("");
setIngredients([""]);
setSteps([""]);
}
}, [open]);
if (!open) return null;
const handleAddIngredient = () => {
setIngredients((prev) => [...prev, ""]);
};
const handleChangeIngredient = (idx, value) => {
setIngredients((prev) => prev.map((v, i) => (i === idx ? value : v)));
};
const handleRemoveIngredient = (idx) => {
setIngredients((prev) => prev.filter((_, i) => i !== idx || prev.length === 1));
};
const handleAddStep = () => {
setSteps((prev) => [...prev, ""]);
};
const handleChangeStep = (idx, value) => {
setSteps((prev) => prev.map((v, i) => (i === idx ? value : v)));
};
const handleRemoveStep = (idx) => {
setSteps((prev) => prev.filter((_, i) => i !== idx || prev.length === 1));
};
const handleSubmit = (e) => {
e.preventDefault();
const cleanIngredients = ingredients.map((s) => s.trim()).filter(Boolean);
const cleanSteps = steps.map((s) => s.trim()).filter(Boolean);
const tagsArr = tags
.split(",")
.map((t) => t.trim())
.filter(Boolean);
onSubmit({
name,
meal_type: mealType,
time_minutes: Number(timeMinutes),
tags: tagsArr,
ingredients: cleanIngredients,
steps: cleanSteps,
});
};
return (
<div className="drawer-backdrop" onClick={onClose}>
<div className="drawer" onClick={(e) => e.stopPropagation()}>
<header className="drawer-header">
<h2>מתכון חדש</h2>
<button className="icon-btn" onClick={onClose}>
</button>
</header>
<form className="drawer-body" onSubmit={handleSubmit}>
<div className="field">
<label>שם המתכון</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
required
placeholder="שקשוקה חריפה, פסטה שמנת, חזה עוף בתנור..."
/>
</div>
<div className="two-cols">
<div className="field">
<label>סוג ארוחה</label>
<select value={mealType} onChange={(e) => setMealType(e.target.value)}>
<option value="breakfast">בוקר</option>
<option value="lunch">צהריים</option>
<option value="dinner">ערב</option>
<option value="snack">נשנוש</option>
</select>
</div>
<div className="field">
<label>זמן הכנה (דקות)</label>
<input
type="number"
min="1"
value={timeMinutes}
onChange={(e) => setTimeMinutes(e.target.value)}
required
/>
</div>
</div>
<div className="field">
<label>תגיות (מופרד בפסיקים)</label>
<input
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="מהיר, טבעוני, משפחתי..."
/>
</div>
<div className="field">
<label>מצרכים</label>
<div className="dynamic-list">
{ingredients.map((val, idx) => (
<div key={idx} className="dynamic-row">
<input
value={val}
onChange={(e) => handleChangeIngredient(idx, e.target.value)}
placeholder="למשל: 2 ביצים"
/>
<button
type="button"
className="icon-btn small"
onClick={() => handleRemoveIngredient(idx)}
>
</button>
</div>
))}
<button
type="button"
className="btn ghost small"
onClick={handleAddIngredient}
>
+ הוספת מצרך
</button>
</div>
</div>
<div className="field">
<label>שלבים</label>
<div className="dynamic-list">
{steps.map((val, idx) => (
<div key={idx} className="dynamic-row">
<input
value={val}
onChange={(e) => handleChangeStep(idx, e.target.value)}
placeholder="למשל: לחמם את התנור ל־180 מעלות"
/>
<button
type="button"
className="icon-btn small"
onClick={() => handleRemoveStep(idx)}
>
</button>
</div>
))}
<button
type="button"
className="btn ghost small"
onClick={handleAddStep}
>
+ הוספת שלב
</button>
</div>
</div>
<footer className="drawer-footer">
<button type="button" className="btn ghost" onClick={onClose}>
ביטול
</button>
<button type="submit" className="btn primary">
שמירת מתכון
</button>
</footer>
</form>
</div>
</div>
);
}
export default RecipeFormDrawer;

View File

@ -0,0 +1,51 @@
function RecipeList({ recipes, selectedId, onSelect }) {
return (
<section className="panel secondary">
<div className="panel-header">
<h3>כל המתכונים</h3>
<span className="badge">{recipes.length}</span>
</div>
{recipes.length === 0 ? (
<p className="muted">עדיין אין מתכונים, לחץ על מתכון חדש להתחלה.</p>
) : (
<ul className="recipe-list">
{recipes.map((r) => (
<li
key={r.id}
className={
selectedId === r.id ? "recipe-list-item active" : "recipe-list-item"
}
onClick={() => onSelect(r)}
>
<div className="recipe-list-main">
<div className="recipe-list-name">{r.name}</div>
<div className="recipe-list-meta">
<span>{r.time_minutes} דק׳</span>
<span>{translateMealType(r.meal_type)}</span>
</div>
</div>
</li>
))}
</ul>
)}
</section>
);
}
function translateMealType(type) {
switch (type) {
case "breakfast":
return "בוקר";
case "lunch":
return "צהריים";
case "dinner":
return "ערב";
case "snack":
return "נשנוש";
default:
return type;
}
}
export default RecipeList;

View File

@ -0,0 +1,21 @@
function TopBar({ onAddClick }) {
return (
<header className="topbar">
<div className="topbar-left">
<span className="logo-emoji" role="img" aria-label="plate">
🍽
</span>
<div className="brand">
<div className="brand-title">מה לבשל היום?</div>
<div className="brand-subtitle">מנהל המתכונים האישי שלך</div>
</div>
</div>
<button className="btn primary" onClick={onAddClick}>
+ מתכון חדש
</button>
</header>
);
}
export default TopBar;