diff --git a/.agents/TODO.md b/.agents/TODO.md
index 953b718..2e7c01c 100644
--- a/.agents/TODO.md
+++ b/.agents/TODO.md
@@ -12,10 +12,10 @@ Dieses Dokument enthält den aktuellen Entwicklungsstand und detaillierte Aufgab
- Fullscreen Overlay-Navigation (`LiquidOverlayMenu` & `SlideUpTextHover`)
- Geschmeidiges Scrollverhalten (`LenisSmoothScroll` & `IndexSectionIndicator`)
- Layout-Raster (`FloemaLayoutGrid`) & Premium-Buttons
-- [x] **Phase 2: Authentifizierung & Firebase Integration**
- - Firebase-Projekt Setup & Anbindung
- - Newsletter-System (Firebase Firestore)
- - Benutzerprofile (Registrierung, Login, Favoriten)
+- [x] **Phase 2: Profil & Personalisierung (lokal)**
+ - Ernährungsziel & Favoriten im Browser (`fc-local.js`)
+ - Newsletter per Mailto
+ - Profilseite `login.php`
- [x] **Phase 3: Nährwerte & Rezepterweiterung**
- Datenmodell & Admin.php Erweiterung
- Visuelles Nährwert-Widget (Floema-Präzision)
@@ -28,6 +28,10 @@ Dieses Dokument enthält den aktuellen Entwicklungsstand und detaillierte Aufgab
- Touch-Swipe-Karussell direkt auf index.php (unter Landing-Page)
- Filteralgorithmus nach Benutzer-Ernährungszielen
- Mikro-Animationen & Quick-Save Funktion
+- [x] **Phase 6: PostgreSQL Datenbank**
+ - `DATABASE_URL` in `.env`, Docker Compose für lokale Dev-DB
+ - `init_db()` / `save_recipes()` in `helpers.php`
+ - Fallback auf `data/recipes.json`
---
@@ -60,26 +64,17 @@ Das visuelle Fundament der Website. Die Etablierung des Premium-Designs stellt s
---
-### 🔒 Phase 2: Authentifizierung & Firebase Integration
-Das Fundament für Personalisierung und Newsletter-Abos über eine sichere Firebase-Anbindung (Auth & Firestore).
+### 🔒 Phase 2: Profil & Personalisierung (lokal)
+Favoriten, Ernährungsziel und Newsletter ohne Cloud-Backend.
-- [x] **Firebase Setup & Initialisierung**
- - [x] Firebase SDK Client-Side Einbindung in `partials/head.php` or separaten Helper
- - [x] Firebase Config in umweltabhängigen Settings/Variablen auslagern
- - [x] Firestore-Datenbank initialisieren (`subscribers` und `users` Collections)
-- [x] **Premium Login- & Registrierungsseite**
- - [x] Erstellung der Seite `login.php` (und optional `signup.php`) im neuen Design-System-Stil
- - [x] Firebase Authentication (Email/Passwort Login & Registrierung) implementieren
- - [x] Responsive UI mit sanften Error- & Success-Meldungen und Eingabefeldern im Glasmorphismus-Look
- - [x] Client-seitige und Server-seitige Session-Synchronisation (z.B. Firebase Session Tokens an PHP-Session via Ajax senden)
+- [x] **Profilseite `login.php`**
+ - [x] Ernährungsziel wählen und in `localStorage` speichern (`fc-local.js`)
+ - [x] Gespeicherte Favoriten anzeigen
- [x] **Favoriten-Funktion (Rezept-Bookmarks)**
- - [x] Firestore-Collection `user_favorites` anlegen (Format: `userId` -> Liste von `recipeSlugs`)
- - [x] "Rezept speichern" Button (Herz-Icon) auf den Rezeptkarten und Rezept-Details mit dynamischem Login-Check
- - [x] Mikro-Animationen für das Herz-Icon (Scale up/down, Fülleffekte mit `var(--ease-overshoot)`)
-- [x] **Firebase-gestütztes Newsletter-Abo**
- - [x] Newsletter-Eingabefeld im Footer (`partials/footer.php`) implementieren
- - [x] AJAX-Submit-Handler: E-Mail-Adresse prüfen und in Firestore Collection `subscribers` speichern
- - [x] Elegantes Feedback-UI (Erfolgsmeldung ohne Page-Reload)
+ - [x] Herz-Icon auf Karten, Speicherung in `localStorage`
+ - [x] Mikro-Animationen für das Herz-Icon
+- [x] **Newsletter**
+ - [x] Footer-Formular mit `mailto:`-Weiterleitung
---
@@ -106,7 +101,7 @@ Jedes Rezept erhält präzise Makronährstoffe (Kalorien, Proteine, Kohlenhydrat
Ein immersiver Kochmodus, der Anwendern Schritt-für-Schritt durch die Zubereitung führt – inklusive Videoanleitungen und integrierten Timern.
- [x] **Video-Verknüpfung im Admin-Panel**
- - [x] Rezeptstruktur in `data/recipes.json` erweitern, damit jeder Arbeitsschritt (`steps`) eine optionale `video_url` (MP4 aus Firebase Storage oder extern) besitzen kann
+ - [x] Rezeptstruktur in `data/recipes.json` erweitern, damit jeder Arbeitsschritt (`steps`) eine optionale `video_url` (externe MP4-URL) besitzen kann
- [x] `admin.php` erweitern, um Video-URLs pro Einzelschritt einzugeben
- [x] **Fullscreen Cooking-Mode Overlay**
- [x] Trigger-Button "Kochmodus starten" (`CapitoliumRevealButton` Stil) auf `recipe.php` einbauen
@@ -128,16 +123,28 @@ Ein hochgradig interaktives, touch-freundliches Karussell direkt auf der Startse
- [x] CSS-Klammern (`clamp()`) für responsive Skalierung der Karten
- [x] Flüssiges Einblenden per Scroll-Reveal (`reveal-target`) mit Anbindung an `LenisSmoothScroll`
- [x] **Personalisiertes Goal-Matching**
- - [x] Falls der Benutzer eingeloggt ist, sein Ernährungsziel aus den Firebase-Profildaten abrufen
+ - [x] Ernährungsziel aus `localStorage` (`fc-local.js`) für Karussell-Filter
- [x] Rezepte dynamisch filtern (z.B. High-Protein Rezepte bei Ziel "Muskelaufbau", Low-Carb Rezepte bei Ziel "Abnehmen")
- [x] Personalisierte "Für dich empfohlen"-Badge auf den Karten anzeigen
- [x] **Mikro-Interaktionen & Quick-Save**
- [x] Touch-Gesten-Unterstützung (Wischen zum Blättern mit sanftem Trägheits-Feedback)
- - [x] Quick-Save Button direkt auf den Karussell-Karten (Herzeffekt mit direktem Firebase Firestore Sync)
+ - [x] Quick-Save Button direkt auf den Karussell-Karten (Herzeffekt, `localStorage`)
- [x] Hover-Reveal-Effekte für Rezeptdetails (Zutaten-Vorschau oder Kochzeit-Highlight schiebt sich weich hoch)
---
+### 🔥 Phase 6: PostgreSQL Datenbank
+Rezepte in Postgres (JSONB), optional lokal per Docker.
+
+- [x] **`DATABASE_URL` & Docker Compose**
+ - [x] `docker-compose.dev.yml` für lokale Postgres-Instanz
+ - [x] `init_db()` legt Tabelle `recipes` an und seedet aus `recipes.json`
+- [x] **PHP-Datenzugriff**
+ - [x] `load_recipes()` / `save_recipes()` mit JSON-Fallback
+ - [x] `load_recipe_by_slug()` in `recipe.php`
+
+---
+
## 📈 Status-Legende
- `[ ]` Noch nicht begonnen
- `[/]` In Bearbeitung
diff --git a/.agents/brain.md b/.agents/brain.md
index 5ae699f..9011b1e 100644
--- a/.agents/brain.md
+++ b/.agents/brain.md
@@ -1,37 +1,131 @@
# FlixCooks Project Brain
-This document summarizes the architectural knowledge, conventions, and learnings accumulated during our session.
+This document summarizes architectural knowledge, conventions, and learnings for humans and AI agents working on this repo.
## 1. Project Architecture & Stack
-- **Backend:** Vanilla PHP. The project intentionally avoids heavy frameworks.
-- **Database:** Flat-file JSON database (`data/recipes.json`). Data is loaded and saved via utility functions in `helpers.php`.
-- **Admin Panel (`admin.php`):** Acts as a lightweight CMS. It uses simple textareas where each line maps to an array element (e.g., for `ingredients`, `steps`, `step_videos`, `step_timers`). This keeps the JSON structure clean and parsing straightforward.
-- **Frontend:** Server-rendered PHP templates (`index.php`, `recipe.php`) with Vanilla JavaScript and Vanilla CSS. No Tailwind or heavy component libraries.
+- **Backend:** Vanilla PHP. No heavy frameworks.
+- **Database:** PostgreSQL required (`DATABASE_URL` in `.env`). Normalized tables in `scripts/schema.sql`; `load_recipes()` / `save_recipe()` in `helpers.php`. No runtime JSON recipe file. One-time import: `php scripts/db-seed.php` from `data/recipes.json`.
+- **Site settings (legal pages):** Flat-file `data/site.json` via `load_site_settings()` / `save_site_settings()` — not in Postgres.
+- **Admin Panel (`admin.php`):** Lightweight CMS. Textareas use one line per array element (`ingredients`, `steps`, `step_videos`, `step_timers`).
+- **Frontend:** Server-rendered PHP (`index.php`, `recipe.php`, …), Vanilla JS/CSS. Profile/favorites via `assets/fc-local.js`.
+- **Config:** `config.php` loads `.env`, `get_db_connection()`. Never commit `.env` (see `.gitignore`).
## 2. Design & Aesthetics
-- **CSS:** Highly customized CSS with modern design tokens (e.g., `var(--ease-out-expo)`, `var(--surface-1)`).
-- **Animations:** Employs sophisticated micro-animations, glassmorphism (`backdrop-filter: blur`), and dynamic layouts (e.g., `clip-path` for overlays).
-- **Smooth Scrolling:** Uses **Lenis** (`LenisSmoothScroll`).
- - *Crucial Rule:* Whenever a fullscreen overlay (like the Cooking Mode) is opened, `lenis.stop()` must be called to prevent background scrolling. When closed, call `lenis.start()`.
-- **Preloader:** A custom `CapitoliumPreloader` runs on the homepage. It is cached in `sessionStorage('flixcooks_preloader_seen')` so it only fires once per browsing session.
+- **CSS:** Custom properties (`var(--ease-out-expo)`, `var(--surface-1)`), glassmorphism, `FloemaLayoutGrid`.
+- **Lenis:** Call `lenis.stop()` when opening fullscreen overlays (e.g. Cooking Mode); `lenis.start()` on close.
+- **Preloader:** `CapitoliumPreloader` on homepage; once per session via `sessionStorage('flixcooks_preloader_seen')`.
## 3. Antigravity Agent Configuration
-- **Workspace Rules:** Best placed in `.agents/rules/` (e.g., `AGENT.md`). To ensure they are always active, the frontmatter must include:
- ```yaml
- always_on: true
- glob: "*"
- ```
-- **Custom Skills:** Can be defined as JSON files in `.agents/skills/`. We successfully created `close_feature.json` to automate the Git workflow of checking out `main`, merging a feature branch, verifying functionality, and deleting the branch.
+- **Workspace Rules:** `.agents/rules/` with `always_on: true` and `glob: "*"` in frontmatter.
+- **Custom Skills:** `.agents/skills/` (e.g. `close_feature.json` for Git merge workflow).
## 4. GitHub Actions & CI/CD
-- **Gemini Code Review Automation:** We integrated `petarzarkov/gemini-code-review-action` to automatically review PRs.
- - **Secrets:** Must be passed using `env:` instead of `with:` (e.g., `GEMINI_API_KEY`, `GITHUB_TOKEN`), otherwise the action fails with unexpected input errors. NEVER hardcode API keys in workflow files.
- - **Model Naming:** Google's `v1beta` API is very strict. `gemini-1.5-flash` often fails. You must use the fully-qualified name like `gemini-1.5-flash-latest` or `gemini-2.0-flash-lite`.
- - **Pinning Versions:** Always pin GitHub Actions to a specific version tag (e.g., `@v1.0.4`) rather than `@latest` to prevent unexpected breaking changes.
+- **Gemini PR review:** `petarzarkov/gemini-code-review-action`; secrets via `env:` not `with:`; pin action versions; use full model names (e.g. `gemini-2.0-flash-lite`).
-## 5. Completed Milestones
-- **Phase 3 (Nutrition):** Implemented. Recipes now store `calories`, `protein`, `carbs`, and `fat`. Admin panel handles inputs, and the UI displays them beautifully.
-- **Phase 4 (Interactive Cooking Mode):** Implemented. Recipes now support step-by-step looping background videos and interactive timers (`step_videos`, `step_timers`). The UI utilizes a fullscreen overlay slider with Vanilla JS logic.
+---
-## 6. Next Steps
-According to `TODO.md`, the next major feature block is **Phase 5 (PWA & Offline Support)**, which involves service workers, manifest files, and enabling the app to be installable on mobile devices.
+## 5. PostgreSQL — Schema & Data Flow
+
+### Relational schema (`scripts/schema.sql`)
+Created by `ensure_recipe_schema()` on first DB use. Legacy JSONB `recipes.data` is migrated once automatically.
+
+| Table | Role |
+|-------|------|
+| `recipes` | slug, hero, times, servings, nutrition columns, featured, coming_soon |
+| `recipe_translations` | title, description, category, difficulty (en/de) |
+| `recipe_tags`, `recipe_ingredients`, `recipe_utensils`, `recipe_steps` | Ordered lists per language |
+
+PHP still exposes the same nested arrays (`i18n`, `nutrition`, …) via `hydrate_recipes_from_db()`.
+
+### Runtime flow
+1. `DATABASE_URL` required → `require_database()` or HTTP 503 (`maintenance/db-unavailable.php`).
+2. `load_recipes()` → SQL → PHP arrays for templates.
+3. Admin: `save_recipe()`, `delete_recipe()`, `clear_featured_recipes()`.
+4. One-time import: `php scripts/db-seed.php` from `data/recipes.json` (not read at runtime).
+
+### `config.php` functions
+- `load_env()` — parses `.env`.
+- `get_db_connection()` — PDO or `null`.
+- `DatabaseUnavailableException` — thrown when DB is required but missing.
+
+---
+
+## 6. Local Development — Docker Postgres
+
+### Files
+- `docker-compose.dev.yml` — Postgres 16 Alpine, container `flixcooks-postgres-dev`, port `5432`.
+- `.env.example` — template including local `DATABASE_URL`.
+- `scripts/db-check.php` — CLI: connect, `init_db()`, print recipe count + sample slugs/titles.
+
+### Docker credentials (dev only)
+```
+POSTGRES_USER=flixcooks
+POSTGRES_PASSWORD=flixcooks_dev
+POSTGRES_DB=flixcooks_dev
+DATABASE_URL="postgresql://flixcooks:flixcooks_dev@127.0.0.1:5432/flixcooks_dev"
+```
+
+### Commands
+```bash
+docker compose -f docker-compose.dev.yml up -d # start
+docker compose -f docker-compose.dev.yml ps # health
+docker compose -f docker-compose.dev.yml down # stop (data kept)
+docker compose -f docker-compose.dev.yml down -v # stop + wipe volume → re-seed on next hit
+
+docker exec -it flixcooks-postgres-dev psql -U flixcooks -d flixcooks_dev
+# psql: \dt , \d recipes , SELECT slug FROM recipes; , \q
+```
+
+### PHP requirements (WSL/Linux)
+- Extension **`php-pgsql`** (or `php8.5-pgsql`) required; without it the site cannot connect.
+- Install interactively: `sudo apt install php8.5-pgsql` (sudo in non-interactive agent shells may timeout).
+- Verify: `php -m | grep pgsql` → expect `pdo_pgsql`, `pgsql`.
+
+### App server
+```bash
+php -S localhost:8000
+php scripts/db-check.php # after .env + pgsql OK
+```
+
+### `.env` rules for agents
+- Copy from `.env.example`; never commit `.env`.
+- **Local:** use `127.0.0.1` Docker URL above.
+- **`DATABASE_URL` is mandatory** for recipe pages; omitting it shows the DB unavailable page.
+
+### Environment separation (important)
+- One database per environment (local Docker / staging / production).
+- Never point a dev branch `.env` at production Postgres.
+- Export/import between envs: `pg_dump` / `psql` when needed; document URLs in hosting secrets, not in repo.
+
+---
+
+## 7. Troubleshooting (known issues)
+
+| Symptom | Cause | Fix |
+|--------|--------|-----|
+| HTTP 503, database unavailable | No `DATABASE_URL` or Postgres down | Fix `.env`, start Docker, `php scripts/db-check.php` |
+| Log: `could not find driver` | `php-pgsql` not installed | `sudo apt install php8.5-pgsql` |
+| DB OK but 0 recipes | Empty tables | `php scripts/db-seed.php` |
+
+---
+
+## 8. Completed Milestones
+- **Phase 3 (Nutrition):** `calories`, `protein`, `carbs`, `fat` on recipes; admin + UI.
+- **Phase 4 (Cooking Mode):** `step_videos`, `step_timers`; fullscreen overlay.
+- **Phase 6 (Postgres):** Normalized SQL tables; `db-seed.php`; no runtime JSON recipes.
+- **Local dev Postgres:** `docker-compose.dev.yml`, README section, `scripts/db-check.php`, `.env.example` with `DATABASE_URL`.
+- **Local profile data:** Favorites/goals via `assets/fc-local.js` (`localStorage`). Newsletter via `mailto:`.
+
+## 9. Next Steps
+See `.agents/TODO.md` (e.g. PWA & offline support). README `Local Postgres (Docker)` and `Postgres in diesem Projekt` sections mirror setup for humans.
+
+## 10. Key file map (data layer)
+| File | Purpose |
+|------|---------|
+| `config.php` | `.env`, Firebase config, PDO |
+| `helpers.php` | `init_db`, `load_recipes`, `save_recipes`, site settings |
+| `data/recipes.json` | Seed + fallback + admin backup |
+| `data/site.json` | Imprint/privacy settings |
+| `docker-compose.dev.yml` | Local Postgres |
+| `scripts/db-check.php` | Connection + seed smoke test |
+| `partials/head.php` | Firebase init via `get_firebase_config()` |
diff --git a/.cursor/plans/coolify_react_cms_stack_ea69bc7d.plan.md b/.cursor/plans/coolify_react_cms_stack_ea69bc7d.plan.md
new file mode 100644
index 0000000..e0d7bc5
--- /dev/null
+++ b/.cursor/plans/coolify_react_cms_stack_ea69bc7d.plan.md
@@ -0,0 +1,324 @@
+---
+name: Coolify React CMS Stack
+overview: "Empfohlener Standard-Stack: Next.js (React) + Payload CMS (self-hosted) + PostgreSQL, alles als Docker-Images über GitHub → Coolify. Cursor/MCP für die Entwicklung, GitHub Actions für Qualitätssicherung vor dem Deploy."
+todos:
+ - id: scaffold-monorepo
+ content: "Greenfield-Monorepo anlegen: apps/web (Next standalone), apps/cms (Payload), packages/shared-types"
+ status: pending
+ - id: docker-coolify
+ content: "Dockerfiles, docker-compose.yml, docs/COOLIFY.md (3 Services: Postgres, CMS, Web) nach FlixCooks-Muster"
+ status: pending
+ - id: cms-content-model
+ content: Payload Collections + Webhooks für On-Demand Revalidation; Env-Beispiele in .env.example
+ status: pending
+ - id: github-ci
+ content: "GitHub Actions: lint, typecheck, build, docker build smoke; branch protection auf main"
+ status: pending
+ - id: mcp-agents
+ content: .cursor/mcp.json + .agents/brain.md mit Stack-, Env- und Deploy-Regeln für KI
+ status: pending
+ - id: animation-baseline
+ content: "GSAP/Lenis/Framer-Baseline-Komponenten und Regel: Overlays stoppen Lenis"
+ status: pending
+isProject: false
+---
+
+# Tech-Stack & Workflow: React-Sites, CMS, Docker, Coolify
+
+## Empfehlung (Default)
+
+Du bist unsicher bei CMS und Frontend – hier ist ein **bewährtes Default**, das zu deinen Vorgaben passt (React, Animationen, Docker, Coolify, KI/MCP, CI):
+
+| Schicht | Technologie | Warum |
+|---------|-------------|--------|
+| **Frontend** | [Next.js 15](https://nextjs.org) (App Router, TypeScript) | SEO/SSR/ISR, React-Ökosystem, `output: 'standalone'` für ein schlankes Production-Docker-Image |
+| **Animationen** | GSAP (+ ScrollTrigger), Framer Motion, Lenis | GSAP für Scroll/Timeline-Premium-Feel; Framer für UI-Micro-Interactions; Lenis kennst du bereits aus FlixCooks |
+| **CMS** | [Payload CMS 3](https://payloadcms.com) (eigener Container) | TypeScript, Postgres-native, Admin-UI out of the box, Docker-freundlich, passt zu Coolify wie dein aktuelles Postgres-Setup |
+| **Datenbank** | PostgreSQL 16 (Coolify Database Service) | Eine Instanz, getrennte DBs/User für CMS vs. App optional |
+| **Runtime / Deploy** | Docker + [Coolify](https://coolify.io) | Git-Webhook → Build → Traefik/HTTPS; du hast das Muster schon in [docs/COOLIFY.md](docs/COOLIFY.md) |
+| **Lokale Dev** | `docker compose` (Web + CMS + Postgres) | Parität zu Production, wie [docker-compose.yml](docker-compose.yml) bei FlixCooks |
+| **KI-Entwicklung** | Cursor + MCP-Server | Repo-Kontext, GitHub, Docs, optional DB |
+| **CI** | GitHub Actions | Lint, Types, Build, Docker-Smoke, optional E2E; PR-Review wie [.github/workflows/gemini-pr-review.yml](.github/workflows/gemini-pr-review.yml) |
+
+**Alternative Frontend:** Vite + React SPA + nginx-Image – maximal frei für reine Animation-Landingpages, aber schlechteres SEO und kein ISR ohne Extra-Aufwand. **Alternative CMS (weniger Ops):** Sanity/Contentful (Cloud) – nur Frontend-Container auf Coolify. **Alternative CMS (kein Backend):** Tina/Decap + Markdown im Repo – gut für Blogs, schwächer für Redakteur:innen ohne Git.
+
+---
+
+## Zielarchitektur auf Coolify
+
+```mermaid
+flowchart TB
+ subgraph dev [Entwicklung]
+ Cursor[Cursor + MCP]
+ LocalCompose[docker compose]
+ Cursor --> LocalCompose
+ end
+
+ subgraph github [GitHub]
+ Repo[Monorepo]
+ GHA[GitHub Actions CI]
+ Repo --> GHA
+ end
+
+ subgraph coolify [Coolify Server]
+ PG[(PostgreSQL)]
+ CMS[Payload CMS Container]
+ WEB[Next.js Container]
+ Traefik[Traefik HTTPS]
+ PG --> CMS
+ CMS -->|REST/GraphQL| WEB
+ Traefik --> WEB
+ Traefik --> CMS
+ end
+
+ dev -->|push main| Repo
+ GHA -->|grüner Build| Repo
+ Repo -->|Webhook Deploy| coolify
+```
+
+**Drei Coolify-Ressourcen** (analog zu deinem FlixCooks-Setup: Postgres + App):
+
+1. **PostgreSQL** – internal URL, nicht öffentlich
+2. **CMS-App** – Dockerfile aus `apps/cms`, Port z. B. 3001, Env: `DATABASE_URL`, `PAYLOAD_SECRET`
+3. **Web-App** – Dockerfile aus `apps/web`, Port 3000, Env: `CMS_URL` (internal), `REVALIDATE_SECRET` für On-Demand-ISR
+
+Persistenz: Postgres-Volume (Inhalte), optional Volume für CMS-Uploads (`/app/media`).
+
+---
+
+## Repository-Struktur (Greenfield-Vorlage)
+
+Ein Repo pro „Site-Familie“ oder Monorepo für mehrere Marken:
+
+```
+my-site/
+├── apps/
+│ ├── web/ # Next.js
+│ │ ├── Dockerfile
+│ │ ├── src/
+│ │ └── next.config.ts # output: 'standalone'
+│ └── cms/ # Payload
+│ ├── Dockerfile
+│ └── payload.config.ts
+├── packages/
+│ └── shared-types/ # optional: gemeinsame TS-Typen CMS ↔ Web
+├── docker-compose.yml # lokaler Prod-Parität-Stack
+├── docker-compose.dev.yml # nur Postgres (wie bei FlixCooks)
+├── .github/workflows/
+│ ├── ci.yml
+│ └── gemini-pr-review.yml # optional, aus FlixCooks übernehmen
+├── .cursor/
+│ └── mcp.json # MCP-Server für das Team
+├── .agents/
+│ ├── brain.md # Architektur für KI (Pattern aus FlixCooks)
+│ └── rules/AGENT.md
+└── docs/
+ └── COOLIFY.md
+```
+
+---
+
+## Frontend-Stack (React + Animationen)
+
+**Kern:**
+
+- **Next.js App Router** – Seiten in `app/`, Server Components für CMS-Daten, Client Components nur für Animation/Interaktion
+- **TypeScript** – strikt; Typen aus Payload generieren (`payload generate:types`)
+- **Styling** – CSS Modules oder Tailwind (nur wenn du es willst; FlixCooks bleibt bei Vanilla CSS – für neue React-Sites ist Tailwind optional, nicht Pflicht)
+
+**Animation-Toolkit:**
+
+| Tool | Einsatz |
+|------|---------|
+| **GSAP + ScrollTrigger** | Hero-Sequences, pinned Sections, komplexe Timelines |
+| **Framer Motion** | Hover, Page-Transitions, modale UI |
+| **Lenis** | Smooth Scroll (wie FlixCooks: bei Overlays `lenis.stop()`) |
+| **(optional) @react-three/fiber** | 3D-Hero nur wenn nötig |
+
+**CMS-Anbindung im Web:**
+
+- **Build-Zeit (SSG):** `generateStaticParams` + Fetch von Payload REST für Marketing-Seiten
+- **On-Demand Revalidation:** Payload-Webhook → `POST /api/revalidate?secret=...` in Next.js (Inhalt ändert sich ohne Full-Redeploy)
+- **Preview:** Draft-Modus mit Payload Preview-URL + Next `draftMode()`
+
+Env im Web-Container (Coolify):
+
+- `CMS_URL=http://payload-service:3001` (internal hostname)
+- `REVALIDATE_SECRET`, `NEXT_PUBLIC_SITE_URL`
+
+---
+
+## CMS-Stack (Payload auf Coolify)
+
+**Warum Payload als Default:** Self-hosted, eine Postgres-URL, Admin unter `/admin`, Collections/Blocks für Seitenmodule, Media-Uploads, Webhooks – alles containerisierbar.
+
+**Coolify Env (CMS):**
+
+- `DATABASE_URL` – internal Postgres URL (gleiches Muster wie [docs/COOLIFY.md](docs/COOLIFY.md) Zeilen 33–43)
+- `PAYLOAD_SECRET` – langer Zufallswert (nur Secrets, nie ins Repo)
+- `NEXT_PUBLIC_SERVER_URL` – öffentliche CMS-URL (für Admin-Assets)
+
+**Erstes Deployment:** analog FlixCooks `RUN_DB_SEED` – einmalig Migration/Seed, danach Flag entfernen.
+
+**Sicherheit:** CMS-Admin nur über HTTPS; CORS auf Web-Domain beschränken; API-Keys für Preview/Revalidate nur als Secrets.
+
+---
+
+## Docker-Images
+
+### Web (`apps/web/Dockerfile`) – Next standalone
+
+Mehrstufig: `node:22-alpine` → `npm ci` → `npm run build` → Runtime nur `.next/standalone` + `static` + `public`.
+
+- `EXPOSE 3000`
+- `HEALTHCHECK` auf `/api/health` (kleine Route: `{ "status": "ok" }`)
+- Coolify: Port **3000**, Health Path `/api/health`
+
+### CMS (`apps/cms/Dockerfile`)
+
+Payload-Official-Pattern oder Node-Image mit `npm run build && npm run start`.
+
+- `HEALTHCHECK` auf CMS-Health-Endpoint
+- Volume für `/app/media` (Uploads überleben Redeploy)
+
+### Lokales Parität-Compose
+
+Orientierung an deinem bestehenden [docker-compose.yml](docker-compose.yml):
+
+- `postgres` mit `healthcheck`
+- `cms` `depends_on: postgres: service_healthy`
+- `web` `depends_on: cms` + `DATABASE_URL` nur wenn Web eigene DB braucht (meist nicht – nur CMS nutzt DB)
+
+Entrypoint-Pattern von [docker/entrypoint.sh](docker/entrypoint.sh) übernehmen: **DB warten → Migration → dann Prozess starten**.
+
+---
+
+## Coolify-Workflow (End-to-End)
+
+```mermaid
+sequenceDiagram
+ participant Dev as Developer
+ participant GH as GitHub
+ participant GHA as GitHub Actions
+ participant CF as Coolify
+ participant Web as Next Container
+ participant CMS as Payload Container
+
+ Dev->>GH: push feature branch
+ Dev->>GH: open PR
+ GHA->>GHA: lint typecheck build docker
+ GHA-->>Dev: PR checks green
+ Dev->>GH: merge to main
+ GH->>CF: webhook deploy
+ CF->>CF: build CMS image
+ CF->>CF: build Web image
+ CF->>Web: rolling update
+ CMS->>Web: optional revalidate webhook
+```
+
+**Coolify-Konfiguration pro App:**
+
+| Setting | Web | CMS |
+|---------|-----|-----|
+| Build Pack | Dockerfile | Dockerfile |
+| Branch | `main` | `main` |
+| Port | 3000 | 3001 |
+| Health | `/api/health` | `/api/health` oder Payload-Default |
+| Secrets | `REVALIDATE_SECRET`, `CMS_URL` | `DATABASE_URL`, `PAYLOAD_SECRET` |
+
+**Checkliste Erstdeploy** (aus [docs/COOLIFY.md](docs/COOLIFY.md) übertragbar):
+
+1. Postgres healthy, internal URL notieren
+2. CMS deployen, Admin anlegen, Collections seeden
+3. Web deployen mit internal `CMS_URL`
+4. Domain + HTTPS (Traefik)
+5. Webhook Payload → Next Revalidate testen
+6. `RUN_DB_SEED` / Migration-Flags wieder aus
+
+---
+
+## GitHub Actions (CI vor Coolify)
+
+**Workflow `ci.yml`** (bei jedem PR + push auf `main`):
+
+1. **checkout**
+2. **Node 22** + Cache (`apps/web`, `apps/cms`)
+3. **Parallel jobs oder Matrix:**
+ - `npm run lint` (ESLint)
+ - `npm run typecheck` (`tsc --noEmit`)
+ - `npm run build` (Web + CMS)
+4. **Docker build test** (ohne Push):
+ - `docker build -f apps/web/Dockerfile apps/web`
+ - `docker build -f apps/cms/Dockerfile apps/cms`
+5. **(optional) Playwright** gegen `docker compose up` – Smoke: Startseite, eine CMS-Seite, Health endpoints
+6. **(optional) PR Review** – bestehendes Gemini-Workflow aus FlixCooks wiederverwenden
+
+**Branch-Schutz:** `main` nur mit grünen Required Checks mergebar.
+
+Coolify deployt **nach** Merge – CI blockiert kaputte Images, Coolify baut das echte Production-Image (oder du pushst zu GHCR – für den Start reicht Coolify-eigener Build).
+
+---
+
+## KI-gestützte Entwicklung mit MCP
+
+**Cursor `mcp.json` (Team-Standard):**
+
+| MCP | Zweck |
+|-----|--------|
+| **GitHub** | Issues, PRs, Actions-Logs aus dem Chat |
+| **Context7** (oder Fetch) | Aktuelle Next.js / Payload / GSAP-Docs |
+| **Postgres** (optional, nur Dev) | Content/Debugging – nie Production-Credentials im Repo |
+| **Filesystem** | Standard in Cursor |
+
+**Projekt-Wissen für Agenten** (aus FlixCooks übernehmen):
+
+- [.agents/brain.md](.agents/brain.md) – Architektur, Env-Regeln, Coolify-Hosts
+- [.agents/rules/AGENT.md](.agents/rules/AGENT.md) – Commit/PR/TODO-Konventionen
+- `docs/COOLIFY.md` – Deploy-Runbook pro Projekt
+
+**Typischer KI-Workflow:**
+
+1. Ticket/Issue in GitHub (MCP)
+2. Feature-Branch; Agent ändert `apps/web` + Payload-Collection
+3. `docker compose up` lokal; Agent nutzt Health-URLs
+4. PR → CI grün → Gemini-Review optional
+5. Merge → Coolify
+
+---
+
+## Entwickler-Alltag (Kurzablauf)
+
+1. `cp .env.example .env` – lokale URLs
+2. `docker compose -f docker-compose.dev.yml up -d` (nur Postgres) **oder** volles `docker compose up`
+3. `npm run dev` in `apps/web` und `apps/cms` (schneller Hot Reload) **oder** alles in Containern
+4. In Payload Inhalte pflegen → Webhook triggert Revalidate
+5. `git push` → PR → CI → merge → Coolify rebuild
+
+---
+
+## Bezug zu FlixCooks (dieses Repo)
+
+FlixCooks ist heute **Vanilla PHP + Postgres + eingebautes `admin.php`-CMS** – kein React. Das ist ein **paralleler Stack**, kein Widerspruch:
+
+| Aspekt | FlixCooks (aktuell) | Neuer React-Stack |
+|--------|---------------------|-------------------|
+| Frontend | PHP-Templates | Next.js + React |
+| CMS | `admin.php` | Payload (Container) |
+| DB | Postgres | Postgres |
+| Deploy | [Dockerfile](Dockerfile) + [COOLIFY.md](docs/COOLIFY.md) | gleiches Muster, zwei App-Services |
+| Animationen | Lenis, Vanilla CSS | Lenis + GSAP + Framer |
+
+Du kannst FlixCooks auf Coolify weiterbetreiben und **neue Projekte** im Monorepo-Template starten. Eine spätere Migration FlixCooks → Next wäre ein separates Projekt (Content-Export aus Postgres/JSON → Payload-Collections).
+
+---
+
+## Nächste konkrete Schritte (nach Plan-Freigabe)
+
+1. **Greenfield-Repo** aus der Struktur oben scaffolden (oder `create-payload-app` + Next in Monorepo).
+2. **Minimale Collections** in Payload: `pages`, `recipes` (oder `projects`), `siteSettings`, Media.
+3. **Dockerfiles + compose** + `docs/COOLIFY.md` vom FlixCooks-Muster kopieren/adaptieren.
+4. **GitHub Actions `ci.yml`** anlegen.
+5. **Coolify:** Postgres → CMS → Web → Webhook Revalidate testen.
+6. **`.cursor/mcp.json` + `.agents/brain.md`** für das neue Repo.
+
+Wenn du willst, kann im nächsten Schritt ein **konkretes Starter-Repo** (Dateien + minimale Hero-Animation + eine Payload-Collection) direkt in einem neuen Ordner oder Branch angelegt werden.
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..88018b8
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,12 @@
+.git
+.github
+.agents
+.env
+.env.*
+!.env.example
+docker-compose.dev.yml
+docker-compose.yml
+terminals
+*.md
+!README.md
+.cursor
diff --git a/.env.example b/.env.example
index 810ca3e..8ec31d8 100644
--- a/.env.example
+++ b/.env.example
@@ -1,9 +1,11 @@
-# FlixCooks Firebase Configuration
-# Replace these placeholder values with your actual Firebase project settings.
+# FlixCooks – lokale Entwicklung (.env wird nicht committed)
+# Kopieren: cp .env.example .env
-FIREBASE_API_KEY="AIzaSyYourApiKeyHere"
-FIREBASE_AUTH_DOMAIN="flixcooks-your-project-id.firebaseapp.com"
-FIREBASE_PROJECT_ID="flixcooks-your-project-id"
-FIREBASE_STORAGE_BUCKET="flixcooks-your-project-id.appspot.com"
-FIREBASE_MESSAGING_SENDER_ID="123456789012"
-FIREBASE_APP_ID="1:123456789012:web:abcdef123456"
+# --- Postgres (Pflicht für die Website) ---
+# Lokal: docker compose -f docker-compose.dev.yml up -d
+DATABASE_URL="postgresql://flixcooks:flixcooks_dev@127.0.0.1:5432/flixcooks_dev"
+
+# --- Production / Coolify (im Dashboard setzen, nicht committen) ---
+# DATABASE_URL="postgresql://user:pass@postgresql-service:5432/flixcooks"
+# FLIXCOOKS_ADMIN_KEY="langes-zufaelliges-passwort"
+# RUN_DB_SEED="true" # nur beim allerersten Deploy, danach entfernen
diff --git a/.gitattributes b/.gitattributes
index dfe0770..f7e2def 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,2 +1,2 @@
-# Auto detect text files and perform LF normalization
-* text=auto
+* text=auto eol=lf
+*.sh text eol=lf
diff --git a/.htaccess b/.htaccess
deleted file mode 100644
index f96c3a6..0000000
--- a/.htaccess
+++ /dev/null
@@ -1,4 +0,0 @@
-#KONSOLEH AREA START - PLEASE DO NOT EDIT MANUALLY BETWEEN THESE LINES
-DirectoryIndex index.php
-#KONSOLEH AREA END - PLEASE ADD MANUAL CHANGES BELOW
-SetEnv FLIXCOOKS_ADMIN_KEY "vFDH.N_tVLEKNdR3fhLs"
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..32682a1
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,37 @@
+# FlixCooks – Production image (Coolify / any Docker host)
+FROM php:8.3-apache-bookworm
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libpq-dev \
+ && docker-php-ext-install pdo_pgsql \
+ && a2enmod rewrite headers \
+ && rm -rf /var/lib/apt/lists/*
+
+# Apache: document root, no per-directory overrides in production
+ENV APACHE_DOCUMENT_ROOT=/var/www/html
+RUN sed -ri 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
+ && sed -ri 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf \
+ && printf '%s\n' \
+ '' \
+ ' AllowOverride None' \
+ ' Require all granted' \
+ '' \
+ > /etc/apache2/conf-available/flixcooks.conf \
+ && a2enconf flixcooks
+
+WORKDIR /var/www/html
+
+COPY --chown=www-data:www-data . /var/www/html
+
+RUN sed -i 's/\r$//' /var/www/html/docker/entrypoint.sh \
+ && chmod +x /var/www/html/docker/entrypoint.sh \
+ && mkdir -p /var/www/html/data \
+ && chown -R www-data:www-data /var/www/html/data
+
+EXPOSE 80
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
+ CMD php /var/www/html/health.php >/dev/null || exit 1
+
+ENTRYPOINT ["/var/www/html/docker/entrypoint.sh"]
+CMD ["apache2-foreground"]
diff --git a/README.md b/README.md
index 9c2bb3b..3cb670b 100644
--- a/README.md
+++ b/README.md
@@ -13,13 +13,14 @@ The project is architected to remain extremely lightweight and fast, intentional
- `index.php`: The atmospheric landing page showcasing featured recipe selections, introducing the brand, and housing the **Sleek Swipe Discovery Carousel**.
- `recipe.php`: The immersive recipe detail page, featuring floating macro-nutrition widgets, interactive ingredients lists, and the fullscreen **Step-by-Step Cooking Mode**.
- `admin.php`: A custom, lightweight CMS/admin dashboard allowing full CRUD capabilities over the recipe database, dynamic ingredient line parsing, cooking timers, and video URL associations.
- - `login.php` & `register.php`: Fully responsive, glassmorphic auth portals powered by Firebase.
+ - `login.php`: Local profile page for dietary goals and saved favorites (browser storage).
- **Support & Layouts**:
- `partials/`: Contains modular templates (`head.php`, `header.php`, `footer.php`) to maintain a clean DRY structure.
- - `helpers.php`: Core PHP utilities containing data formatting helpers and the data access layer for the flat-file database.
+ - `helpers.php`: Core PHP utilities and the PostgreSQL data access layer for recipes.
- `config.php`: Environment-independent configuration loader which reads runtime secrets from `.env`.
- - `api/`: Lightweight, stateless backend endpoints supporting AJAX operations (e.g., newsletter subscriptions, bookmarks, recommendation queries).
- - `data/`: Houses `recipes.json`, our flat-file recipe database.
+ - `assets/fc-local.js`: Browser-side storage for favorites and dietary goals.
+ - `data/recipes.json`: Optional seed file only (`php scripts/db-seed.php`), not used at runtime.
+ - `scripts/schema.sql`: Relational table definitions for recipes.
---
@@ -36,13 +37,9 @@ FlixCooks uses a modern, carefully curated vanilla tech-stack focused on lightni
### ⚙️ Backend & Data
- **Engine**: Vanilla PHP.
-- **Database**: Flat-file JSON database (`data/recipes.json`), allowing lightning-quick load times and simple structural schemas without heavy overhead.
+- **Database**: PostgreSQL only. `DATABASE_URL` is required; without a working DB connection the site returns HTTP 503.
- **Environment**: Custom `.env` variable parser integrated into PHP bootstrap.
-### 🔒 Integrations & Cloud Services
-- **Firebase Authentication**: Client and server-side synchronized user sessions for profile management.
-- **Firebase Firestore**: Real-time database managing newsletter subscribers and user bookmarks/favorites lists.
-
---
## 🚀 Local Development Setup
@@ -51,7 +48,8 @@ Follow these simple steps to spin up the local development environment.
### Prerequisites
Make sure you have the following installed on your local machine:
-- **PHP** (v7.4 or higher recommended)
+- **PHP** (8.x recommended) with the **pgsql** extension (`php-pgsql` on Linux/WSL)
+- **Docker** (for local Postgres via `docker-compose.dev.yml`)
- A modern web browser
### 1. Set Up Environment Variables
@@ -59,14 +57,63 @@ Make sure you have the following installed on your local machine:
```bash
cp .env.example .env
```
-2. Open `.env` and fill in your actual **Firebase project settings** (API keys, project identifier, authentication domain, etc.):
- ```env
- FIREBASE_API_KEY="AIzaSyYourApiKeyHere"
- FIREBASE_AUTH_DOMAIN="flixcooks-your-project-id.firebaseapp.com"
- ...
- ```
+2. Set **`DATABASE_URL`** in `.env` (required). See [Local Postgres (Docker)](#local-postgres-docker) below.
+3. Seed recipes once: `php scripts/db-seed.php` (imports `data/recipes.json` into SQL tables).
-### 2. Start the Development Server
+### 2. Local Postgres (Docker)
+
+Für DB-Integration auf einem Dev-Branch – getrennt von Production.
+
+```bash
+# Container starten
+docker compose -f docker-compose.dev.yml up -d
+
+# Warten bis healthy (einmalig prüfen)
+docker compose -f docker-compose.dev.yml ps
+```
+
+In `.env` (Werte passen zu `docker-compose.dev.yml`):
+
+```env
+DATABASE_URL="postgresql://flixcooks:flixcooks_dev@127.0.0.1:5432/flixcooks_dev"
+```
+
+**PHP-Extension (WSL/Ubuntu, einmalig):**
+
+```bash
+sudo apt install php-pgsql
+# oder passend zur Version: sudo apt install php8.5-pgsql
+```
+
+**Datenbank-Shell (zum Lernen / Inspizieren):**
+
+```bash
+docker exec -it flixcooks-postgres-dev psql -U flixcooks -d flixcooks_dev
+```
+
+Nützliche SQL-Befehle in `psql`:
+
+```sql
+\dt -- alle Tabellen
+\d recipes -- Spalten der Tabelle recipes
+SELECT slug, created_at FROM recipes;
+SELECT slug, data->>'title' AS title FROM recipes, jsonb_to_record(data) AS x(title text); -- optional
+\q -- beenden
+```
+
+**DB komplett leeren und neu seeden:**
+
+```bash
+docker compose -f docker-compose.dev.yml down -v
+docker compose -f docker-compose.dev.yml up -d
+php scripts/db-seed.php
+```
+
+**Verbindung prüfen:** `php scripts/db-check.php`
+
+Details zum Schema: Abschnitt unten und `scripts/schema.sql`.
+
+### 3. Start the Development Server
#### Option A: PHP Built-in Web Server (Recommended & Easiest)
You do not need to install complex local servers like Apache or Nginx. Simply run the following command in the root folder of the project:
@@ -78,14 +125,50 @@ Then, open your browser and navigate to:
http://localhost:8000
```
-#### Option B: Local Apache Environments (XAMPP / MAMP / WAMP)
+#### Option B: Local Apache (XAMPP / MAMP / WAMP)
If you prefer running a full local stack:
1. Move or link the project directory inside your local server's document root (e.g., `htdocs` or `www`).
-2. Ensure URL rewriting is enabled (the included `.htaccess` file handles caching and custom redirections).
+2. Configure the virtual host or Apache server config to use `DirectoryIndex index.php`; this repo does not rely on `.htaccess` overrides.
3. Access the site via your custom local virtual host (e.g., `http://localhost/flixcooks-website`).
---
+## 🗄️ Postgres in diesem Projekt (Kurzüberblick)
+
+Rezepte liegen in **normalisierten SQL-Tabellen** (kein JSONB-Blob, kein Laufzeit-Fallback auf Dateien):
+
+| Tabelle | Inhalt |
+|---------|--------|
+| `recipes` | Slug, Zeiten, Hero-URL, Nährwerte, `featured`, `coming_soon` |
+| `recipe_translations` | Titel, Beschreibung, Kategorie, Schwierigkeit (EN/DE) |
+| `recipe_tags` | Tags pro Sprache |
+| `recipe_ingredients` | Zutatenzeilen |
+| `recipe_utensils` | Werkzeugzeilen |
+| `recipe_steps` | Schritte inkl. Video-URL und Timer |
+
+Schema: `scripts/schema.sql`. PHP baut daraus dieselben Arrays wie früher (`i18n.en`, `nutrition`, …), damit Templates unverändert bleiben.
+
+**Ablauf:**
+
+1. `DATABASE_URL` in `.env` → Verbindung über `config.php`.
+2. Beim ersten Request: Tabellen anlegen (`ensure_recipe_schema()`). Alte JSONB-Tabelle wird einmalig migriert.
+3. `load_recipes()` liest per SQL; ohne DB → HTTP 503 (`maintenance/db-unavailable.php`).
+4. Admin: `save_recipe()` / `delete_recipe()` – direkt in die Tabellen.
+
+**Einmalig Daten laden:** `php scripts/db-seed.php` (aus `data/recipes.json`).
+
+Für **Staging/Production** nur `DATABASE_URL` in der Hosting-Umgebung setzen – nie Production-Daten in der lokalen Dev-DB mischen.
+
+### Docker / Coolify
+
+Production-Image: `Dockerfile` im Repo-Root. Ausführliche Schritte: [docs/COOLIFY.md](docs/COOLIFY.md).
+
+```bash
+docker compose build && docker compose up -d # lokal testen → http://127.0.0.1:8080
+```
+
+---
+
## 📈 Development Tracking & Progress
All current development tasks, features, and roadmaps are actively tracked and updated in the project’s [.agents/TODO.md](file:///.agents/TODO.md) file.
Architectural learnings, conventions, and configuration updates are maintained in the central knowledge base: [.agents/brain.md](file:///.agents/brain.md).
diff --git a/admin.php b/admin.php
index 1902a51..b07411a 100644
--- a/admin.php
+++ b/admin.php
@@ -2,9 +2,36 @@
session_start();
require __DIR__ . '/helpers.php';
-$ADMIN_KEY = getenv('FLIXCOOKS_ADMIN_KEY') ?: 'vFDH.N_tVLEKNdR3fhLs';
+$ADMIN_KEY = getenv('FLIXCOOKS_ADMIN_KEY') ?: '';
$authed = isset($_SESSION['fc_admin']) && $_SESSION['fc_admin'] === true;
+if ($ADMIN_KEY === '') {
+ http_response_code(503);
+ ?>
+
+
+
+
+
+ FlixCooks Admin Unavailable
+
+
+
+
+
+
Admin unavailable
+
FLIXCOOKS_ADMIN_KEY is not configured.
+
+
+
+ ($r['slug'] ?? '') !== $slugDel));
- if ($before !== count($allRecipes) && save_recipes($allRecipes)) {
+ if ($slugDel !== '' && delete_recipe($slugDel)) {
$message = 'Recipe deleted.';
+ $allRecipes = load_recipes();
} else {
- $message = 'Could not delete. Check permissions.';
+ $message = 'Could not delete recipe.';
}
$editing = null;
$editIndex = null;
@@ -259,13 +289,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
}
}
- if ($featured) {
- foreach ($allRecipes as &$r) {
- $r['featured'] = false;
- }
- unset($r);
- }
-
$record = [
'slug' => $slug,
'hero' => $hero,
@@ -309,16 +332,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
],
];
- if ($slugOriginal && $editIndex !== null) {
- $allRecipes[$editIndex] = $record;
- } else {
- $allRecipes[] = $record;
+ if ($slugOriginal && $slugOriginal !== $slug) {
+ delete_recipe($slugOriginal);
}
- if (save_recipes($allRecipes)) {
+ if (save_recipe($record)) {
$message = $slugOriginal ? 'Saved changes.' : 'Saved! New recipe added.';
+ $allRecipes = load_recipes();
} else {
- $message = 'Could not save the file. Check permissions on data/recipes.json.';
+ $message = 'Could not save to database.';
}
}
}
diff --git a/api/session.php b/api/session.php
deleted file mode 100644
index 9aecee2..0000000
--- a/api/session.php
+++ /dev/null
@@ -1,40 +0,0 @@
- $uid,
- 'email' => $email,
- 'token' => $token
- ];
- echo json_encode(['status' => 'success', 'message' => 'Logged in']);
- } else {
- http_response_code(400);
- echo json_encode(['status' => 'error', 'message' => 'Missing UID']);
- }
-} elseif ($action === 'logout') {
- $_SESSION = [];
- if (ini_get("session.use_cookies")) {
- $params = session_get_cookie_params();
- setcookie(session_name(), '', time() - 42000,
- $params["path"], $params["domain"],
- $params["secure"], $params["httponly"]
- );
- }
- session_destroy();
- echo json_encode(['status' => 'success', 'message' => 'Logged out']);
-} else {
- http_response_code(400);
- echo json_encode(['status' => 'error', 'message' => 'Invalid action']);
-}
diff --git a/assets/fc-local.js b/assets/fc-local.js
new file mode 100644
index 0000000..7df4708
--- /dev/null
+++ b/assets/fc-local.js
@@ -0,0 +1,62 @@
+// Local-only favorites and dietary goal (no cloud backend).
+(function (global) {
+ var FAV_KEY = 'fc_fav_slugs';
+ var GOAL_KEY = 'fc_diet_goal';
+
+ function readJson(key, fallback) {
+ try {
+ var raw = global.localStorage.getItem(key);
+ if (!raw) return fallback;
+ return JSON.parse(raw);
+ } catch (e) {
+ return fallback;
+ }
+ }
+
+ function writeJson(key, value) {
+ try {
+ global.localStorage.setItem(key, JSON.stringify(value));
+ } catch (e) {
+ /* ignore quota errors */
+ }
+ }
+
+ global.fcLocal = {
+ getFavorites: function () {
+ var list = readJson(FAV_KEY, []);
+ return Array.isArray(list) ? list : [];
+ },
+ hasFavorite: function (slug) {
+ return this.getFavorites().indexOf(slug) !== -1;
+ },
+ toggleFavorite: function (slug) {
+ var list = this.getFavorites();
+ var idx = list.indexOf(slug);
+ if (idx === -1) {
+ list.push(slug);
+ } else {
+ list.splice(idx, 1);
+ }
+ writeJson(FAV_KEY, list);
+ return list;
+ },
+ getGoal: function () {
+ try {
+ return global.localStorage.getItem(GOAL_KEY) || '';
+ } catch (e) {
+ return '';
+ }
+ },
+ setGoal: function (goal) {
+ try {
+ if (goal) {
+ global.localStorage.setItem(GOAL_KEY, goal);
+ } else {
+ global.localStorage.removeItem(GOAL_KEY);
+ }
+ } catch (e) {
+ /* ignore */
+ }
+ }
+ };
+})(window);
diff --git a/config.php b/config.php
index 869259c..2422301 100644
--- a/config.php
+++ b/config.php
@@ -34,13 +34,51 @@ function load_env() {
// Automatically load on include
load_env();
-function get_firebase_config(): array {
- return [
- 'apiKey' => getenv('FIREBASE_API_KEY') ?: '',
- 'authDomain' => getenv('FIREBASE_AUTH_DOMAIN') ?: '',
- 'projectId' => getenv('FIREBASE_PROJECT_ID') ?: '',
- 'storageBucket' => getenv('FIREBASE_STORAGE_BUCKET') ?: '',
- 'messagingSenderId' => getenv('FIREBASE_MESSAGING_SENDER_ID') ?: '',
- 'appId' => getenv('FIREBASE_APP_ID') ?: '',
- ];
+class DatabaseUnavailableException extends RuntimeException
+{
+}
+
+/**
+ * Get a PDO connection to the database.
+ */
+function get_db_connection() {
+ static $pdo = null;
+
+ if ($pdo !== null) {
+ return $pdo;
+ }
+
+ if (!class_exists('PDO')) {
+ return null;
+ }
+
+ $url = getenv('DATABASE_URL');
+ if (!$url) {
+ return null;
+ }
+
+ $parsedUrl = parse_url($url);
+ if ($parsedUrl === false || !isset($parsedUrl['host'], $parsedUrl['user'], $parsedUrl['pass'], $parsedUrl['path'])) {
+ return null;
+ }
+
+ $host = $parsedUrl['host'];
+ $port = $parsedUrl['port'] ?? 5432;
+ $user = $parsedUrl['user'];
+ $pass = $parsedUrl['pass'];
+ $db = ltrim($parsedUrl['path'], '/');
+
+ $dsn = "pgsql:host=$host;port=$port;dbname=$db";
+
+ try {
+ $pdo = new PDO($dsn, $user, $pass, [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_TIMEOUT => 3,
+ ]);
+ return $pdo;
+ } catch (\Exception $e) {
+ error_log("Database connection failed: " . $e->getMessage());
+ return null;
+ }
}
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
new file mode 100644
index 0000000..4f1a2de
--- /dev/null
+++ b/docker-compose.dev.yml
@@ -0,0 +1,26 @@
+# Lokale Postgres-Instanz für Entwicklung (dev branch).
+# Start: docker compose -f docker-compose.dev.yml up -d
+# Stop: docker compose -f docker-compose.dev.yml down
+# Reset: docker compose -f docker-compose.dev.yml down -v (löscht alle Daten!)
+
+services:
+ postgres:
+ image: postgres:16-alpine
+ container_name: flixcooks-postgres-dev
+ restart: unless-stopped
+ ports:
+ - "5432:5432"
+ environment:
+ POSTGRES_USER: flixcooks
+ POSTGRES_PASSWORD: flixcooks_dev
+ POSTGRES_DB: flixcooks_dev
+ volumes:
+ - flixcooks_pg_dev:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U flixcooks -d flixcooks_dev"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+volumes:
+ flixcooks_pg_dev:
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..5ec26a7
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,42 @@
+# Lokaler Produktions-Test: App-Image + Postgres (ähnlich Coolify mit zwei Services)
+#
+# docker compose build
+# docker compose up -d
+# curl http://127.0.0.1:8080/health.php
+#
+# Erstes Deployment mit Seed:
+# RUN_DB_SEED=true docker compose up -d
+
+services:
+ web:
+ build: .
+ ports:
+ - "8080:80"
+ environment:
+ DATABASE_URL: postgresql://flixcooks:flixcooks_prod@postgres:5432/flixcooks
+ FLIXCOOKS_ADMIN_KEY: ${FLIXCOOKS_ADMIN_KEY:-change-me-in-production}
+ RUN_DB_SEED: ${RUN_DB_SEED:-false}
+ depends_on:
+ postgres:
+ condition: service_healthy
+ volumes:
+ # Impressum/Datenschutz-Einstellungen persistent halten
+ - site_data:/var/www/html/data
+
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_USER: flixcooks
+ POSTGRES_PASSWORD: flixcooks_prod
+ POSTGRES_DB: flixcooks
+ volumes:
+ - pg_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U flixcooks -d flixcooks"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+volumes:
+ pg_data:
+ site_data:
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
new file mode 100644
index 0000000..c5db388
--- /dev/null
+++ b/docker/entrypoint.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+set -euo pipefail
+
+cd /var/www/html
+
+echo "[flixcooks] Waiting for database..."
+TRIES=0
+MAX_TRIES="${DB_WAIT_MAX_TRIES:-30}"
+
+until php scripts/db-check.php >/dev/null 2>&1; do
+ TRIES=$((TRIES + 1))
+ if [ "$TRIES" -ge "$MAX_TRIES" ]; then
+ echo "[flixcooks] Database not reachable after ${MAX_TRIES} attempts." >&2
+ exit 1
+ fi
+ sleep 2
+done
+
+echo "[flixcooks] Applying schema..."
+php -r "require 'helpers.php'; require_database(); echo \"schema ok\n\";"
+
+if [ "${RUN_DB_SEED:-false}" = "true" ]; then
+ echo "[flixcooks] Seeding recipes from data/recipes.json..."
+ php scripts/db-seed.php
+fi
+
+echo "[flixcooks] Starting Apache..."
+exec "$@"
diff --git a/docs/COOLIFY.md b/docs/COOLIFY.md
new file mode 100644
index 0000000..f226bea
--- /dev/null
+++ b/docs/COOLIFY.md
@@ -0,0 +1,149 @@
+# FlixCooks auf Coolify deployen
+
+Zwei getrennte Ressourcen: **PostgreSQL** und **PHP-Web-App** (dieses Repo als Dockerfile).
+
+## Architektur
+
+```
+┌──────────────────── Coolify Server ────────────────────┐
+│ │
+│ ┌──────────────┐ ┌─────────────────────────┐ │
+│ │ PostgreSQL │◄────────│ FlixCooks (dieses Image) │ │
+│ │ (Service B) │ :5432 │ Apache + PHP 8.3 │ │
+│ └──────────────┘ │ Port 80 → Traefik/HTTPS │ │
+│ ▲ └─────────────────────────┘ │
+│ │ ▲ │
+│ Volume (Daten) Volume optional: │
+│ data/ (site.json) │
+└─────────────────────────────────────────────────────────┘
+```
+
+Die App startet **nicht**, wenn `DATABASE_URL` fehlt oder Postgres nicht erreichbar ist.
+
+---
+
+## 1. PostgreSQL in Coolify anlegen
+
+1. Neues **Database** → PostgreSQL (16).
+2. Notieren:
+ - Benutzer, Passwort, Datenbankname
+ - **Internal URL** (Host ist oft der Service-Name, z. B. `postgresql-xxxxx` oder was Coolify anzeigt)
+3. Format für die App:
+
+```text
+postgresql://USER:PASSWORD@HOST:5432/DATABASE
+```
+
+Beispiel (Platzhalter durch Coolify-Werte ersetzen):
+
+```text
+postgresql://flixcooks:geheim@postgresql-flixcooks:5432/flixcooks
+```
+
+**Wichtig:** In der App den **internen** Hostnamen verwenden (gleiches Coolify-Netzwerk), nicht `127.0.0.1`.
+
+---
+
+## 2. Web-App in Coolify anlegen
+
+1. Neues **Application** → Build Pack: **Dockerfile** (Repository dieses Projekts).
+2. Dockerfile-Pfad: `Dockerfile` (Root).
+3. Port: **80** (Container exponiert Apache auf 80).
+4. Health Check (optional, empfohlen):
+ - Path: `/health.php`
+ - Erwartet HTTP 200 mit `{"status":"ok"}`
+
+### Environment Variables (Pflicht)
+
+
+| Variable | Beschreibung |
+| --------------------- | --------------------------------- |
+| `DATABASE_URL` | Interne Postgres-URL von Coolify |
+| `FLIXCOOKS_ADMIN_KEY` | Starkes Passwort für `/admin.php` |
+
+
+### Environment Variables (optional)
+
+
+| Variable | Default | Beschreibung |
+| ------------------- | ------- | ------------------------------------------------------------------ |
+| `RUN_DB_SEED` | `false` | Einmalig `true` setzen → importiert `data/recipes.json` beim Start |
+| `DB_WAIT_MAX_TRIES` | `30` | Warteversuche bis Postgres da ist (à 2 s) |
+
+
+Nach dem ersten erfolgreichen Deploy: `RUN_DB_SEED` wieder auf `false` oder entfernen.
+
+### Persistent Storage (empfohlen)
+
+Mount für Impressum/Datenschutz (`data/site.json`):
+
+
+| Mount Path (Container) | Inhalt |
+| ---------------------- | ----------------------------------------- |
+| `/var/www/html/data` | `site.json` bleibt nach Redeploy erhalten |
+
+
+Rezepte liegen in Postgres – **kein** Volume für Rezepte nötig.
+
+---
+
+## 3. Erstes Deployment (Checkliste)
+
+1. Postgres-Service läuft (healthy).
+2. App mit `DATABASE_URL` + `FLIXCOOKS_ADMIN_KEY` deployen.
+3. Einmalig `RUN_DB_SEED=true` → Redeploy → Rezepte prüfen auf der Startseite.
+4. `RUN_DB_SEED` deaktivieren.
+5. `https://deine-domain/admin.php` testen.
+6. `https://deine-domain/health.php` → `{"status":"ok"}`.
+
+### Schema ohne Seed
+
+Tabellen legt der Container beim Start automatisch an (`scripts/schema.sql` via `require_database()`). Ohne Seed ist die DB leer → Seite lädt, aber keine Rezepte, bis du im Admin anlegst oder seedest.
+
+---
+
+## 4. Lokaler Test vor Coolify
+
+```bash
+# Starkes Admin-Passwort setzen
+export FLIXCOOKS_ADMIN_KEY="dein-geheimes-passwort"
+
+# Mit Seed
+export RUN_DB_SEED=true
+docker compose build
+docker compose up -d
+
+curl http://127.0.0.1:8080/health.php
+```
+
+---
+
+## 5. Updates / Redeploy
+
+- Neues Image bauen lassen (Git push → Coolify rebuild).
+- Postgres-Volume bleibt → Daten bleiben.
+- `data/`-Volume bleibt → Site-Settings bleiben.
+- Kein manuelles `db-seed` bei Updates, außer du leerst die DB bewusst.
+
+---
+
+## 6. Sicherheit
+
+- `.env` wird **nicht** ins Image kopiert (`.dockerignore`).
+- Admin-Key **nur** über `FLIXCOOKS_ADMIN_KEY` in Coolify setzen. Ohne diese Variable ist `/admin.php` deaktiviert.
+- Postgres nicht öffentlich exponieren, wenn nicht nötig (nur interne URL).
+
+---
+
+## 7. Troubleshooting
+
+
+| Problem | Lösung |
+| -------------------------------- | --------------------------------------------------------------- |
+| Container startet nicht | Logs: DB nicht erreichbar → `DATABASE_URL` Host/Passwort prüfen |
+| 503 „Datenbank nicht verfügbar“ | Gleiches Netzwerk in Coolify? Internal URL? |
+| Leere Seite, Health OK | `RUN_DB_SEED=true` einmalig oder Admin-Rezepte anlegen |
+| Admin geht nicht | `FLIXCOOKS_ADMIN_KEY` gesetzt? |
+| `site.json` verloren nach Deploy | Volume auf `/var/www/html/data` mounten |
+
+
diff --git a/health.php b/health.php
new file mode 100644
index 0000000..f3d269b
--- /dev/null
+++ b/health.php
@@ -0,0 +1,25 @@
+ 'ok'];
+ echo json_encode($response, JSON_UNESCAPED_UNICODE);
+} catch (Throwable $e) {
+ http_response_code(503);
+ $response = [
+ 'status' => 'error',
+ 'message' => $e->getMessage(),
+ ];
+ echo json_encode($response, JSON_UNESCAPED_UNICODE);
+ exit(1);
+}
diff --git a/helpers.php b/helpers.php
index d6b4099..1774209 100644
--- a/helpers.php
+++ b/helpers.php
@@ -7,31 +7,381 @@ if (session_status() === PHP_SESSION_NONE) {
require_once __DIR__ . '/config.php';
-function load_recipes(): array {
- $path = __DIR__ . '/data/recipes.json';
+function handle_database_unavailable(DatabaseUnavailableException $e): void {
+ http_response_code(503);
+ $dbError = $e->getMessage();
+ include __DIR__ . '/maintenance/db-unavailable.php';
+ exit;
+}
+
+function db_table_has_column(PDO $pdo, string $table, string $column): bool {
+ $stmt = $pdo->prepare(
+ 'SELECT 1 FROM information_schema.columns
+ WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?'
+ );
+ $stmt->execute([$table, $column]);
+ return (bool) $stmt->fetchColumn();
+}
+
+function apply_recipe_schema(PDO $pdo): void {
+ $path = __DIR__ . '/scripts/schema.sql';
if (!file_exists($path)) {
- return [];
+ throw new RuntimeException('Missing scripts/schema.sql');
}
- $json = file_get_contents($path);
- $data = json_decode($json, true);
- if (!is_array($data)) {
+ $pdo->exec(file_get_contents($path));
+}
+
+function recipe_empty_i18n(): array {
+ return [
+ 'title' => '',
+ 'description' => '',
+ 'category' => '',
+ 'difficulty' => '',
+ 'tags' => [],
+ 'ingredients' => [],
+ 'utensils' => [],
+ 'steps' => [],
+ 'step_videos' => [],
+ 'step_timers' => [],
+ ];
+}
+
+function recipe_base_from_row(array $row): array {
+ return [
+ 'slug' => $row['slug'],
+ 'hero' => $row['hero'] ?? '',
+ 'prep_time' => (int) ($row['prep_time'] ?? 0),
+ 'cook_time' => (int) ($row['cook_time'] ?? 0),
+ 'total_time' => (int) ($row['total_time'] ?? 0),
+ 'servings' => (int) ($row['servings'] ?? 2),
+ 'featured' => (bool) ($row['featured'] ?? false),
+ 'coming_soon' => (bool) ($row['coming_soon'] ?? false),
+ 'nutrition' => [
+ 'calories' => (int) ($row['calories'] ?? 0),
+ 'protein' => (int) ($row['protein'] ?? 0),
+ 'carbs' => (int) ($row['carbs'] ?? 0),
+ 'fat' => (int) ($row['fat'] ?? 0),
+ ],
+ 'i18n' => [
+ 'en' => recipe_empty_i18n(),
+ 'de' => recipe_empty_i18n(),
+ ],
+ ];
+}
+
+function decode_legacy_jsonb_value($value): ?array {
+ if (is_array($value)) {
+ return $value;
+ }
+ if (is_string($value)) {
+ $decoded = json_decode($value, true);
+ return is_array($decoded) ? $decoded : null;
+ }
+ if ($value !== null) {
+ $decoded = json_decode(json_encode($value), true);
+ return is_array($decoded) ? $decoded : null;
+ }
+ return null;
+}
+
+function migrate_legacy_jsonb_storage(PDO $pdo): void {
+ if (!db_table_has_column($pdo, 'recipes', 'data')) {
+ return;
+ }
+
+ $legacy = [];
+ $stmt = $pdo->query('SELECT slug, data FROM recipes');
+ while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
+ $recipe = decode_legacy_jsonb_value($row['data']);
+ if (is_array($recipe) && !empty($recipe['slug'])) {
+ $legacy[] = $recipe;
+ }
+ }
+
+ $pdo->exec('DROP TABLE IF EXISTS recipe_steps CASCADE');
+ $pdo->exec('DROP TABLE IF EXISTS recipe_utensils CASCADE');
+ $pdo->exec('DROP TABLE IF EXISTS recipe_ingredients CASCADE');
+ $pdo->exec('DROP TABLE IF EXISTS recipe_tags CASCADE');
+ $pdo->exec('DROP TABLE IF EXISTS recipe_translations CASCADE');
+ $pdo->exec('DROP TABLE IF EXISTS recipes CASCADE');
+
+ apply_recipe_schema($pdo);
+
+ foreach ($legacy as $recipe) {
+ save_recipe($recipe, $pdo);
+ }
+}
+
+function ensure_recipe_schema(PDO $pdo): void {
+ static $ready = false;
+ if ($ready) {
+ return;
+ }
+
+ if (db_table_has_column($pdo, 'recipes', 'data') && !db_table_has_column($pdo, 'recipes', 'calories')) {
+ migrate_legacy_jsonb_storage($pdo);
+ $ready = true;
+ return;
+ }
+
+ apply_recipe_schema($pdo);
+ $ready = true;
+}
+
+function require_database(): PDO {
+ $pdo = get_db_connection();
+ if (!$pdo) {
+ $message = getenv('DATABASE_URL')
+ ? 'Database connection failed. Check DATABASE_URL and that Postgres is running.'
+ : 'DATABASE_URL is not set in .env.';
+ throw new DatabaseUnavailableException($message);
+ }
+
+ ensure_recipe_schema($pdo);
+ return $pdo;
+}
+
+function init_db(): void {
+ require_database();
+}
+
+function hydrate_recipes_from_db(PDO $pdo): array {
+ $recipes = [];
+ $stmt = $pdo->query(
+ 'SELECT * FROM recipes ORDER BY featured DESC, updated_at DESC, slug ASC'
+ );
+ while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
+ $recipes[$row['slug']] = recipe_base_from_row($row);
+ }
+
+ if ($recipes === []) {
return [];
}
- foreach ($data as &$recipe) {
+ $stmt = $pdo->query('SELECT * FROM recipe_translations');
+ while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
+ $slug = $row['recipe_slug'];
+ $lang = $row['lang'];
+ if (!isset($recipes[$slug])) {
+ continue;
+ }
+ $recipes[$slug]['i18n'][$lang] = array_merge(recipe_empty_i18n(), [
+ 'title' => $row['title'],
+ 'description' => $row['description'],
+ 'category' => $row['category'],
+ 'difficulty' => $row['difficulty'],
+ ]);
+ }
+
+ $stmt = $pdo->query('SELECT recipe_slug, lang, tag FROM recipe_tags ORDER BY sort_order, id');
+ while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
+ if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
+ $recipes[$row['recipe_slug']]['i18n'][$row['lang']]['tags'][] = $row['tag'];
+ }
+ }
+
+ $stmt = $pdo->query('SELECT recipe_slug, lang, content FROM recipe_ingredients ORDER BY sort_order, id');
+ while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
+ if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
+ $recipes[$row['recipe_slug']]['i18n'][$row['lang']]['ingredients'][] = $row['content'];
+ }
+ }
+
+ $stmt = $pdo->query('SELECT recipe_slug, lang, content FROM recipe_utensils ORDER BY sort_order, id');
+ while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
+ if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
+ $recipes[$row['recipe_slug']]['i18n'][$row['lang']]['utensils'][] = $row['content'];
+ }
+ }
+
+ $stmt = $pdo->query(
+ 'SELECT recipe_slug, lang, content, video_url, timer_minutes
+ FROM recipe_steps ORDER BY sort_order, id'
+ );
+ while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
+ if (!isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
+ continue;
+ }
+ $lang = &$recipes[$row['recipe_slug']]['i18n'][$row['lang']];
+ $lang['steps'][] = $row['content'];
+ $lang['step_videos'][] = $row['video_url'] ?? '';
+ $lang['step_timers'][] = $row['timer_minutes'] !== null ? (string) $row['timer_minutes'] : '';
+ unset($lang);
+ }
+
+ $list = array_values($recipes);
+ foreach ($list as &$recipe) {
if (!empty($recipe['hero']) && is_string($recipe['hero'])) {
$recipe['hero'] = normalize_asset_path($recipe['hero']);
}
}
unset($recipe);
- return $data;
+ return $list;
}
-function save_recipes(array $recipes): bool {
- $path = __DIR__ . '/data/recipes.json';
- $json = json_encode($recipes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
- return (bool) file_put_contents($path, $json);
+function load_recipes(): array {
+ $pdo = require_database();
+ return hydrate_recipes_from_db($pdo);
+}
+
+function load_recipe_by_slug(string $slug): ?array {
+ foreach (load_recipes() as $recipe) {
+ if (($recipe['slug'] ?? '') === $slug) {
+ return $recipe;
+ }
+ }
+ return null;
+}
+
+function clear_featured_recipes(?PDO $pdo = null): void {
+ $pdo = $pdo ?? require_database();
+ $pdo->exec('UPDATE recipes SET featured = FALSE');
+}
+
+function delete_recipe(string $slug): bool {
+ $pdo = require_database();
+ $stmt = $pdo->prepare('DELETE FROM recipes WHERE slug = ?');
+ $stmt->execute([$slug]);
+ return $stmt->rowCount() > 0;
+}
+
+function save_recipe(array $recipe, ?PDO $pdo = null): bool {
+ if (empty($recipe['slug'])) {
+ return false;
+ }
+
+ $pdo = $pdo ?? require_database();
+ $nutrition = $recipe['nutrition'] ?? [];
+ $slug = $recipe['slug'];
+
+ try {
+ $pdo->beginTransaction();
+
+ if (!empty($recipe['featured'])) {
+ clear_featured_recipes($pdo);
+ }
+
+ $stmt = $pdo->prepare(
+ 'INSERT INTO recipes (
+ slug, hero, prep_time, cook_time, total_time, servings,
+ featured, coming_soon, calories, protein, carbs, fat, updated_at
+ ) VALUES (
+ :slug, :hero, :prep_time, :cook_time, :total_time, :servings,
+ :featured, :coming_soon, :calories, :protein, :carbs, :fat, NOW()
+ )
+ ON CONFLICT (slug) DO UPDATE SET
+ hero = EXCLUDED.hero,
+ prep_time = EXCLUDED.prep_time,
+ cook_time = EXCLUDED.cook_time,
+ total_time = EXCLUDED.total_time,
+ servings = EXCLUDED.servings,
+ featured = EXCLUDED.featured,
+ coming_soon = EXCLUDED.coming_soon,
+ calories = EXCLUDED.calories,
+ protein = EXCLUDED.protein,
+ carbs = EXCLUDED.carbs,
+ fat = EXCLUDED.fat,
+ updated_at = NOW()'
+ );
+ $stmt->bindValue(':slug', $slug);
+ $stmt->bindValue(':hero', $recipe['hero'] ?? '');
+ $stmt->bindValue(':prep_time', (int) ($recipe['prep_time'] ?? 0), PDO::PARAM_INT);
+ $stmt->bindValue(':cook_time', (int) ($recipe['cook_time'] ?? 0), PDO::PARAM_INT);
+ $stmt->bindValue(':total_time', (int) ($recipe['total_time'] ?? 0), PDO::PARAM_INT);
+ $stmt->bindValue(':servings', (int) ($recipe['servings'] ?? 2), PDO::PARAM_INT);
+ $stmt->bindValue(':featured', !empty($recipe['featured']), PDO::PARAM_BOOL);
+ $stmt->bindValue(':coming_soon', !empty($recipe['coming_soon']), PDO::PARAM_BOOL);
+ $stmt->bindValue(':calories', (int) ($nutrition['calories'] ?? 0), PDO::PARAM_INT);
+ $stmt->bindValue(':protein', (int) ($nutrition['protein'] ?? 0), PDO::PARAM_INT);
+ $stmt->bindValue(':carbs', (int) ($nutrition['carbs'] ?? 0), PDO::PARAM_INT);
+ $stmt->bindValue(':fat', (int) ($nutrition['fat'] ?? 0), PDO::PARAM_INT);
+ $stmt->execute();
+
+ $pdo->prepare('DELETE FROM recipe_translations WHERE recipe_slug = ?')->execute([$slug]);
+ $pdo->prepare('DELETE FROM recipe_tags WHERE recipe_slug = ?')->execute([$slug]);
+ $pdo->prepare('DELETE FROM recipe_ingredients WHERE recipe_slug = ?')->execute([$slug]);
+ $pdo->prepare('DELETE FROM recipe_utensils WHERE recipe_slug = ?')->execute([$slug]);
+ $pdo->prepare('DELETE FROM recipe_steps WHERE recipe_slug = ?')->execute([$slug]);
+
+ $translationStmt = $pdo->prepare(
+ 'INSERT INTO recipe_translations (recipe_slug, lang, title, description, category, difficulty)
+ VALUES (?, ?, ?, ?, ?, ?)'
+ );
+ $tagStmt = $pdo->prepare(
+ 'INSERT INTO recipe_tags (recipe_slug, lang, tag, sort_order) VALUES (?, ?, ?, ?)'
+ );
+ $ingredientStmt = $pdo->prepare(
+ 'INSERT INTO recipe_ingredients (recipe_slug, lang, content, sort_order) VALUES (?, ?, ?, ?)'
+ );
+ $utensilStmt = $pdo->prepare(
+ 'INSERT INTO recipe_utensils (recipe_slug, lang, content, sort_order) VALUES (?, ?, ?, ?)'
+ );
+ $stepStmt = $pdo->prepare(
+ 'INSERT INTO recipe_steps (recipe_slug, lang, content, video_url, timer_minutes, sort_order)
+ VALUES (?, ?, ?, ?, ?, ?)'
+ );
+
+ foreach (['en', 'de'] as $lang) {
+ $block = $recipe['i18n'][$lang] ?? [];
+ $translationStmt->execute([
+ $slug,
+ $lang,
+ $block['title'] ?? '',
+ $block['description'] ?? '',
+ $block['category'] ?? '',
+ $block['difficulty'] ?? '',
+ ]);
+
+ $tagOrder = 0;
+ foreach (array_values($block['tags'] ?? []) as $tag) {
+ $tag = trim((string) $tag);
+ if ($tag !== '') {
+ $tagStmt->execute([$slug, $lang, $tag, $tagOrder++]);
+ }
+ }
+
+ $ingredientOrder = 0;
+ foreach (array_values($block['ingredients'] ?? []) as $line) {
+ $line = trim((string) $line);
+ if ($line !== '') {
+ $ingredientStmt->execute([$slug, $lang, $line, $ingredientOrder++]);
+ }
+ }
+
+ $utensilOrder = 0;
+ foreach (array_values($block['utensils'] ?? []) as $line) {
+ $line = trim((string) $line);
+ if ($line !== '') {
+ $utensilStmt->execute([$slug, $lang, $line, $utensilOrder++]);
+ }
+ }
+
+ $steps = array_values($block['steps'] ?? []);
+ $videos = array_values($block['step_videos'] ?? []);
+ $timers = array_values($block['step_timers'] ?? []);
+ $stepOrder = 0;
+ foreach ($steps as $i => $step) {
+ $step = trim((string) $step);
+ if ($step === '') {
+ continue;
+ }
+ $video = trim((string) ($videos[$i] ?? ''));
+ $timerRaw = $timers[$i] ?? '';
+ $timerMinutes = ($timerRaw !== '' && is_numeric($timerRaw)) ? (int) $timerRaw : null;
+ $stepStmt->execute([$slug, $lang, $step, $video, $timerMinutes, $stepOrder++]);
+ }
+ }
+
+ $pdo->commit();
+ return true;
+ } catch (\Exception $e) {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+ error_log('save_recipe failed: ' . $e->getMessage());
+ return false;
+ }
}
/**
diff --git a/index.php b/index.php
index ed2c95a..894e4de 100644
--- a/index.php
+++ b/index.php
@@ -1,6 +1,12 @@
empty($r['coming_soon'])));
@@ -920,43 +925,38 @@ $recipesJson = json_encode(array_values(array_map(function($r) use ($lang) {
});
}
- // Hook into auth state
- window.addEventListener('DOMContentLoaded', function() {
- if (window.auth && window.db) {
- window.auth.onAuthStateChanged(function(user) {
- if (user) {
- window.db.collection('users').doc(user.uid).get()
- .then(function(doc) {
- if (doc.exists) {
- var goal = doc.data().goal;
- var filtered = indexRecipeBank;
-
- if (goal === 'weight_loss') {
- filtered = indexRecipeBank.filter(r =>
- r.tags.some(t => t.toLowerCase().includes('low-carb') || t.toLowerCase().includes('diet')) ||
- (r.category && r.category.toLowerCase().includes('salad'))
- );
- } else if (goal === 'muscle_gain') {
- filtered = indexRecipeBank.filter(r =>
- r.tags.some(t => t.toLowerCase().includes('high-protein') || t.toLowerCase().includes('meat') || t.toLowerCase().includes('fleisch'))
- );
- } else if (goal === 'healthy') {
- filtered = indexRecipeBank.filter(r =>
- r.tags.some(t => t.toLowerCase().includes('vegetarian') || t.toLowerCase().includes('healthy'))
- );
- }
-
- // fallback if empty
- if (filtered.length === 0) filtered = indexRecipeBank;
-
- renderSwipeCarousel(filtered, i18nGoal[goal] || null);
- }
- });
- } else {
- // Default unauthenticated view
- renderSwipeCarousel(indexRecipeBank, null);
- }
+ function filterRecipesByGoal(goal) {
+ var filtered = indexRecipeBank;
+ if (goal === 'weight_loss') {
+ filtered = indexRecipeBank.filter(function(r) {
+ return r.tags.some(function(t) {
+ var lower = t.toLowerCase();
+ return lower.includes('low-carb') || lower.includes('diet');
+ }) || (r.category && r.category.toLowerCase().includes('salad'));
});
+ } else if (goal === 'muscle_gain') {
+ filtered = indexRecipeBank.filter(function(r) {
+ return r.tags.some(function(t) {
+ var lower = t.toLowerCase();
+ return lower.includes('high-protein') || lower.includes('meat') || lower.includes('fleisch');
+ });
+ });
+ } else if (goal === 'healthy') {
+ filtered = indexRecipeBank.filter(function(r) {
+ return r.tags.some(function(t) {
+ var lower = t.toLowerCase();
+ return lower.includes('vegetarian') || lower.includes('healthy');
+ });
+ });
+ }
+ if (filtered.length === 0) filtered = indexRecipeBank;
+ return filtered;
+ }
+
+ window.addEventListener('DOMContentLoaded', function() {
+ var goal = window.fcLocal ? window.fcLocal.getGoal() : '';
+ if (goal) {
+ renderSwipeCarousel(filterRecipesByGoal(goal), i18nGoal[goal] || null);
} else {
renderSwipeCarousel(indexRecipeBank, null);
}
diff --git a/login.php b/login.php
index 5fddef4..cb1f87a 100644
--- a/login.php
+++ b/login.php
@@ -1,6 +1,12 @@
'Your dietary goal',
'saved_recipes' => 'Your Saved Favorites',
'no_favorites' => "You haven't saved any recipes yet. Explore our collection and tap the heart icon!",
- 'logout' => 'Log Out',
- 'err_pass_match' => 'Passwords do not match.',
- 'err_fill_fields'=> 'Please fill in all fields.',
+ 'save_goal' => 'Save goal',
+ 'goal_saved' => 'Dietary goal saved.',
+ 'local_note' => 'Favorites and goals are stored in this browser only.',
],
'de' => [
'page_title' => 'Mein Profil & Favoriten | FlixCooks',
@@ -46,9 +52,9 @@ $copy = [
'your_goal' => 'Dein Ernährungsziel',
'saved_recipes' => 'Deine gespeicherten Favoriten',
'no_favorites' => "Du hast noch keine Rezepte gespeichert. Entdecke unsere Küche und klicke auf das Herz-Symbol!",
- 'logout' => 'Abmelden',
- 'err_pass_match' => 'Die Passwörter stimmen nicht überein.',
- 'err_fill_fields'=> 'Bitte fülle alle Felder aus.',
+ 'save_goal' => 'Ziel speichern',
+ 'goal_saved' => 'Ernährungsziel gespeichert.',
+ 'local_note' => 'Favoriten und Ziele werden nur in diesem Browser gespeichert.',
]
];
@@ -56,10 +62,6 @@ $t = $copy[$lang];
$pageTitle = $t['page_title'];
$description = $t['meta_desc'];
-// Check PHP session state
-$user = $_SESSION['fc_user'] ?? null;
-
-$allRecipes = load_recipes();
$allRecipesLocal = localize_recipes($allRecipes, $lang);
// Formatter helper for JS usage
@@ -376,95 +378,37 @@ include __DIR__ . '/partials/header.php';
-
-
-
-
-