Compare commits

..

15 Commits

24 changed files with 302 additions and 72 deletions

View File

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

11
backend/.dockerignore Normal file
View File

@ -0,0 +1,11 @@
__pycache__
*.pyc
*.pyo
.git
.gitignore
.env
.env.local
.DS_Store
.pytest_cache
venv
env

View File

@ -55,7 +55,7 @@ def list_recipes_db() -> List[Dict[str, Any]]:
cur.execute( cur.execute(
""" """
SELECT id, name, meal_type, time_minutes, SELECT id, name, meal_type, time_minutes,
tags, ingredients, steps, image tags, ingredients, steps, image, made_by
FROM recipes FROM recipes
ORDER BY id 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]]: def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
""" """
עדכון מתכון קיים לפי id. עדכון מתכון קיים לפי 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() conn = get_conn()
try: try:
@ -82,9 +82,10 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
tags = %s, tags = %s,
ingredients = %s, ingredients = %s,
steps = %s, steps = %s,
image = %s image = %s,
made_by = %s
WHERE id = %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"], 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("ingredients", [])),
json.dumps(recipe_data.get("steps", [])), json.dumps(recipe_data.get("steps", [])),
recipe_data.get("image"), recipe_data.get("image"),
recipe_data.get("made_by"),
recipe_id, recipe_id,
), ),
) )
@ -131,9 +133,9 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
""" """
INSERT INTO recipes (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) VALUES (%s, %s, %s, %s, %s, %s, %s, %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"], 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("ingredients", [])),
json.dumps(recipe_data.get("steps", [])), json.dumps(recipe_data.get("steps", [])),
recipe_data.get("image"), recipe_data.get("image"),
recipe_data.get("made_by"),
), ),
) )
row = cur.fetchone() row = cur.fetchone()
@ -160,7 +163,7 @@ def get_recipes_by_filters_db(
try: try:
query = """ query = """
SELECT id, name, meal_type, time_minutes, SELECT id, name, meal_type, time_minutes,
tags, ingredients, steps tags, ingredients, steps, image, made_by
FROM recipes FROM recipes
WHERE 1=1 WHERE 1=1
""" """

View File

@ -21,6 +21,7 @@ class RecipeBase(BaseModel):
name: str name: str
meal_type: str # breakfast / lunch / dinner / snack meal_type: str # breakfast / lunch / dinner / snack
time_minutes: int time_minutes: int
made_by: Optional[str] = None # Person who created this recipe version
tags: List[str] = [] tags: List[str] = []
ingredients: List[str] = [] ingredients: List[str] = []
steps: List[str] = [] steps: List[str] = []
@ -45,9 +46,17 @@ app = FastAPI(
description="API פשוט לבחירת מתכונים לצהריים / ערב / כל ארוחה 😋", 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:3000"], allow_origins=allowed_origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@ -63,6 +72,7 @@ def list_recipes():
name=r["name"], name=r["name"],
meal_type=r["meal_type"], meal_type=r["meal_type"],
time_minutes=r["time_minutes"], time_minutes=r["time_minutes"],
made_by=r.get("made_by"),
tags=r["tags"] or [], tags=r["tags"] or [],
ingredients=r["ingredients"] or [], ingredients=r["ingredients"] or [],
steps=r["steps"] or [], steps=r["steps"] or [],
@ -85,6 +95,7 @@ def create_recipe(recipe_in: RecipeCreate):
name=row["name"], name=row["name"],
meal_type=row["meal_type"], meal_type=row["meal_type"],
time_minutes=row["time_minutes"], time_minutes=row["time_minutes"],
made_by=row.get("made_by"),
tags=row["tags"] or [], tags=row["tags"] or [],
ingredients=row["ingredients"] or [], ingredients=row["ingredients"] or [],
steps=row["steps"] or [], steps=row["steps"] or [],
@ -105,6 +116,7 @@ def update_recipe(recipe_id: int, recipe_in: RecipeUpdate):
name=row["name"], name=row["name"],
meal_type=row["meal_type"], meal_type=row["meal_type"],
time_minutes=row["time_minutes"], time_minutes=row["time_minutes"],
made_by=row.get("made_by"),
tags=row["tags"] or [], tags=row["tags"] or [],
ingredients=row["ingredients"] or [], ingredients=row["ingredients"] or [],
steps=row["steps"] or [], steps=row["steps"] or [],
@ -137,6 +149,7 @@ def random_recipe(
name=r["name"], name=r["name"],
meal_type=r["meal_type"], meal_type=r["meal_type"],
time_minutes=r["time_minutes"], time_minutes=r["time_minutes"],
made_by=r.get("made_by"),
tags=r["tags"] or [], tags=r["tags"] or [],
ingredients=r["ingredients"] or [], ingredients=r["ingredients"] or [],
steps=r["steps"] or [], steps=r["steps"] or [],

View File

@ -1,5 +1,5 @@
fastapi==0.103.2 fastapi==0.115.0
uvicorn[standard]==0.23.2 uvicorn[standard]==0.30.1
pydantic==2.7.4 pydantic==2.7.4
python-dotenv==1.0.1 python-dotenv==1.0.1

View File

@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS recipes (
name TEXT NOT NULL, name TEXT NOT NULL,
meal_type TEXT NOT NULL, -- breakfast / lunch / dinner / snack meal_type TEXT NOT NULL, -- breakfast / lunch / dinner / snack
time_minutes INTEGER NOT NULL, time_minutes INTEGER NOT NULL,
made_by TEXT, -- Person who created this recipe version
tags JSONB NOT NULL DEFAULT '[]', -- ["מהיר", "בריא"] tags JSONB NOT NULL DEFAULT '[]', -- ["מהיר", "בריא"]
ingredients JSONB NOT NULL DEFAULT '[]', -- ["ביצה", "עגבניה", "מלח"] ingredients JSONB NOT NULL DEFAULT '[]', -- ["ביצה", "עגבניה", "מלח"]
steps 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 CREATE INDEX IF NOT EXISTS idx_recipes_time_minutes
ON 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 CREATE INDEX IF NOT EXISTS idx_recipes_tags_jsonb
ON recipes USING GIN (tags); ON recipes USING GIN (tags);
CREATE INDEX IF NOT EXISTS idx_recipes_ingredients_jsonb CREATE INDEX IF NOT EXISTS idx_recipes_ingredients_jsonb
ON recipes USING GIN (ingredients); ON recipes USING GIN (ingredients);

9
frontend/.dockerignore Normal file
View File

@ -0,0 +1,9 @@
node_modules
npm-debug.log
dist
.git
.gitignore
README.md
.env
.env.local
.DS_Store

3
frontend/.gitignore vendored
View File

@ -12,6 +12,9 @@ dist
dist-ssr dist-ssr
*.local *.local
# Local development env.js (production uses runtime injection)
public/env.js
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json

View 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
View 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

View File

@ -5,6 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>frontend</title>
<!-- Load environment variables before app starts -->
<script src="/env.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

28
frontend/nginx.conf Normal file
View 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";
}
}
}

View File

@ -0,0 +1,3 @@
window.__ENV__ = {
API_BASE: "${API_BASE:-/api}"
};

View File

@ -314,6 +314,13 @@ select {
color: var(--text-muted); color: var(--text-muted);
} }
.recipe-made-by {
margin: 0.3rem 0 0;
font-size: 0.8rem;
color: var(--accent);
font-weight: 500;
}
.pill-row { .pill-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@ -19,6 +19,7 @@ function App() {
const [filterMealType, setFilterMealType] = useState(""); const [filterMealType, setFilterMealType] = useState("");
const [filterMaxTime, setFilterMaxTime] = useState(""); const [filterMaxTime, setFilterMaxTime] = useState("");
const [filterTags, setFilterTags] = useState([]); const [filterTags, setFilterTags] = useState([]);
const [filterMadeBy, setFilterMadeBy] = useState("");
// Random recipe filters // Random recipe filters
const [mealTypeFilter, setMealTypeFilter] = useState(""); 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; return true;
}); });
}; };
@ -222,6 +228,8 @@ function App() {
onMaxTimeChange={setFilterMaxTime} onMaxTimeChange={setFilterMaxTime}
filterTags={filterTags} filterTags={filterTags}
onTagsChange={setFilterTags} onTagsChange={setFilterTags}
filterMadeBy={filterMadeBy}
onMadeByChange={setFilterMadeBy}
/> />
</section> </section>

View File

@ -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() { export async function getRecipes() {
const res = await axios.get(`${API_BASE}/recipes`); const res = await fetch(`${API_BASE}/recipes`);
return res.data; if (!res.ok) {
throw new Error("Failed to fetch recipes");
}
return res.json();
} }
export async function getRandomRecipe(filters) { export async function getRandomRecipe(filters) {
const params = {}; const params = new URLSearchParams();
if (filters.mealType) params.meal_type = filters.mealType; if (filters.mealType) params.append("meal_type", filters.mealType);
if (filters.maxTime) params.max_time = filters.maxTime; if (filters.maxTime) params.append("max_time", filters.maxTime);
if (filters.ingredients && filters.ingredients.length > 0) { if (filters.ingredients && filters.ingredients.length > 0) {
filters.ingredients.forEach((ing) => { filters.ingredients.forEach((ing) => {
params.ingredients = params.ingredients || []; params.append("ingredients", ing);
params.ingredients.push(ing);
}); });
} }
const res = await axios.get(`${API_BASE}/recipes/random`, { params }); const queryString = params.toString();
return res.data; 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) { export async function createRecipe(recipe) {
const res = await axios.post(`${API_BASE}/recipes`, recipe); const res = await fetch(`${API_BASE}/recipes`, {
return res.data; 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) { export async function updateRecipe(id, payload) {

View File

@ -26,6 +26,9 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
<p className="recipe-subtitle"> <p className="recipe-subtitle">
{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה {translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה
</p> </p>
{recipe.made_by && (
<h4 className="recipe-made-by">המתכון של: {recipe.made_by}</h4>
)}
</div> </div>
<div className="pill-row"> <div className="pill-row">
<span className="pill"> {recipe.time_minutes} דק׳</span> <span className="pill"> {recipe.time_minutes} דק׳</span>

View File

@ -4,6 +4,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [mealType, setMealType] = useState("lunch"); const [mealType, setMealType] = useState("lunch");
const [timeMinutes, setTimeMinutes] = useState(15); const [timeMinutes, setTimeMinutes] = useState(15);
const [madeBy, setMadeBy] = useState("");
const [tags, setTags] = useState(""); const [tags, setTags] = useState("");
const [image, setImage] = useState(""); const [image, setImage] = useState("");
@ -18,6 +19,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
setName(editingRecipe.name || ""); setName(editingRecipe.name || "");
setMealType(editingRecipe.meal_type || "lunch"); setMealType(editingRecipe.meal_type || "lunch");
setTimeMinutes(editingRecipe.time_minutes || 15); setTimeMinutes(editingRecipe.time_minutes || 15);
setMadeBy(editingRecipe.made_by || "");
setTags((editingRecipe.tags || []).join(", ")); setTags((editingRecipe.tags || []).join(", "));
setImage(editingRecipe.image || ""); setImage(editingRecipe.image || "");
setIngredients(editingRecipe.ingredients || [""]); setIngredients(editingRecipe.ingredients || [""]);
@ -26,6 +28,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
setName(""); setName("");
setMealType("lunch"); setMealType("lunch");
setTimeMinutes(15); setTimeMinutes(15);
setMadeBy("");
setTags(""); setTags("");
setImage(""); setImage("");
setIngredients([""]); setIngredients([""]);
@ -94,6 +97,10 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
steps: cleanSteps, steps: cleanSteps,
}; };
if (madeBy.trim()) {
payload.made_by = madeBy.trim();
}
if (image) { if (image) {
payload.image = image; payload.image = image;
} }
@ -145,6 +152,15 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
</div> </div>
</div> </div>
<div className="field">
<label>המתכון של:</label>
<input
value={madeBy}
onChange={(e) => setMadeBy(e.target.value)}
placeholder="שם האדם שיצר את הגרסה הזו..."
/>
</div>
<div className="field"> <div className="field">
<label>תמונה של המתכון</label> <label>תמונה של המתכון</label>
<div className="image-upload-wrapper"> <div className="image-upload-wrapper">

View File

@ -14,6 +14,8 @@ function RecipeSearchList({
onMaxTimeChange, onMaxTimeChange,
filterTags, filterTags,
onTagsChange, onTagsChange,
filterMadeBy,
onMadeByChange,
}) { }) {
const [expandFilters, setExpandFilters] = useState(false); const [expandFilters, setExpandFilters] = useState(false);
@ -25,6 +27,9 @@ function RecipeSearchList({
// Extract unique meal types from ALL recipes (not filtered) // Extract unique meal types from ALL recipes (not filtered)
const mealTypes = Array.from(new Set(allRecipes.map((r) => r.meal_type))).sort(); 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 // 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 maxTimeInRecipes = Math.max(...allRecipes.map((r) => r.time_minutes), 0);
const sliderMax = maxTimeInRecipes > 0 ? maxTimeInRecipes + 10 : 70; const sliderMax = maxTimeInRecipes > 0 ? maxTimeInRecipes + 10 : 70;
@ -42,9 +47,10 @@ function RecipeSearchList({
onMealTypeChange(""); onMealTypeChange("");
onMaxTimeChange(""); onMaxTimeChange("");
onTagsChange([]); onTagsChange([]);
onMadeByChange("");
}; };
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0; const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterMadeBy;
return ( return (
<section className="panel secondary recipe-search-list"> <section className="panel secondary recipe-search-list">
@ -152,6 +158,30 @@ function RecipeSearchList({
</div> </div>
</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> </div>
{/* Clear All Button */} {/* Clear All Button */}

View File

@ -4,4 +4,5 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
assetsInclude: ['**/*.svg'],
}) })