Compare commits

..

No commits in common. "master" and "add-buttons" have entirely different histories.

26 changed files with 84 additions and 1331 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

View File

@ -1,11 +0,0 @@
__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, made_by tags, ingredients, steps
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, made_by recipe_data: name, meal_type, time_minutes, tags, ingredients, steps
""" """
conn = get_conn() conn = get_conn()
try: try:
@ -81,11 +81,9 @@ 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, image, made_by RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps
""", """,
( (
recipe_data["name"], recipe_data["name"],
@ -94,8 +92,6 @@ 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,
), ),
) )
@ -133,9 +129,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, made_by) INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps
""", """,
( (
recipe_data["name"], recipe_data["name"],
@ -144,8 +140,6 @@ 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()
@ -163,7 +157,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, image, made_by tags, ingredients, steps
FROM recipes FROM recipes
WHERE 1=1 WHERE 1=1
""" """

View File

@ -21,11 +21,9 @@ 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):
@ -46,17 +44,9 @@ 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=allowed_origins, allow_origins=["http://localhost:5173", "http://localhost:3000"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@ -72,11 +62,9 @@ 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
] ]
@ -95,11 +83,9 @@ 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)
@ -116,11 +102,9 @@ 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"),
) )
@ -149,11 +133,9 @@ 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.115.0 fastapi==0.103.2
uvicorn[standard]==0.30.1 uvicorn[standard]==0.23.2
pydantic==2.7.4 pydantic==2.7.4
python-dotenv==1.0.1 python-dotenv==1.0.1

View File

@ -4,11 +4,10 @@ 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
@ -18,12 +17,8 @@ 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);

View File

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

3
frontend/.gitignore vendored
View File

@ -12,9 +12,6 @@ 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

@ -1,31 +0,0 @@
#!/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"

View File

@ -1,37 +0,0 @@
# 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,8 +5,6 @@
<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>

View File

@ -1,28 +0,0 @@
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

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

View File

@ -248,23 +248,6 @@ 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;
} }
@ -314,13 +297,6 @@ 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;
@ -335,23 +311,6 @@ 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;
@ -543,90 +502,6 @@ 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 {
@ -879,380 +754,4 @@ 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 RecipeSearchList from "./components/RecipeSearchList"; import RecipeList from "./components/RecipeList";
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,14 +14,6 @@ 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("");
@ -65,46 +57,6 @@ 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("");
@ -215,28 +167,6 @@ 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">
@ -284,7 +214,15 @@ function App() {
</button> </button>
</section> </section>
{/* Recipe Details Card */} <RecipeList
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,52 +1,31 @@
// Get API base from injected env.js or fallback to /api relative path import axios from "axios";
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 = getApiBase(); const API_BASE = "http://localhost:8000";
export async function getRecipes() { export async function getRecipes() {
const res = await fetch(`${API_BASE}/recipes`); const res = await axios.get(`${API_BASE}/recipes`);
if (!res.ok) { return res.data;
throw new Error("Failed to fetch recipes");
}
return res.json();
} }
export async function getRandomRecipe(filters) { export async function getRandomRecipe(filters) {
const params = new URLSearchParams(); const params = {};
if (filters.mealType) params.append("meal_type", filters.mealType); if (filters.mealType) params.meal_type = filters.mealType;
if (filters.maxTime) params.append("max_time", filters.maxTime); if (filters.maxTime) params.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.append("ingredients", ing); params.ingredients = params.ingredients || [];
params.ingredients.push(ing);
}); });
} }
const queryString = params.toString(); const res = await axios.get(`${API_BASE}/recipes/random`, { params });
const url = queryString ? `${API_BASE}/recipes/random?${queryString}` : `${API_BASE}/recipes/random`; return res.data;
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 fetch(`${API_BASE}/recipes`, { const res = await axios.post(`${API_BASE}/recipes`, recipe);
method: "POST", return res.data;
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

@ -1,25 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,5 +1,3 @@
import placeholderImage from "../assets/placeholder.svg";
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }) { function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }) {
if (!recipe) { if (!recipe) {
return ( return (
@ -15,20 +13,12 @@ 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

@ -1,157 +0,0 @@
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,9 +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 [ingredients, setIngredients] = useState([""]); const [ingredients, setIngredients] = useState([""]);
const [steps, setSteps] = useState([""]); const [steps, setSteps] = useState([""]);
@ -19,18 +17,14 @@ 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([""]);
} }
@ -63,21 +57,6 @@ 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();
@ -88,24 +67,14 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
.map((t) => t.trim()) .map((t) => t.trim())
.filter(Boolean); .filter(Boolean);
const payload = { onSubmit({
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 (
@ -152,44 +121,6 @@ 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

@ -1,248 +0,0 @@
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,5 +4,4 @@ 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'],
}) })