Compare commits

...

20 Commits

Author SHA1 Message Date
9d3779ba76 Fix dev environment 2025-12-07 20:54:12 +02:00
dvirlabs
994d7bb093 Allow CORS in backend 2025-12-07 19:15:23 +02:00
dvirlabs
02541a62ea Update frontend 2025-12-07 10:33:59 +02:00
b4008f5b93 Use separate ingress for frontend and backend (like navix): frontend at my-recipes.dvirlabs.com, backend at api-my-recipes.dvirlabs.com 2025-12-06 22:44:59 +02:00
5c8481304f Fix frontend API connectivity: switch to nginx, add runtime env injection, update chart ports 2025-12-06 19:13:50 +02:00
88ec0585a7 Update API_BASE 2025-12-06 19:04:37 +02:00
bd31ffbff4 fix front conn 2025-12-05 16:37:38 +02:00
895786b405 Fix backend 2025-12-05 16:23:03 +02:00
e0b0ec94fe Add made by 2025-12-05 15:47:47 +02:00
56d00c2ed8 Remove axios and use fetch 2025-12-05 11:38:26 +02:00
7eeaf2fad9 Update frontend Dockerfile 2025-12-05 11:35:11 +02:00
99538b4b40 Update frontend Dockerfile 2025-12-05 11:33:40 +02:00
c43c33db37 Update frontend Dockerfile 2025-12-05 11:25:03 +02:00
30986fdf40 Update frontend Dockerfile 2025-12-05 11:17:35 +02:00
3afc7d2750 Merge pull request 'add-pic' (#2) from add-pic into master
Reviewed-on: #2
2025-12-05 09:13:12 +00:00
c8025dc62b Add small pic to recipes and update the scehma.sql 2025-12-05 11:09:46 +02:00
02e4a5d7fa Add small pic to recipes and update the scehma.sql 2025-12-05 11:08:40 +02:00
984e10682b Fix the slider 2025-12-05 10:38:04 +02:00
9b302c86e5 Add search and filter 2025-12-05 09:51:28 +02:00
c6eaae7321 Merge pull request 'add-buttons' (#1) from add-buttons into master
Reviewed-on: #1
2025-12-05 07:11:42 +00:00
26 changed files with 1331 additions and 84 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 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 recipe_data: name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
""" """
conn = get_conn() conn = get_conn()
try: try:
@ -81,9 +81,11 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
time_minutes = %s, time_minutes = %s,
tags = %s, tags = %s,
ingredients = %s, ingredients = %s,
steps = %s steps = %s,
image = %s,
made_by = %s
WHERE id = %s WHERE id = %s
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
""", """,
( (
recipe_data["name"], recipe_data["name"],
@ -92,6 +94,8 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
json.dumps(recipe_data.get("tags", [])), json.dumps(recipe_data.get("tags", [])),
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("made_by"),
recipe_id, recipe_id,
), ),
) )
@ -129,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) INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by)
VALUES (%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 RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
""", """,
( (
recipe_data["name"], recipe_data["name"],
@ -140,6 +144,8 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
json.dumps(recipe_data.get("tags", [])), json.dumps(recipe_data.get("tags", [])),
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("made_by"),
), ),
) )
row = cur.fetchone() row = cur.fetchone()
@ -157,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,9 +21,11 @@ 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] = []
image: Optional[str] = None # Base64-encoded image or image URL
class RecipeCreate(RecipeBase): class RecipeCreate(RecipeBase):
@ -44,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=["*"],
@ -62,9 +72,11 @@ 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 [],
image=r.get("image"),
) )
for r in rows for r in rows
] ]
@ -83,9 +95,11 @@ 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 [],
image=row.get("image"),
) )
@app.put("/recipes/{recipe_id}", response_model=Recipe) @app.put("/recipes/{recipe_id}", response_model=Recipe)
@ -102,9 +116,11 @@ 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 [],
image=row.get("image"),
) )
@ -133,9 +149,11 @@ 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 [],
image=r.get("image"),
) )
for r in rows for r in rows
] ]

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,10 +4,11 @@ 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 '[]', -- ["לחתוך", "לבשל", ...]
image TEXT -- Base64-encoded image or image URL
); );
-- Optional: index for filters -- Optional: index for filters
@ -17,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

@ -248,6 +248,23 @@ select {
border: 1px solid rgba(34, 197, 94, 0.6); border: 1px solid rgba(34, 197, 94, 0.6);
} }
.recipe-list-image {
width: 50px;
height: 50px;
border-radius: 8px;
overflow: hidden;
margin-left: 0.6rem;
flex-shrink: 0;
background: rgba(15, 23, 42, 0.5);
}
.recipe-list-image img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.recipe-list-main { .recipe-list-main {
flex: 1; flex: 1;
} }
@ -297,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;
@ -311,6 +335,23 @@ select {
font-size: 0.78rem; font-size: 0.78rem;
} }
/* Recipe Image */
.recipe-image-container {
width: 100%;
max-height: 250px;
border-radius: 12px;
overflow: hidden;
margin-bottom: 0.8rem;
background: rgba(15, 23, 42, 0.5);
}
.recipe-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.recipe-body { .recipe-body {
display: grid; display: grid;
gap: 0.8rem; gap: 0.8rem;
@ -502,6 +543,90 @@ select {
flex: 1; flex: 1;
} }
/* Image Upload */
.image-upload-wrapper {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.image-preview {
position: relative;
width: 100%;
max-height: 180px;
border-radius: 10px;
overflow: hidden;
background: rgba(15, 23, 42, 0.5);
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.image-remove-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
border: none;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
transition: background 0.2s ease;
}
.image-remove-btn:hover {
background: rgba(249, 115, 115, 0.8);
}
.image-input {
display: none;
}
.image-upload-label {
padding: 0.7rem 1rem;
border-radius: 8px;
border: 2px dashed rgba(148, 163, 184, 0.5);
background: transparent;
color: var(--text-main);
text-align: center;
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
font-size: 0.9rem;
}
.image-upload-label:hover {
border-color: var(--accent);
color: var(--accent);
background: rgba(34, 197, 94, 0.05);
}
/* Light mode image upload */
[data-theme="light"] .image-preview {
background: rgba(229, 231, 235, 0.5);
}
[data-theme="light"] .image-upload-label {
border: 2px dashed rgba(107, 114, 128, 0.4);
color: var(--text-main);
}
[data-theme="light"] .image-upload-label:hover {
border-color: var(--accent);
color: var(--accent);
background: rgba(5, 150, 105, 0.05);
}
/* Toast Notifications */ /* Toast Notifications */
.toast-container { .toast-container {
@ -754,4 +879,380 @@ html {
[data-theme="light"] html { [data-theme="light"] html {
scrollbar-color: rgba(107, 114, 128, 0.4) transparent; scrollbar-color: rgba(107, 114, 128, 0.4) transparent;
}
/* Search and Filter Panel */
.search-filter-panel {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.search-box-wrapper {
position: relative;
display: flex;
align-items: center;
}
.search-box {
width: 100%;
padding: 0.65rem 2.4rem 0.65rem 0.8rem;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.6);
background: #020617;
color: var(--text-main);
font-size: 0.9rem;
}
.search-box::placeholder {
color: var(--text-muted);
}
.search-box:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15);
}
.search-clear {
position: absolute;
left: 0.6rem;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.3rem 0.4rem;
font-size: 0.95rem;
transition: color 0.2s ease;
}
.search-clear:hover {
color: var(--text-main);
}
.filters-grid {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.filter-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.filter-options {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.filter-btn {
padding: 0.35rem 0.75rem;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.4);
background: transparent;
color: var(--text-main);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s ease;
}
.filter-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.filter-btn.active {
background: var(--accent);
color: #f9fafb;
border-color: var(--accent);
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3);
}
.time-filter-wrapper {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.time-slider {
width: 100%;
height: 5px;
border-radius: 5px;
background: rgba(148, 163, 184, 0.2);
outline: none;
appearance: none;
-webkit-appearance: none;
}
.time-slider::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
box-shadow: 0 2px 6px rgba(34, 197, 94, 0.4);
}
.time-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: none;
box-shadow: 0 2px 6px rgba(34, 197, 94, 0.4);
}
.time-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-muted);
}
.tag-filter-wrapper {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.tag-filter-btn {
padding: 0.3rem 0.65rem;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.4);
background: transparent;
color: var(--text-main);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
}
.tag-filter-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.tag-filter-btn.active {
background: var(--accent);
color: #f9fafb;
border-color: var(--accent);
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.3);
}
/* Light mode search and filter styles */
[data-theme="light"] .search-box {
border: 1px solid rgba(107, 114, 128, 0.3);
background: #ffffff;
color: var(--text-main);
}
[data-theme="light"] .search-box:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.15);
}
[data-theme="light"] .filter-btn {
border: 1px solid rgba(107, 114, 128, 0.3);
color: var(--text-main);
}
[data-theme="light"] .filter-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
[data-theme="light"] .filter-btn.active {
background: var(--accent);
color: #ffffff;
box-shadow: 0 4px 12px rgba(5, 150, 105, 0.3);
}
[data-theme="light"] .time-slider {
background: rgba(107, 114, 128, 0.15);
}
[data-theme="light"] .time-slider::-webkit-slider-thumb {
background: var(--accent);
box-shadow: 0 2px 6px rgba(5, 150, 105, 0.3);
}
[data-theme="light"] .time-slider::-moz-range-thumb {
background: var(--accent);
box-shadow: 0 2px 6px rgba(5, 150, 105, 0.3);
}
[data-theme="light"] .tag-filter-btn {
border: 1px solid rgba(107, 114, 128, 0.3);
color: var(--text-main);
}
[data-theme="light"] .tag-filter-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
[data-theme="light"] .tag-filter-btn.active {
background: var(--accent);
color: #ffffff;
box-shadow: 0 2px 8px rgba(5, 150, 105, 0.3);
}
/* Unified Recipe Search List */
.recipe-search-list {
display: flex;
flex-direction: column;
padding: 0;
}
.recipe-search-header {
display: flex;
gap: 0.6rem;
align-items: stretch;
padding: 1.1rem 1.2rem 0.8rem;
border-bottom: 1px solid var(--border-subtle);
}
.search-box-wrapper {
flex: 1;
position: relative;
display: flex;
align-items: center;
}
.filter-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0.5rem 0.8rem;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.4);
background: transparent;
color: var(--text-main);
cursor: pointer;
font-size: 1rem;
transition: all 0.15s ease;
position: relative;
}
.filter-toggle-btn:hover {
border-color: var(--accent);
background: rgba(34, 197, 94, 0.1);
}
.filter-icon {
display: block;
font-size: 0.9rem;
}
.filter-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 0.3rem;
background: var(--accent);
color: #f9fafb;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 600;
}
.recipe-filters-expanded {
padding: 0.8rem 1.2rem;
border-bottom: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
gap: 0.8rem;
background: rgba(15, 23, 42, 0.5);
}
.recipe-list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.8rem 1.2rem 0.6rem;
}
.recipe-list-header h3 {
margin: 0;
font-size: 0.95rem;
}
.recipe-list {
list-style: none;
padding: 0.6rem 1.2rem 1rem;
margin: 0;
max-height: 400px;
overflow-y: auto;
flex: 1;
}
.recipe-list::-webkit-scrollbar {
width: 6px;
}
.recipe-list::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.4);
border-radius: 3px;
}
.recipe-list::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.6);
}
/* Light mode unified list */
[data-theme="light"] .recipe-search-header {
border-bottom: 1px solid rgba(107, 114, 128, 0.2);
}
[data-theme="light"] .filter-toggle-btn {
border: 1px solid rgba(107, 114, 128, 0.3);
color: var(--text-main);
}
[data-theme="light"] .filter-toggle-btn:hover {
border-color: var(--accent);
background: rgba(5, 150, 105, 0.1);
}
[data-theme="light"] .recipe-filters-expanded {
border-bottom: 1px solid rgba(107, 114, 128, 0.2);
background: rgba(229, 231, 235, 0.3);
}
[data-theme="light"] .recipe-list {
max-height: 400px;
overflow-y: auto;
}
[data-theme="light"] .recipe-list::-webkit-scrollbar-thumb {
background: rgba(107, 114, 128, 0.35);
}
[data-theme="light"] .recipe-list::-webkit-scrollbar-thumb:hover {
background: rgba(107, 114, 128, 0.5);
}
/* Light mode recipe list styling */
[data-theme="light"] .recipe-list-item:hover {
background: rgba(229, 231, 235, 0.6);
}
[data-theme="light"] .recipe-list-image {
background: rgba(229, 231, 235, 0.5);
} }

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import "./App.css"; import "./App.css";
import TopBar from "./components/TopBar"; import TopBar from "./components/TopBar";
import RecipeList from "./components/RecipeList"; import RecipeSearchList from "./components/RecipeSearchList";
import RecipeDetails from "./components/RecipeDetails"; import RecipeDetails from "./components/RecipeDetails";
import RecipeFormDrawer from "./components/RecipeFormDrawer"; import RecipeFormDrawer from "./components/RecipeFormDrawer";
import Modal from "./components/Modal"; import Modal from "./components/Modal";
@ -14,6 +14,14 @@ function App() {
const [recipes, setRecipes] = useState([]); const [recipes, setRecipes] = useState([]);
const [selectedRecipe, setSelectedRecipe] = useState(null); const [selectedRecipe, setSelectedRecipe] = useState(null);
// Recipe listing filters
const [searchQuery, setSearchQuery] = useState("");
const [filterMealType, setFilterMealType] = useState("");
const [filterMaxTime, setFilterMaxTime] = useState("");
const [filterTags, setFilterTags] = useState([]);
const [filterMadeBy, setFilterMadeBy] = useState("");
// Random recipe filters
const [mealTypeFilter, setMealTypeFilter] = useState(""); const [mealTypeFilter, setMealTypeFilter] = useState("");
const [maxTimeFilter, setMaxTimeFilter] = useState(""); const [maxTimeFilter, setMaxTimeFilter] = useState("");
const [ingredientsFilter, setIngredientsFilter] = useState(""); const [ingredientsFilter, setIngredientsFilter] = useState("");
@ -57,6 +65,46 @@ function App() {
} }
}; };
const getFilteredRecipes = () => {
return recipes.filter((recipe) => {
// Search by name
if (searchQuery && !recipe.name.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
}
// Filter by meal type
if (filterMealType && recipe.meal_type !== filterMealType) {
return false;
}
// Filter by prep time
if (filterMaxTime) {
const maxTime = parseInt(filterMaxTime, 10);
if (recipe.time_minutes > maxTime) {
return false;
}
}
// Filter by tags
if (filterTags.length > 0) {
const recipeTags = recipe.tags || [];
const hasAllTags = filterTags.every((tag) =>
recipeTags.some((t) => t.toLowerCase() === tag.toLowerCase())
);
if (!hasAllTags) {
return false;
}
}
// Filter by made_by
if (filterMadeBy && (!recipe.made_by || recipe.made_by !== filterMadeBy)) {
return false;
}
return true;
});
};
const handleRandomClick = async () => { const handleRandomClick = async () => {
setLoadingRandom(true); setLoadingRandom(true);
setError(""); setError("");
@ -167,6 +215,28 @@ function App() {
<main className="layout"> <main className="layout">
<section className="sidebar"> <section className="sidebar">
<RecipeSearchList
allRecipes={recipes}
recipes={getFilteredRecipes()}
selectedId={selectedRecipe?.id}
onSelect={setSelectedRecipe}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
filterMealType={filterMealType}
onMealTypeChange={setFilterMealType}
filterMaxTime={filterMaxTime}
onMaxTimeChange={setFilterMaxTime}
filterTags={filterTags}
onTagsChange={setFilterTags}
filterMadeBy={filterMadeBy}
onMadeByChange={setFilterMadeBy}
/>
</section>
<section className="content">
{error && <div className="error-banner">{error}</div>}
{/* Random Recipe Suggester - Top Left */}
<section className="panel filter-panel"> <section className="panel filter-panel">
<h3>חיפוש מתכון רנדומלי</h3> <h3>חיפוש מתכון רנדומלי</h3>
<div className="panel-grid"> <div className="panel-grid">
@ -214,15 +284,7 @@ function App() {
</button> </button>
</section> </section>
<RecipeList {/* Recipe Details Card */}
recipes={recipes}
selectedId={selectedRecipe?.id}
onSelect={setSelectedRecipe}
/>
</section>
<section className="content">
{error && <div className="error-banner">{error}</div>}
<RecipeDetails <RecipeDetails
recipe={selectedRecipe} recipe={selectedRecipe}
onEditClick={handleEditRecipe} onEditClick={handleEditRecipe}

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

@ -0,0 +1,25 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="200" height="200" fill="#e0e0e0"/>
<!-- Food plate icon -->
<circle cx="100" cy="100" r="70" fill="#c0c0c0" stroke="#999" stroke-width="2"/>
<!-- Fork -->
<g transform="translate(60, 80)">
<line x1="0" y1="0" x2="0" y2="35" stroke="#666" stroke-width="2" stroke-linecap="round"/>
<line x1="-8" y1="5" x2="-8" y2="35" stroke="#666" stroke-width="2" stroke-linecap="round"/>
<line x1="8" y1="5" x2="8" y2="35" stroke="#666" stroke-width="2" stroke-linecap="round"/>
<circle cx="0" cy="0" r="3" fill="#666"/>
</g>
<!-- Knife -->
<g transform="translate(130, 80)">
<line x1="0" y1="0" x2="0" y2="35" stroke="#666" stroke-width="2" stroke-linecap="round"/>
<polygon points="0,0 -6,8 6,8" fill="#666"/>
<circle cx="0" cy="2" r="3" fill="#999"/>
</g>
<!-- "No Image" text -->
<text x="100" y="155" font-family="Arial, sans-serif" font-size="14" fill="#666" text-anchor="middle" font-weight="bold">No Image</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,3 +1,5 @@
import placeholderImage from "../assets/placeholder.svg";
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }) { function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }) {
if (!recipe) { if (!recipe) {
return ( return (
@ -13,12 +15,20 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
return ( return (
<section className="panel recipe-card"> <section className="panel recipe-card">
{/* Recipe Image */}
<div className="recipe-image-container">
<img src={recipe.image || placeholderImage} alt={recipe.name} className="recipe-image" />
</div>
<header className="recipe-header"> <header className="recipe-header">
<div> <div>
<h2>{recipe.name}</h2> <h2>{recipe.name}</h2>
<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

@ -0,0 +1,157 @@
function RecipeFilters({
recipes,
searchQuery,
onSearchChange,
filterMealType,
onMealTypeChange,
filterMaxTime,
onMaxTimeChange,
filterTags,
onTagsChange,
}) {
// Extract unique tags from all recipes
const allTags = Array.from(
new Set(recipes.flatMap((recipe) => recipe.tags || []))
).sort();
// Extract unique meal types from recipes
const mealTypes = Array.from(new Set(recipes.map((r) => r.meal_type))).sort();
// Extract max time for slider
const maxTimeInRecipes = Math.max(...recipes.map((r) => r.time_minutes), 0);
const handleTagToggle = (tag) => {
if (filterTags.includes(tag)) {
onTagsChange(filterTags.filter((t) => t !== tag));
} else {
onTagsChange([...filterTags, tag]);
}
};
const clearAllFilters = () => {
onSearchChange("");
onMealTypeChange("");
onMaxTimeChange("");
onTagsChange([]);
};
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0;
return (
<section className="panel search-filter-panel">
<div className="panel-header">
<h3>חיפוש וסינון</h3>
{hasActiveFilters && (
<button className="btn ghost small" onClick={clearAllFilters}>
נקה הכל
</button>
)}
</div>
{/* Search Box */}
<div className="search-box-wrapper">
<input
type="text"
className="search-box"
placeholder="חיפוש לפי שם מתכון..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
{searchQuery && (
<button
className="search-clear"
onClick={() => onSearchChange("")}
title="נקה חיפוש"
>
</button>
)}
</div>
{/* Filters Grid */}
<div className="filters-grid">
{/* Meal Type Filter */}
{mealTypes.length > 0 && (
<div className="filter-group">
<label className="filter-label">סוג ארוחה</label>
<div className="filter-options">
<button
className={`filter-btn ${filterMealType === "" ? "active" : ""}`}
onClick={() => onMealTypeChange("")}
>
הכל
</button>
{mealTypes.map((type) => (
<button
key={type}
className={`filter-btn ${filterMealType === type ? "active" : ""}`}
onClick={() => onMealTypeChange(type)}
>
{translateMealType(type)}
</button>
))}
</div>
</div>
)}
{/* Prep Time Filter */}
{maxTimeInRecipes > 0 && (
<div className="filter-group">
<label className="filter-label">
זמן הכנה {filterMaxTime ? `עד ${filterMaxTime} דקות` : ""}
</label>
<div className="time-filter-wrapper">
<input
type="range"
min="0"
max={maxTimeInRecipes}
value={filterMaxTime || 0}
onChange={(e) => onMaxTimeChange(e.target.value === "0" ? "" : e.target.value)}
className="time-slider"
/>
<div className="time-labels">
<span>0</span>
<span>{maxTimeInRecipes}</span>
</div>
</div>
</div>
)}
{/* Tags Filter */}
{allTags.length > 0 && (
<div className="filter-group">
<label className="filter-label">תגיות</label>
<div className="tag-filter-wrapper">
{allTags.map((tag) => (
<button
key={tag}
className={`tag-filter-btn ${filterTags.includes(tag) ? "active" : ""}`}
onClick={() => handleTagToggle(tag)}
>
{tag}
</button>
))}
</div>
</div>
)}
</div>
</section>
);
}
function translateMealType(type) {
switch (type) {
case "breakfast":
return "בוקר";
case "lunch":
return "צהריים";
case "dinner":
return "ערב";
case "snack":
return "נשנוש";
default:
return type;
}
}
export default RecipeFilters;

View File

@ -4,7 +4,9 @@ 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 [ingredients, setIngredients] = useState([""]); const [ingredients, setIngredients] = useState([""]);
const [steps, setSteps] = useState([""]); const [steps, setSteps] = useState([""]);
@ -17,14 +19,18 @@ 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 || "");
setIngredients(editingRecipe.ingredients || [""]); setIngredients(editingRecipe.ingredients || [""]);
setSteps(editingRecipe.steps || [""]); setSteps(editingRecipe.steps || [""]);
} else { } else {
setName(""); setName("");
setMealType("lunch"); setMealType("lunch");
setTimeMinutes(15); setTimeMinutes(15);
setMadeBy("");
setTags(""); setTags("");
setImage("");
setIngredients([""]); setIngredients([""]);
setSteps([""]); setSteps([""]);
} }
@ -57,6 +63,21 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
setSteps((prev) => prev.filter((_, i) => i !== idx || prev.length === 1)); setSteps((prev) => prev.filter((_, i) => i !== idx || prev.length === 1));
}; };
const handleImageChange = (e) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setImage(reader.result);
};
reader.readAsDataURL(file);
}
};
const handleRemoveImage = () => {
setImage("");
};
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
@ -67,14 +88,24 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
.map((t) => t.trim()) .map((t) => t.trim())
.filter(Boolean); .filter(Boolean);
onSubmit({ const payload = {
name, name,
meal_type: mealType, meal_type: mealType,
time_minutes: Number(timeMinutes), time_minutes: Number(timeMinutes),
tags: tagsArr, tags: tagsArr,
ingredients: cleanIngredients, ingredients: cleanIngredients,
steps: cleanSteps, steps: cleanSteps,
}); };
if (madeBy.trim()) {
payload.made_by = madeBy.trim();
}
if (image) {
payload.image = image;
}
onSubmit(payload);
}; };
return ( return (
@ -121,6 +152,44 @@ 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">
<label>תמונה של המתכון</label>
<div className="image-upload-wrapper">
{image && (
<div className="image-preview">
<img src={image} alt="preview" />
<button
type="button"
className="image-remove-btn"
onClick={handleRemoveImage}
title="הסרת תמונה"
>
</button>
</div>
)}
<input
type="file"
accept="image/*"
onChange={handleImageChange}
className="image-input"
id="recipe-image-input"
/>
<label htmlFor="recipe-image-input" className="image-upload-label">
{image ? "החלף תמונה" : "בחר תמונה"}
</label>
</div>
</div>
<div className="field"> <div className="field">
<label>תגיות (מופרד בפסיקים)</label> <label>תגיות (מופרד בפסיקים)</label>
<input <input

View File

@ -0,0 +1,248 @@
import { useState } from "react";
import placeholderImage from "../assets/placeholder.svg";
function RecipeSearchList({
allRecipes,
recipes,
selectedId,
onSelect,
searchQuery,
onSearchChange,
filterMealType,
onMealTypeChange,
filterMaxTime,
onMaxTimeChange,
filterTags,
onTagsChange,
filterMadeBy,
onMadeByChange,
}) {
const [expandFilters, setExpandFilters] = useState(false);
// Extract unique tags from ALL recipes (not filtered)
const allTags = Array.from(
new Set(allRecipes.flatMap((recipe) => recipe.tags || []))
).sort();
// 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;
const handleTagToggle = (tag) => {
if (filterTags.includes(tag)) {
onTagsChange(filterTags.filter((t) => t !== tag));
} else {
onTagsChange([...filterTags, tag]);
}
};
const clearAllFilters = () => {
onSearchChange("");
onMealTypeChange("");
onMaxTimeChange("");
onTagsChange([]);
onMadeByChange("");
};
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterMadeBy;
return (
<section className="panel secondary recipe-search-list">
{/* Header with Search */}
<div className="recipe-search-header">
<div className="search-box-wrapper">
<input
type="text"
className="search-box"
placeholder="חיפוש לפי שם מתכון..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
{searchQuery && (
<button
className="search-clear"
onClick={() => onSearchChange("")}
title="נקה חיפוש"
>
</button>
)}
</div>
{/* Filter Toggle Button */}
<button
className="filter-toggle-btn"
onClick={() => setExpandFilters(!expandFilters)}
title={expandFilters ? "הסתר סינונים" : "הצג סינונים"}
>
<span className="filter-icon"></span>
{hasActiveFilters && <span className="filter-badge">{Object.values([filterMealType, filterMaxTime, filterTags.length > 0 ? "tags" : null]).filter(Boolean).length}</span>}
</button>
</div>
{/* Expandable Filters */}
{expandFilters && (
<div className="recipe-filters-expanded">
<div className="filters-grid">
{/* Meal Type Filter */}
{mealTypes.length > 0 && (
<div className="filter-group">
<label className="filter-label">סוג ארוחה</label>
<div className="filter-options">
<button
className={`filter-btn ${filterMealType === "" ? "active" : ""}`}
onClick={() => onMealTypeChange("")}
>
הכל
</button>
{mealTypes.map((type) => (
<button
key={type}
className={`filter-btn ${filterMealType === type ? "active" : ""}`}
onClick={() => onMealTypeChange(type)}
>
{translateMealType(type)}
</button>
))}
</div>
</div>
)}
{/* Prep Time Filter */}
{maxTimeInRecipes > 0 && (
<div className="filter-group">
<label className="filter-label">
זמן הכנה {filterMaxTime ? `עד ${filterMaxTime} דקות` : "הכל"}
</label>
<div className="time-filter-wrapper">
<input
type="range"
min="0"
max={sliderMax}
step="1"
value={filterMaxTime || 0}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
onMaxTimeChange(val === 0 ? "" : String(val));
}}
className="time-slider"
/>
<div className="time-labels">
<span>0</span>
<span>{sliderMax}</span>
</div>
</div>
</div>
)}
{/* Tags Filter */}
{allTags.length > 0 && (
<div className="filter-group">
<label className="filter-label">תגיות</label>
<div className="tag-filter-wrapper">
{allTags.map((tag) => (
<button
key={tag}
className={`tag-filter-btn ${filterTags.includes(tag) ? "active" : ""}`}
onClick={() => handleTagToggle(tag)}
>
{tag}
</button>
))}
</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 */}
{hasActiveFilters && (
<button className="btn ghost full" onClick={clearAllFilters} style={{ marginTop: "0.6rem" }}>
נקה הכל
</button>
)}
</div>
)}
{/* Recipe List Header */}
<div className="recipe-list-header">
<h3>כל המתכונים</h3>
<span className="badge">{recipes.length}</span>
</div>
{/* Recipe List */}
{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-image">
<img src={r.image || placeholderImage} alt={r.name} />
</div>
<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 RecipeSearchList;

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'],
}) })