Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d3779ba76 | |||
|
|
994d7bb093 | ||
|
|
02541a62ea | ||
| b4008f5b93 | |||
| 5c8481304f | |||
| 88ec0585a7 | |||
| bd31ffbff4 | |||
| 895786b405 | |||
| e0b0ec94fe | |||
| 56d00c2ed8 | |||
| 7eeaf2fad9 | |||
| 99538b4b40 | |||
| c43c33db37 | |||
| 30986fdf40 | |||
| 3afc7d2750 |
@ -1,24 +1,24 @@
|
||||
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-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
|
||||
@ -41,32 +41,32 @@ steps:
|
||||
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-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
|
||||
|
||||
11
backend/.dockerignore
Normal file
11
backend/.dockerignore
Normal file
@ -0,0 +1,11 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
.pytest_cache
|
||||
venv
|
||||
env
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -55,7 +55,7 @@ def list_recipes_db() -> List[Dict[str, Any]]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, meal_type, time_minutes,
|
||||
tags, ingredients, steps, image
|
||||
tags, ingredients, steps, image, made_by
|
||||
FROM recipes
|
||||
ORDER BY id
|
||||
"""
|
||||
@ -68,7 +68,7 @@ def list_recipes_db() -> List[Dict[str, Any]]:
|
||||
def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
עדכון מתכון קיים לפי id.
|
||||
recipe_data: name, meal_type, time_minutes, tags, ingredients, steps, image
|
||||
recipe_data: name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
|
||||
"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
@ -82,9 +82,10 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
|
||||
tags = %s,
|
||||
ingredients = %s,
|
||||
steps = %s,
|
||||
image = %s
|
||||
image = %s,
|
||||
made_by = %s
|
||||
WHERE id = %s
|
||||
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image
|
||||
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
|
||||
""",
|
||||
(
|
||||
recipe_data["name"],
|
||||
@ -94,6 +95,7 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
|
||||
json.dumps(recipe_data.get("ingredients", [])),
|
||||
json.dumps(recipe_data.get("steps", [])),
|
||||
recipe_data.get("image"),
|
||||
recipe_data.get("made_by"),
|
||||
recipe_id,
|
||||
),
|
||||
)
|
||||
@ -131,9 +133,9 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
|
||||
""",
|
||||
(
|
||||
recipe_data["name"],
|
||||
@ -143,6 +145,7 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
json.dumps(recipe_data.get("ingredients", [])),
|
||||
json.dumps(recipe_data.get("steps", [])),
|
||||
recipe_data.get("image"),
|
||||
recipe_data.get("made_by"),
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
@ -160,7 +163,7 @@ def get_recipes_by_filters_db(
|
||||
try:
|
||||
query = """
|
||||
SELECT id, name, meal_type, time_minutes,
|
||||
tags, ingredients, steps
|
||||
tags, ingredients, steps, image, made_by
|
||||
FROM recipes
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
@ -21,6 +21,7 @@ class RecipeBase(BaseModel):
|
||||
name: str
|
||||
meal_type: str # breakfast / lunch / dinner / snack
|
||||
time_minutes: int
|
||||
made_by: Optional[str] = None # Person who created this recipe version
|
||||
tags: List[str] = []
|
||||
ingredients: List[str] = []
|
||||
steps: List[str] = []
|
||||
@ -45,9 +46,17 @@ app = FastAPI(
|
||||
description="API פשוט לבחירת מתכונים לצהריים / ערב / כל ארוחה 😋",
|
||||
)
|
||||
|
||||
# Allow CORS from frontend domains
|
||||
allowed_origins = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
"https://my-recipes.dvirlabs.com",
|
||||
"http://my-recipes.dvirlabs.com",
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://localhost:3000"],
|
||||
allow_origins=allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@ -63,6 +72,7 @@ def list_recipes():
|
||||
name=r["name"],
|
||||
meal_type=r["meal_type"],
|
||||
time_minutes=r["time_minutes"],
|
||||
made_by=r.get("made_by"),
|
||||
tags=r["tags"] or [],
|
||||
ingredients=r["ingredients"] or [],
|
||||
steps=r["steps"] or [],
|
||||
@ -85,6 +95,7 @@ def create_recipe(recipe_in: RecipeCreate):
|
||||
name=row["name"],
|
||||
meal_type=row["meal_type"],
|
||||
time_minutes=row["time_minutes"],
|
||||
made_by=row.get("made_by"),
|
||||
tags=row["tags"] or [],
|
||||
ingredients=row["ingredients"] or [],
|
||||
steps=row["steps"] or [],
|
||||
@ -105,6 +116,7 @@ def update_recipe(recipe_id: int, recipe_in: RecipeUpdate):
|
||||
name=row["name"],
|
||||
meal_type=row["meal_type"],
|
||||
time_minutes=row["time_minutes"],
|
||||
made_by=row.get("made_by"),
|
||||
tags=row["tags"] or [],
|
||||
ingredients=row["ingredients"] or [],
|
||||
steps=row["steps"] or [],
|
||||
@ -137,6 +149,7 @@ def random_recipe(
|
||||
name=r["name"],
|
||||
meal_type=r["meal_type"],
|
||||
time_minutes=r["time_minutes"],
|
||||
made_by=r.get("made_by"),
|
||||
tags=r["tags"] or [],
|
||||
ingredients=r["ingredients"] or [],
|
||||
steps=r["steps"] or [],
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
fastapi==0.103.2
|
||||
uvicorn[standard]==0.23.2
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.1
|
||||
|
||||
pydantic==2.7.4
|
||||
python-dotenv==1.0.1
|
||||
|
||||
@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS recipes (
|
||||
name TEXT NOT NULL,
|
||||
meal_type TEXT NOT NULL, -- breakfast / lunch / dinner / snack
|
||||
time_minutes INTEGER NOT NULL,
|
||||
|
||||
made_by TEXT, -- Person who created this recipe version
|
||||
tags JSONB NOT NULL DEFAULT '[]', -- ["מהיר", "בריא"]
|
||||
ingredients JSONB NOT NULL DEFAULT '[]', -- ["ביצה", "עגבניה", "מלח"]
|
||||
steps JSONB NOT NULL DEFAULT '[]', -- ["לחתוך", "לבשל", ...]
|
||||
@ -18,8 +18,12 @@ CREATE INDEX IF NOT EXISTS idx_recipes_meal_type
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_time_minutes
|
||||
ON recipes (time_minutes);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_made_by
|
||||
ON recipes (made_by);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_tags_jsonb
|
||||
ON recipes USING GIN (tags);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_ingredients_jsonb
|
||||
ON recipes USING GIN (ingredients);
|
||||
|
||||
|
||||
9
frontend/.dockerignore
Normal file
9
frontend/.dockerignore
Normal file
@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@ -12,6 +12,9 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Local development env.js (production uses runtime injection)
|
||||
public/env.js
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
31
frontend/10-generate-env.sh
Normal file
31
frontend/10-generate-env.sh
Normal file
@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
# Don't use set -e to avoid crashing if chown fails
|
||||
|
||||
# Generate env.js from API_BASE environment variable
|
||||
# This is set in the Helm deployment values
|
||||
TARGET="/usr/share/nginx/html/env.js"
|
||||
|
||||
# API_BASE should be set via deployment env (e.g., from Helm values)
|
||||
# Default to /api as fallback (relative path)
|
||||
: ${API_BASE:=/api}
|
||||
|
||||
echo "[ENTRYPOINT] Generating env.js with API_BASE=${API_BASE}"
|
||||
|
||||
cat > "$TARGET" <<EOF
|
||||
window.__ENV__ = {
|
||||
API_BASE: "${API_BASE}"
|
||||
};
|
||||
EOF
|
||||
|
||||
if [ -f "$TARGET" ]; then
|
||||
echo "[ENTRYPOINT] ✓ env.js generated successfully at $TARGET"
|
||||
cat "$TARGET"
|
||||
else
|
||||
echo "[ENTRYPOINT] ✗ Failed to generate env.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure ownership/permissions for nginx (don't fail if this doesn't work)
|
||||
chown nginx:nginx /usr/share/nginx/html/env.js 2>/dev/null || echo "[ENTRYPOINT] Note: Could not change ownership (not critical)"
|
||||
|
||||
echo "[ENTRYPOINT] env.js setup complete"
|
||||
37
frontend/Dockerfile
Normal file
37
frontend/Dockerfile
Normal file
@ -0,0 +1,37 @@
|
||||
# Build stage
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --prefer-offline --no-audit
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage - use nginx to serve static files
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy built app from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy entrypoint script to nginx entrypoint.d directory
|
||||
# This will run before nginx starts and generate env.js from API_BASE env var
|
||||
COPY 10-generate-env.sh /docker-entrypoint.d/10-generate-env.sh
|
||||
|
||||
# Ensure entrypoint script is executable and has correct line endings
|
||||
RUN chmod +x /docker-entrypoint.d/10-generate-env.sh && \
|
||||
sed -i 's/\r$//' /docker-entrypoint.d/10-generate-env.sh
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# nginx will start automatically; our script in /docker-entrypoint.d runs first
|
||||
@ -5,6 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<!-- Load environment variables before app starts -->
|
||||
<script src="/env.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
28
frontend/nginx.conf
Normal file
28
frontend/nginx.conf
Normal file
@ -0,0 +1,28 @@
|
||||
worker_processes 1;
|
||||
events { worker_connections 1024; }
|
||||
http {
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Serve static files and fallback to index.html for SPA
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Optional caching for static assets
|
||||
location ~* \.(?:css|js|svg|png|jpg|jpeg|gif|ico)$ {
|
||||
try_files $uri =404;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
}
|
||||
}
|
||||
3
frontend/public/env.js.template
Normal file
3
frontend/public/env.js.template
Normal file
@ -0,0 +1,3 @@
|
||||
window.__ENV__ = {
|
||||
API_BASE: "${API_BASE:-/api}"
|
||||
};
|
||||
@ -314,6 +314,13 @@ select {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.recipe-made-by {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pill-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@ -19,6 +19,7 @@ function App() {
|
||||
const [filterMealType, setFilterMealType] = useState("");
|
||||
const [filterMaxTime, setFilterMaxTime] = useState("");
|
||||
const [filterTags, setFilterTags] = useState([]);
|
||||
const [filterMadeBy, setFilterMadeBy] = useState("");
|
||||
|
||||
// Random recipe filters
|
||||
const [mealTypeFilter, setMealTypeFilter] = useState("");
|
||||
@ -95,6 +96,11 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by made_by
|
||||
if (filterMadeBy && (!recipe.made_by || recipe.made_by !== filterMadeBy)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
@ -222,6 +228,8 @@ function App() {
|
||||
onMaxTimeChange={setFilterMaxTime}
|
||||
filterTags={filterTags}
|
||||
onTagsChange={setFilterTags}
|
||||
filterMadeBy={filterMadeBy}
|
||||
onMadeByChange={setFilterMadeBy}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
@ -1,31 +1,52 @@
|
||||
import axios from "axios";
|
||||
// Get API base from injected env.js or fallback to /api relative path
|
||||
const getApiBase = () => {
|
||||
if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) {
|
||||
return window.__ENV__.API_BASE;
|
||||
}
|
||||
// Default to relative /api path for local/containerized deployments
|
||||
return "/api";
|
||||
};
|
||||
|
||||
const API_BASE = "http://localhost:8000";
|
||||
const API_BASE = getApiBase();
|
||||
|
||||
export async function getRecipes() {
|
||||
const res = await axios.get(`${API_BASE}/recipes`);
|
||||
return res.data;
|
||||
const res = await fetch(`${API_BASE}/recipes`);
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch recipes");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getRandomRecipe(filters) {
|
||||
const params = {};
|
||||
if (filters.mealType) params.meal_type = filters.mealType;
|
||||
if (filters.maxTime) params.max_time = filters.maxTime;
|
||||
const params = new URLSearchParams();
|
||||
if (filters.mealType) params.append("meal_type", filters.mealType);
|
||||
if (filters.maxTime) params.append("max_time", filters.maxTime);
|
||||
|
||||
if (filters.ingredients && filters.ingredients.length > 0) {
|
||||
filters.ingredients.forEach((ing) => {
|
||||
params.ingredients = params.ingredients || [];
|
||||
params.ingredients.push(ing);
|
||||
params.append("ingredients", ing);
|
||||
});
|
||||
}
|
||||
|
||||
const res = await axios.get(`${API_BASE}/recipes/random`, { params });
|
||||
return res.data;
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `${API_BASE}/recipes/random?${queryString}` : `${API_BASE}/recipes/random`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch random recipe");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createRecipe(recipe) {
|
||||
const res = await axios.post(`${API_BASE}/recipes`, recipe);
|
||||
return res.data;
|
||||
const res = await fetch(`${API_BASE}/recipes`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(recipe),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to create recipe");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateRecipe(id, payload) {
|
||||
|
||||
@ -26,6 +26,9 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
||||
<p className="recipe-subtitle">
|
||||
{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה
|
||||
</p>
|
||||
{recipe.made_by && (
|
||||
<h4 className="recipe-made-by">המתכון של: {recipe.made_by}</h4>
|
||||
)}
|
||||
</div>
|
||||
<div className="pill-row">
|
||||
<span className="pill">⏱ {recipe.time_minutes} דק׳</span>
|
||||
|
||||
@ -4,6 +4,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||
const [name, setName] = useState("");
|
||||
const [mealType, setMealType] = useState("lunch");
|
||||
const [timeMinutes, setTimeMinutes] = useState(15);
|
||||
const [madeBy, setMadeBy] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [image, setImage] = useState("");
|
||||
|
||||
@ -18,6 +19,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||
setName(editingRecipe.name || "");
|
||||
setMealType(editingRecipe.meal_type || "lunch");
|
||||
setTimeMinutes(editingRecipe.time_minutes || 15);
|
||||
setMadeBy(editingRecipe.made_by || "");
|
||||
setTags((editingRecipe.tags || []).join(", "));
|
||||
setImage(editingRecipe.image || "");
|
||||
setIngredients(editingRecipe.ingredients || [""]);
|
||||
@ -26,6 +28,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||
setName("");
|
||||
setMealType("lunch");
|
||||
setTimeMinutes(15);
|
||||
setMadeBy("");
|
||||
setTags("");
|
||||
setImage("");
|
||||
setIngredients([""]);
|
||||
@ -94,6 +97,10 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||
steps: cleanSteps,
|
||||
};
|
||||
|
||||
if (madeBy.trim()) {
|
||||
payload.made_by = madeBy.trim();
|
||||
}
|
||||
|
||||
if (image) {
|
||||
payload.image = image;
|
||||
}
|
||||
@ -145,6 +152,15 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>המתכון של:</label>
|
||||
<input
|
||||
value={madeBy}
|
||||
onChange={(e) => setMadeBy(e.target.value)}
|
||||
placeholder="שם האדם שיצר את הגרסה הזו..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>תמונה של המתכון</label>
|
||||
<div className="image-upload-wrapper">
|
||||
|
||||
@ -14,6 +14,8 @@ function RecipeSearchList({
|
||||
onMaxTimeChange,
|
||||
filterTags,
|
||||
onTagsChange,
|
||||
filterMadeBy,
|
||||
onMadeByChange,
|
||||
}) {
|
||||
const [expandFilters, setExpandFilters] = useState(false);
|
||||
|
||||
@ -25,6 +27,9 @@ function RecipeSearchList({
|
||||
// Extract unique meal types from ALL recipes (not filtered)
|
||||
const mealTypes = Array.from(new Set(allRecipes.map((r) => r.meal_type))).sort();
|
||||
|
||||
// Extract unique made_by from ALL recipes (not filtered)
|
||||
const allMadeBy = Array.from(new Set(allRecipes.map((r) => r.made_by).filter(Boolean))).sort();
|
||||
|
||||
// Extract max time for slider from ALL recipes (not filtered) - add 10 to make it comfortable to select max time recipes
|
||||
const maxTimeInRecipes = Math.max(...allRecipes.map((r) => r.time_minutes), 0);
|
||||
const sliderMax = maxTimeInRecipes > 0 ? maxTimeInRecipes + 10 : 70;
|
||||
@ -42,9 +47,10 @@ function RecipeSearchList({
|
||||
onMealTypeChange("");
|
||||
onMaxTimeChange("");
|
||||
onTagsChange([]);
|
||||
onMadeByChange("");
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0;
|
||||
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterMadeBy;
|
||||
|
||||
return (
|
||||
<section className="panel secondary recipe-search-list">
|
||||
@ -152,6 +158,30 @@ function RecipeSearchList({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Made By Filter */}
|
||||
{allMadeBy.length > 0 && (
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">המתכונים של:</label>
|
||||
<div className="filter-options">
|
||||
<button
|
||||
className={`filter-btn ${filterMadeBy === "" ? "active" : ""}`}
|
||||
onClick={() => onMadeByChange("")}
|
||||
>
|
||||
הכל
|
||||
</button>
|
||||
{allMadeBy.map((person) => (
|
||||
<button
|
||||
key={person}
|
||||
className={`filter-btn ${filterMadeBy === person ? "active" : ""}`}
|
||||
onClick={() => onMadeByChange(person)}
|
||||
>
|
||||
{person}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clear All Button */}
|
||||
|
||||
@ -4,4 +4,5 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
assetsInclude: ['**/*.svg'],
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user