diff --git a/.woodpecker.yaml b/.woodpecker.yaml new file mode 100644 index 0000000..f226786 --- /dev/null +++ b/.woodpecker.yaml @@ -0,0 +1,121 @@ +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-backend: + name: Build & Push Backend + image: woodpeckerci/plugin-kaniko + when: + branch: [ master, develop ] + event: [ push, pull_request, tag ] + path: + include: [ backend/** ] + settings: + registry: harbor.dvirlabs.com + repo: my-apps/${CI_REPO_NAME}-backend + dockerfile: backend/Dockerfile + context: backend + tags: + - latest + - ${CI_COMMIT_TAG:-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}} + username: + from_secret: DOCKER_USERNAME + 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-backend: + name: Update backend tag in values.yaml + image: alpine:3.19 + when: + branch: [ master, develop ] + event: [ push ] + path: + include: [ backend/** ] + 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" + - | + if [ ! -d "my-apps" ]; then + git clone "https://${GIT_USERNAME}:${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/my-apps.git" + fi + - cd my-apps + - | + TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}" + echo "๐ก Setting backend tag to: $TAG" + yq -i ".backend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml + git add manifests/${CI_REPO_NAME}/values.yaml + git commit -m "backend: update tag to $TAG" || echo "No changes" + git push origin HEAD + + + trigger-gitops-via-push: + when: + branch: [ master, develop ] + event: [ push ] + name: Trigger apps-gitops via Git push + image: alpine/git + environment: + GIT_USERNAME: + from_secret: GIT_USERNAME + GIT_TOKEN: + from_secret: GIT_TOKEN + commands: | + 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/apps-gitops.git" + cd apps-gitops + echo "# trigger at $(date) by $${CI_REPO_NAME}" >> .trigger + git add .trigger + git commit -m "ci: trigger apps-gitops build" || echo "no changes" + git push origin HEAD diff --git a/backend/.env b/backend/.env index 0c25ad1..e8b54af 100644 --- a/backend/.env +++ b/backend/.env @@ -1 +1 @@ -DATABASE_URL=postgresql://recipes_user:recipes_password@localhost:5432/recipes_db +DATABASE_URL=postgresql://recipes_user:Aa123456@localhost:5432/recipes_db diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..79616ca --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# deps for psycopg2 +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/__pycache__/db_utils.cpython-313.pyc b/backend/__pycache__/db_utils.cpython-313.pyc new file mode 100644 index 0000000..56c26ca Binary files /dev/null and b/backend/__pycache__/db_utils.cpython-313.pyc differ diff --git a/backend/__pycache__/main.cpython-313.pyc b/backend/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..ab172e1 Binary files /dev/null and b/backend/__pycache__/main.cpython-313.pyc differ diff --git a/backend/db_utils.py b/backend/db_utils.py index 00983d0..4ab6fe4 100644 --- a/backend/db_utils.py +++ b/backend/db_utils.py @@ -6,16 +6,39 @@ import psycopg2 from psycopg2.extras import RealDictCursor from dotenv import load_dotenv +# load .env for local/dev; in K8s you'll use environment variables load_dotenv() +# Prefer explicit envs (K8s), fallback to DATABASE_URL (local dev) +DB_HOST = os.getenv("DB_HOST") +DB_PORT = os.getenv("DB_PORT", "5432") +DB_NAME = os.getenv("DB_NAME") +DB_USER = os.getenv("DB_USER") +DB_PASSWORD = os.getenv("DB_PASSWORD") + DATABASE_URL = os.getenv( "DATABASE_URL", "postgresql://user:password@localhost:5432/recipes_db", ) +def _build_dsn() -> str: + if DB_HOST and DB_NAME and DB_USER and DB_PASSWORD: + return ( + f"dbname={DB_NAME} user={DB_USER} password={DB_PASSWORD} " + f"host={DB_HOST} port={DB_PORT}" + ) + if DATABASE_URL: + return DATABASE_URL + raise RuntimeError( + "No DB configuration found. Set DB_HOST/DB_NAME/DB_USER/DB_PASSWORD " + "or DATABASE_URL." + ) + + def get_conn(): - return psycopg2.connect(DATABASE_URL, cursor_factory=RealDictCursor) + dsn = _build_dsn() + return psycopg2.connect(dsn, cursor_factory=RealDictCursor) def list_recipes_db() -> List[Dict[str, Any]]: diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml index dcf5fca..1df84f0 100644 --- a/backend/docker-compose.yaml +++ b/backend/docker-compose.yaml @@ -7,7 +7,7 @@ services: restart: unless-stopped environment: POSTGRES_USER: recipes_user - POSTGRES_PASSWORD: recipes_password + POSTGRES_PASSWORD: Aa123456 POSTGRES_DB: recipes_db ports: - "5432:5432" diff --git a/frontend/src/App.css b/frontend/src/App.css index 245df5d..79979c3 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,85 +1,433 @@ +:root { + --bg: #020617; + --bg-elevated: #020617; + --card: #0b1120; + --card-soft: #020617; + --border-subtle: rgba(148, 163, 184, 0.35); + --accent: #22c55e; + --accent-soft: rgba(34, 197, 94, 0.16); + --accent-strong: #16a34a; + --text-main: #e5e7eb; + --text-muted: #9ca3af; + --danger: #f97373; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + background: radial-gradient(circle at top, #0f172a 0, #020617 55%); + color: var(--text-main); +} + .app-root { - max-width: 900px; + min-height: 100vh; + max-width: 1200px; margin: 0 auto; padding: 1.5rem; - font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; direction: rtl; - text-align: right; } -h1 { - text-align: center; - margin-bottom: 1.5rem; +/* Top bar */ + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + background: linear-gradient(90deg, #020617, #020617f2); + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.45); + padding: 0.8rem 1.2rem; + margin-bottom: 1.6rem; + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.9); } -.card { - border-radius: 12px; - border: 1px solid #e5e7eb; - padding: 1.25rem; - margin-bottom: 1.5rem; - background: #ffffff; - box-shadow: 0 4px 12px rgba(15, 23, 42, 0.04); +.topbar-left { + display: flex; + align-items: center; + gap: 0.65rem; } -.grid { +.logo-emoji { + font-size: 1.8rem; +} + +.brand-title { + font-weight: 800; + font-size: 1.2rem; +} + +.brand-subtitle { + font-size: 0.8rem; + color: var(--text-muted); +} + +/* Layout */ + +.layout { display: grid; + gap: 1.4rem; +} + +@media (min-width: 960px) { + .layout { + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); + } +} + +.sidebar, +.content { + display: flex; + flex-direction: column; gap: 1rem; } -@media (min-width: 768px) { - .grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } +/* Panels */ - .grid .field:last-child { - grid-column: 1 / -1; +.panel { + background: var(--card); + border-radius: 18px; + padding: 1.1rem 1.2rem; + border: 1px solid var(--border-subtle); + box-shadow: 0 14px 30px rgba(15, 23, 42, 0.7); +} + +.panel.secondary { + background: var(--card-soft); +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.badge { + background: rgba(148, 163, 184, 0.2); + border-radius: 999px; + padding: 0.1rem 0.55rem; + font-size: 0.75rem; +} + +/* Filter panel */ + +.filter-panel h3 { + margin-top: 0; + margin-bottom: 0.6rem; +} + +.panel-grid { + display: grid; + gap: 0.7rem; + margin-bottom: 0.8rem; +} + +@media (min-width: 720px) { + .panel-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); } } .field { display: flex; flex-direction: column; - gap: 0.35rem; + gap: 0.3rem; } .field label { - font-weight: 600; - font-size: 0.9rem; + font-size: 0.85rem; + color: var(--text-muted); +} + +.field-full { + grid-column: 1 / -1; } input, -select, -textarea, -button { - border-radius: 8px; - border: 1px solid #d1d5db; - padding: 0.5rem 0.75rem; - font-size: 0.95rem; +select { + border-radius: 10px; + border: 1px solid rgba(148, 163, 184, 0.6); + background: #020617; + color: var(--text-main); + padding: 0.4rem 0.65rem; + font-size: 0.9rem; } -textarea { - resize: vertical; -} +/* Buttons */ -button { - background: #2563eb; - color: #ffffff; +.btn { + border-radius: 999px; + padding: 0.55rem 1.2rem; + border: none; + font-size: 0.9rem; font-weight: 600; cursor: pointer; - border: none; - margin-top: 0.5rem; + transition: transform 0.08s ease, box-shadow 0.08s ease, + background-color 0.08s ease; } -button:disabled { - opacity: 0.7; +.btn.full { + width: 100%; +} + +.btn.primary { + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + color: #f9fafb; + box-shadow: 0 12px 25px rgba(22, 163, 74, 0.6); +} + +.btn.accent { + background: var(--accent-soft); + color: #bbf7d0; +} + +.btn.ghost { + background: transparent; + color: var(--text-main); + border: 1px solid rgba(148, 163, 184, 0.5); +} + +.btn.small { + padding: 0.25rem 0.7rem; + font-size: 0.8rem; +} + +.btn:disabled { + opacity: 0.6; cursor: default; } -.error { - color: #b91c1c; - font-weight: 600; +.btn:not(:disabled):hover { + transform: translateY(-1px); + box-shadow: 0 15px 30px rgba(15, 23, 42, 0.75); } -.recipe-card h2 { - margin-top: 0; +/* Recipe list */ + +.recipe-list { + list-style: none; + padding: 0; + margin: 0.6rem 0 0; + max-height: 280px; + overflow: auto; +} + +.recipe-list-item { + display: flex; + padding: 0.55rem 0.4rem; + border-radius: 11px; + cursor: pointer; + transition: background-color 0.08s ease, transform 0.05s ease; +} + +.recipe-list-item:hover { + background: rgba(15, 23, 42, 0.9); + transform: translateY(-1px); +} + +.recipe-list-item.active { + background: rgba(34, 197, 94, 0.2); + border: 1px solid rgba(34, 197, 94, 0.6); +} + +.recipe-list-main { + flex: 1; +} + +.recipe-list-name { + font-size: 0.92rem; + font-weight: 500; +} + +.recipe-list-meta { + display: flex; + gap: 0.5rem; + font-size: 0.8rem; + color: var(--text-muted); +} + +.muted { + font-size: 0.85rem; + color: var(--text-muted); +} + +/* Recipe details */ + +.recipe-card { + min-height: 260px; +} + +.recipe-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.8rem; + margin-bottom: 0.8rem; +} + +.recipe-header h2 { + margin: 0; + font-size: 1.3rem; +} + +.recipe-subtitle { + margin: 0.2rem 0 0; + font-size: 0.85rem; + color: var(--text-muted); +} + +.pill-row { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.pill { + padding: 0.25rem 0.6rem; + border-radius: 999px; + background: rgba(15, 23, 42, 0.95); + border: 1px solid rgba(148, 163, 184, 0.7); + font-size: 0.78rem; +} + +.recipe-body { + display: grid; + gap: 0.8rem; +} + +@media (min-width: 720px) { + .recipe-body { + grid-template-columns: 1fr 1.2fr; + } +} + +.recipe-column h3 { + margin: 0 0 0.3rem; + font-size: 0.95rem; +} + +.recipe-column ul, +.recipe-column ol { + margin: 0; + padding-right: 1rem; + font-size: 0.9rem; +} + +.tags { + margin-top: 0.6rem; +} + +.tag { + display: inline-block; + margin-left: 0.25rem; + margin-bottom: 0.2rem; + padding: 0.2rem 0.6rem; + border-radius: 999px; + background: rgba(15, 23, 42, 0.9); + border: 1px solid rgba(75, 85, 99, 0.7); + font-size: 0.78rem; +} + +/* Placeholder */ + +.placeholder { + text-align: center; + padding: 2rem 1rem; + color: var(--text-muted); +} + +/* Error */ + +.error-banner { + background: rgba(248, 113, 113, 0.12); + border-radius: 12px; + border: 1px solid rgba(252, 165, 165, 0.7); + padding: 0.6rem 0.8rem; + font-size: 0.85rem; + color: #fecaca; + margin-bottom: 0.6rem; +} + +/* Drawer */ + +.drawer-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.8); + display: flex; + justify-content: flex-start; + align-items: stretch; + z-index: 40; +} + +.drawer { + width: min(420px, 90vw); + background: #020617; + border-left: 1px solid var(--border-subtle); + padding: 1rem 1rem 1rem 1.2rem; + box-shadow: 18px 0 40px rgba(0, 0, 0, 0.7); +} + +.drawer-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.drawer-body { + max-height: calc(100vh - 4rem); + overflow: auto; +} + +.drawer-footer { + margin-top: 0.7rem; + display: flex; + gap: 0.5rem; +} + +.icon-btn { + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + font-size: 1rem; +} + +.icon-btn.small { + font-size: 0.9rem; +} + +/* Dynamic lists */ + +.dynamic-list { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.dynamic-row { + display: flex; + gap: 0.3rem; +} + +.dynamic-row input { + flex: 1; +} + +.two-cols { + display: grid; + gap: 0.6rem; +} + +@media (min-width: 600px) { + .two-cols { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 327445b..455363c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,212 +1,150 @@ -import { useState } from "react"; -import { getRandomRecipe, createRecipe } from "./api"; +import { useEffect, useState } from "react"; import "./App.css"; +import TopBar from "./components/TopBar"; +import RecipeList from "./components/RecipeList"; +import RecipeDetails from "./components/RecipeDetails"; +import RecipeFormDrawer from "./components/RecipeFormDrawer"; +import { getRecipes, getRandomRecipe, createRecipe } from "./api"; + function App() { - const [mealType, setMealType] = useState(""); - const [maxTime, setMaxTime] = useState(""); - const [ingredients, setIngredients] = useState(""); - const [recipe, setRecipe] = useState(null); + const [recipes, setRecipes] = useState([]); + const [selectedRecipe, setSelectedRecipe] = useState(null); + + const [mealTypeFilter, setMealTypeFilter] = useState(""); + const [maxTimeFilter, setMaxTimeFilter] = useState(""); + const [ingredientsFilter, setIngredientsFilter] = useState(""); + + const [loadingRandom, setLoadingRandom] = useState(false); const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - // form for quick demo recipe - const [newName, setNewName] = useState(""); - const [newMealType, setNewMealType] = useState("lunch"); - const [newTime, setNewTime] = useState(10); - const [newIngredients, setNewIngredients] = useState(""); - const [newSteps, setNewSteps] = useState(""); - const [creating, setCreating] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(false); - const handleRandom = async () => { - setLoading(true); - setError(""); - setRecipe(null); + useEffect(() => { + loadRecipes(); + }, []); + + const loadRecipes = async () => { try { - const data = await getRandomRecipe({ - mealType: mealType || undefined, - maxTime: maxTime ? Number(maxTime) : undefined, - ingredients: ingredients || undefined, - }); - setRecipe(data); - } catch (err) { - if (err.response?.status === 404) { - setError("ืื ื ืืฆืื ืืชืืื ืื ืืชืืืืื ๐"); - } else { - setError("ืฉืืืื ืืืขืื ืช ืืชืืื"); + const list = await getRecipes(); + setRecipes(list); + if (!selectedRecipe && list.length > 0) { + setSelectedRecipe(list[0]); } - } finally { - setLoading(false); + } catch { + setError("ืื ืืฆืืื ื ืืืขืื ืืช ืจืฉืืืช ืืืชืืื ืื."); } }; - const handleCreate = async (e) => { - e.preventDefault(); - setCreating(true); + const handleRandomClick = async () => { + setLoadingRandom(true); setError(""); - - const ingArr = newIngredients - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - const stepsArr = newSteps - .split("\n") - .map((s) => s.trim()) - .filter(Boolean); - try { - const created = await createRecipe({ - name: newName, - meal_type: newMealType, - time_minutes: Number(newTime), - tags: [], - ingredients: ingArr, - steps: stepsArr, + const ingredientsArr = ingredientsFilter + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + const recipe = await getRandomRecipe({ + mealType: mealTypeFilter || undefined, + maxTime: maxTimeFilter ? Number(maxTimeFilter) : undefined, + ingredients: ingredientsArr, }); - setRecipe(created); + + setSelectedRecipe(recipe); } catch (err) { - setError("ืฉืืืื ืืืฆืืจืช ืืชืืื"); + if (err.response?.status === 404) { + setError("ืื ื ืืฆืื ืืชืืื ืื ืฉืขืืืืื ืืคืืืืจืื ืฉืื."); + } else { + setError("ืืืจืขื ืฉืืืื ืืืืคืืฉ ืืชืืื."); + } } finally { - setCreating(false); + setLoadingRandom(false); + } + }; + + const handleCreateRecipe = async (payload) => { + try { + const created = await createRecipe(payload); + setDrawerOpen(false); + await loadRecipes(); + setSelectedRecipe(created); + } catch { + setError("ืฉืืืื ืืฉืืืจืช ืืืชืืื ืืืืฉ."); } }; return (
{error}
} - - {recipe && ( -- ืกืื ืืจืืื: {recipe.meal_type} ยท{" "} - ืืื ืืื ื: {recipe.time_minutes} ืืงืืช -
- -ืขืืืื ืื ื ืืืจ ืืชืืื. ืืืจ ืืชืืื ืืืจืฉืืื ืื ืฆืืจ ืืชืืื ืืืฉ.
++ {translateMealType(recipe.meal_type)} ยท {recipe.time_minutes} ืืงืืช ืืื ื +
+ืขืืืื ืืื ืืชืืื ืื, ืืืฅ ืขื โืืชืืื ืืืฉโ ืืืชืืื.
+ ) : ( +