Compare commits

...

15 Commits

24 changed files with 302 additions and 72 deletions

View File

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

11
backend/.dockerignore Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS recipes (
name TEXT NOT NULL,
meal_type TEXT NOT NULL, -- breakfast / lunch / dinner / snack
time_minutes INTEGER NOT NULL,
made_by TEXT, -- Person who created this recipe version
tags JSONB NOT NULL DEFAULT '[]', -- ["מהיר", "בריא"]
ingredients JSONB NOT NULL DEFAULT '[]', -- ["ביצה", "עגבניה", "מלח"]
steps JSONB NOT NULL DEFAULT '[]', -- ["לחתוך", "לבשל", ...]
@ -18,8 +18,12 @@ CREATE INDEX IF NOT EXISTS idx_recipes_meal_type
CREATE INDEX IF NOT EXISTS idx_recipes_time_minutes
ON recipes (time_minutes);
CREATE INDEX IF NOT EXISTS idx_recipes_made_by
ON recipes (made_by);
CREATE INDEX IF NOT EXISTS idx_recipes_tags_jsonb
ON recipes USING GIN (tags);
CREATE INDEX IF NOT EXISTS idx_recipes_ingredients_jsonb
ON recipes USING GIN (ingredients);

9
frontend/.dockerignore Normal file
View File

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

3
frontend/.gitignore vendored
View File

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

View File

@ -0,0 +1,31 @@
#!/bin/sh
# Don't use set -e to avoid crashing if chown fails
# Generate env.js from API_BASE environment variable
# This is set in the Helm deployment values
TARGET="/usr/share/nginx/html/env.js"
# API_BASE should be set via deployment env (e.g., from Helm values)
# Default to /api as fallback (relative path)
: ${API_BASE:=/api}
echo "[ENTRYPOINT] Generating env.js with API_BASE=${API_BASE}"
cat > "$TARGET" <<EOF
window.__ENV__ = {
API_BASE: "${API_BASE}"
};
EOF
if [ -f "$TARGET" ]; then
echo "[ENTRYPOINT] ✓ env.js generated successfully at $TARGET"
cat "$TARGET"
else
echo "[ENTRYPOINT] ✗ Failed to generate env.js"
exit 1
fi
# Ensure ownership/permissions for nginx (don't fail if this doesn't work)
chown nginx:nginx /usr/share/nginx/html/env.js 2>/dev/null || echo "[ENTRYPOINT] Note: Could not change ownership (not critical)"
echo "[ENTRYPOINT] env.js setup complete"

37
frontend/Dockerfile Normal file
View File

@ -0,0 +1,37 @@
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --prefer-offline --no-audit
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage - use nginx to serve static files
FROM nginx:alpine
# Copy nginx config
COPY nginx.conf /etc/nginx/nginx.conf
# Copy built app from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy entrypoint script to nginx entrypoint.d directory
# This will run before nginx starts and generate env.js from API_BASE env var
COPY 10-generate-env.sh /docker-entrypoint.d/10-generate-env.sh
# Ensure entrypoint script is executable and has correct line endings
RUN chmod +x /docker-entrypoint.d/10-generate-env.sh && \
sed -i 's/\r$//' /docker-entrypoint.d/10-generate-env.sh
EXPOSE 80
# nginx will start automatically; our script in /docker-entrypoint.d runs first

View File

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

28
frontend/nginx.conf Normal file
View File

@ -0,0 +1,28 @@
worker_processes 1;
events { worker_connections 1024; }
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Serve static files and fallback to index.html for SPA
location / {
try_files $uri $uri/ /index.html;
}
# Optional caching for static assets
location ~* \.(?:css|js|svg|png|jpg|jpeg|gif|ico)$ {
try_files $uri =404;
expires 7d;
add_header Cache-Control "public";
}
}
}

View File

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

View File

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

View File

@ -19,6 +19,7 @@ function App() {
const [filterMealType, setFilterMealType] = useState("");
const [filterMaxTime, setFilterMaxTime] = useState("");
const [filterTags, setFilterTags] = useState([]);
const [filterMadeBy, setFilterMadeBy] = useState("");
// Random recipe filters
const [mealTypeFilter, setMealTypeFilter] = useState("");
@ -95,6 +96,11 @@ function App() {
}
}
// Filter by made_by
if (filterMadeBy && (!recipe.made_by || recipe.made_by !== filterMadeBy)) {
return false;
}
return true;
});
};
@ -222,6 +228,8 @@ function App() {
onMaxTimeChange={setFilterMaxTime}
filterTags={filterTags}
onTagsChange={setFilterTags}
filterMadeBy={filterMadeBy}
onMadeByChange={setFilterMadeBy}
/>
</section>

View File

@ -1,31 +1,52 @@
import axios from "axios";
// Get API base from injected env.js or fallback to /api relative path
const getApiBase = () => {
if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) {
return window.__ENV__.API_BASE;
}
// Default to relative /api path for local/containerized deployments
return "/api";
};
const API_BASE = "http://localhost:8000";
const API_BASE = getApiBase();
export async function getRecipes() {
const res = await axios.get(`${API_BASE}/recipes`);
return res.data;
const res = await fetch(`${API_BASE}/recipes`);
if (!res.ok) {
throw new Error("Failed to fetch recipes");
}
return res.json();
}
export async function getRandomRecipe(filters) {
const params = {};
if (filters.mealType) params.meal_type = filters.mealType;
if (filters.maxTime) params.max_time = filters.maxTime;
const params = new URLSearchParams();
if (filters.mealType) params.append("meal_type", filters.mealType);
if (filters.maxTime) params.append("max_time", filters.maxTime);
if (filters.ingredients && filters.ingredients.length > 0) {
filters.ingredients.forEach((ing) => {
params.ingredients = params.ingredients || [];
params.ingredients.push(ing);
params.append("ingredients", ing);
});
}
const res = await axios.get(`${API_BASE}/recipes/random`, { params });
return res.data;
const queryString = params.toString();
const url = queryString ? `${API_BASE}/recipes/random?${queryString}` : `${API_BASE}/recipes/random`;
const res = await fetch(url);
if (!res.ok) {
throw new Error("Failed to fetch random recipe");
}
return res.json();
}
export async function createRecipe(recipe) {
const res = await axios.post(`${API_BASE}/recipes`, recipe);
return res.data;
const res = await fetch(`${API_BASE}/recipes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(recipe),
});
if (!res.ok) {
throw new Error("Failed to create recipe");
}
return res.json();
}
export async function updateRecipe(id, payload) {

View File

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

View File

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

View File

@ -14,6 +14,8 @@ function RecipeSearchList({
onMaxTimeChange,
filterTags,
onTagsChange,
filterMadeBy,
onMadeByChange,
}) {
const [expandFilters, setExpandFilters] = useState(false);
@ -25,6 +27,9 @@ function RecipeSearchList({
// Extract unique meal types from ALL recipes (not filtered)
const mealTypes = Array.from(new Set(allRecipes.map((r) => r.meal_type))).sort();
// Extract unique made_by from ALL recipes (not filtered)
const allMadeBy = Array.from(new Set(allRecipes.map((r) => r.made_by).filter(Boolean))).sort();
// Extract max time for slider from ALL recipes (not filtered) - add 10 to make it comfortable to select max time recipes
const maxTimeInRecipes = Math.max(...allRecipes.map((r) => r.time_minutes), 0);
const sliderMax = maxTimeInRecipes > 0 ? maxTimeInRecipes + 10 : 70;
@ -42,9 +47,10 @@ function RecipeSearchList({
onMealTypeChange("");
onMaxTimeChange("");
onTagsChange([]);
onMadeByChange("");
};
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0;
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterMadeBy;
return (
<section className="panel secondary recipe-search-list">
@ -152,6 +158,30 @@ function RecipeSearchList({
</div>
</div>
)}
{/* Made By Filter */}
{allMadeBy.length > 0 && (
<div className="filter-group">
<label className="filter-label">המתכונים של:</label>
<div className="filter-options">
<button
className={`filter-btn ${filterMadeBy === "" ? "active" : ""}`}
onClick={() => onMadeByChange("")}
>
הכל
</button>
{allMadeBy.map((person) => (
<button
key={person}
className={`filter-btn ${filterMadeBy === person ? "active" : ""}`}
onClick={() => onMadeByChange(person)}
>
{person}
</button>
))}
</div>
</div>
)}
</div>
{/* Clear All Button */}

View File

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