Compare commits
No commits in common. "master" and "add-buttons" have entirely different histories.
master
...
add-button
@ -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
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
__pycache__
|
|
||||||
*.pyc
|
|
||||||
*.pyo
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.DS_Store
|
|
||||||
.pytest_cache
|
|
||||||
venv
|
|
||||||
env
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -55,7 +55,7 @@ def list_recipes_db() -> List[Dict[str, Any]]:
|
|||||||
cur.execute(
|
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
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
node_modules
|
|
||||||
npm-debug.log
|
|
||||||
dist
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
README.md
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.DS_Store
|
|
||||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@ -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
|
||||||
|
|||||||
@ -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"
|
|
||||||
@ -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
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
window.__ENV__ = {
|
|
||||||
API_BASE: "${API_BASE:-/api}"
|
|
||||||
};
|
|
||||||
@ -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 {
|
||||||
@ -880,379 +755,3 @@ 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);
|
|
||||||
}
|
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 |
@ -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>
|
||||||
|
|||||||
@ -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;
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
|
||||||
@ -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'],
|
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user