Dev #9
@@ -12,10 +12,10 @@ Dieses Dokument enthält den aktuellen Entwicklungsstand und detaillierte Aufgab
|
|||||||
- Fullscreen Overlay-Navigation (`LiquidOverlayMenu` & `SlideUpTextHover`)
|
- Fullscreen Overlay-Navigation (`LiquidOverlayMenu` & `SlideUpTextHover`)
|
||||||
- Geschmeidiges Scrollverhalten (`LenisSmoothScroll` & `IndexSectionIndicator`)
|
- Geschmeidiges Scrollverhalten (`LenisSmoothScroll` & `IndexSectionIndicator`)
|
||||||
- Layout-Raster (`FloemaLayoutGrid`) & Premium-Buttons
|
- Layout-Raster (`FloemaLayoutGrid`) & Premium-Buttons
|
||||||
- [x] **Phase 2: Authentifizierung & Firebase Integration**
|
- [x] **Phase 2: Profil & Personalisierung (lokal)**
|
||||||
- Firebase-Projekt Setup & Anbindung
|
- Ernährungsziel & Favoriten im Browser (`fc-local.js`)
|
||||||
- Newsletter-System (Firebase Firestore)
|
- Newsletter per Mailto
|
||||||
- Benutzerprofile (Registrierung, Login, Favoriten)
|
- Profilseite `login.php`
|
||||||
- [x] **Phase 3: Nährwerte & Rezepterweiterung**
|
- [x] **Phase 3: Nährwerte & Rezepterweiterung**
|
||||||
- Datenmodell & Admin.php Erweiterung
|
- Datenmodell & Admin.php Erweiterung
|
||||||
- Visuelles Nährwert-Widget (Floema-Präzision)
|
- 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)
|
- Touch-Swipe-Karussell direkt auf index.php (unter Landing-Page)
|
||||||
- Filteralgorithmus nach Benutzer-Ernährungszielen
|
- Filteralgorithmus nach Benutzer-Ernährungszielen
|
||||||
- Mikro-Animationen & Quick-Save Funktion
|
- 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
|
### 🔒 Phase 2: Profil & Personalisierung (lokal)
|
||||||
Das Fundament für Personalisierung und Newsletter-Abos über eine sichere Firebase-Anbindung (Auth & Firestore).
|
Favoriten, Ernährungsziel und Newsletter ohne Cloud-Backend.
|
||||||
|
|
||||||
- [x] **Firebase Setup & Initialisierung**
|
- [x] **Profilseite `login.php`**
|
||||||
- [x] Firebase SDK Client-Side Einbindung in `partials/head.php` or separaten Helper
|
- [x] Ernährungsziel wählen und in `localStorage` speichern (`fc-local.js`)
|
||||||
- [x] Firebase Config in umweltabhängigen Settings/Variablen auslagern
|
- [x] Gespeicherte Favoriten anzeigen
|
||||||
- [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] **Favoriten-Funktion (Rezept-Bookmarks)**
|
- [x] **Favoriten-Funktion (Rezept-Bookmarks)**
|
||||||
- [x] Firestore-Collection `user_favorites` anlegen (Format: `userId` -> Liste von `recipeSlugs`)
|
- [x] Herz-Icon auf Karten, Speicherung in `localStorage`
|
||||||
- [x] "Rezept speichern" Button (Herz-Icon) auf den Rezeptkarten und Rezept-Details mit dynamischem Login-Check
|
- [x] Mikro-Animationen für das Herz-Icon
|
||||||
- [x] Mikro-Animationen für das Herz-Icon (Scale up/down, Fülleffekte mit `var(--ease-overshoot)`)
|
- [x] **Newsletter**
|
||||||
- [x] **Firebase-gestütztes Newsletter-Abo**
|
- [x] Footer-Formular mit `mailto:`-Weiterleitung
|
||||||
- [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)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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.
|
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] **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] `admin.php` erweitern, um Video-URLs pro Einzelschritt einzugeben
|
||||||
- [x] **Fullscreen Cooking-Mode Overlay**
|
- [x] **Fullscreen Cooking-Mode Overlay**
|
||||||
- [x] Trigger-Button "Kochmodus starten" (`CapitoliumRevealButton` Stil) auf `recipe.php` einbauen
|
- [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] CSS-Klammern (`clamp()`) für responsive Skalierung der Karten
|
||||||
- [x] Flüssiges Einblenden per Scroll-Reveal (`reveal-target`) mit Anbindung an `LenisSmoothScroll`
|
- [x] Flüssiges Einblenden per Scroll-Reveal (`reveal-target`) mit Anbindung an `LenisSmoothScroll`
|
||||||
- [x] **Personalisiertes Goal-Matching**
|
- [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] 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] Personalisierte "Für dich empfohlen"-Badge auf den Karten anzeigen
|
||||||
- [x] **Mikro-Interaktionen & Quick-Save**
|
- [x] **Mikro-Interaktionen & Quick-Save**
|
||||||
- [x] Touch-Gesten-Unterstützung (Wischen zum Blättern mit sanftem Trägheits-Feedback)
|
- [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)
|
- [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
|
## 📈 Status-Legende
|
||||||
- `[ ]` Noch nicht begonnen
|
- `[ ]` Noch nicht begonnen
|
||||||
- `[/]` In Bearbeitung
|
- `[/]` In Bearbeitung
|
||||||
|
|||||||
@@ -1,37 +1,131 @@
|
|||||||
# FlixCooks Project Brain
|
# 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
|
## 1. Project Architecture & Stack
|
||||||
- **Backend:** Vanilla PHP. The project intentionally avoids heavy frameworks.
|
- **Backend:** Vanilla PHP. No heavy frameworks.
|
||||||
- **Database:** Flat-file JSON database (`data/recipes.json`). Data is loaded and saved via utility functions in `helpers.php`.
|
- **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`.
|
||||||
- **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.
|
- **Site settings (legal pages):** Flat-file `data/site.json` via `load_site_settings()` / `save_site_settings()` — not in Postgres.
|
||||||
- **Frontend:** Server-rendered PHP templates (`index.php`, `recipe.php`) with Vanilla JavaScript and Vanilla CSS. No Tailwind or heavy component libraries.
|
- **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
|
## 2. Design & Aesthetics
|
||||||
- **CSS:** Highly customized CSS with modern design tokens (e.g., `var(--ease-out-expo)`, `var(--surface-1)`).
|
- **CSS:** Custom properties (`var(--ease-out-expo)`, `var(--surface-1)`), glassmorphism, `FloemaLayoutGrid`.
|
||||||
- **Animations:** Employs sophisticated micro-animations, glassmorphism (`backdrop-filter: blur`), and dynamic layouts (e.g., `clip-path` for overlays).
|
- **Lenis:** Call `lenis.stop()` when opening fullscreen overlays (e.g. Cooking Mode); `lenis.start()` on close.
|
||||||
- **Smooth Scrolling:** Uses **Lenis** (`LenisSmoothScroll`).
|
- **Preloader:** `CapitoliumPreloader` on homepage; once per session via `sessionStorage('flixcooks_preloader_seen')`.
|
||||||
- *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.
|
|
||||||
|
|
||||||
## 3. Antigravity Agent Configuration
|
## 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:
|
- **Workspace Rules:** `.agents/rules/` with `always_on: true` and `glob: "*"` in frontmatter.
|
||||||
```yaml
|
- **Custom Skills:** `.agents/skills/` (e.g. `close_feature.json` for Git merge workflow).
|
||||||
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.
|
|
||||||
|
|
||||||
## 4. GitHub Actions & CI/CD
|
## 4. GitHub Actions & CI/CD
|
||||||
- **Gemini Code Review Automation:** We integrated `petarzarkov/gemini-code-review-action` to automatically review PRs.
|
- **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`).
|
||||||
- **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.
|
|
||||||
|
|
||||||
## 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
|
## 5. PostgreSQL — Schema & Data Flow
|
||||||
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.
|
|
||||||
|
### 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()` |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
.agents
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
docker-compose.dev.yml
|
||||||
|
docker-compose.yml
|
||||||
|
terminals
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
.cursor
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
# FlixCooks Firebase Configuration
|
# FlixCooks – lokale Entwicklung (.env wird nicht committed)
|
||||||
# Replace these placeholder values with your actual Firebase project settings.
|
# Kopieren: cp .env.example .env
|
||||||
|
|
||||||
FIREBASE_API_KEY="AIzaSyYourApiKeyHere"
|
# --- Postgres (Pflicht für die Website) ---
|
||||||
FIREBASE_AUTH_DOMAIN="flixcooks-your-project-id.firebaseapp.com"
|
# Lokal: docker compose -f docker-compose.dev.yml up -d
|
||||||
FIREBASE_PROJECT_ID="flixcooks-your-project-id"
|
DATABASE_URL="postgresql://flixcooks:flixcooks_dev@127.0.0.1:5432/flixcooks_dev"
|
||||||
FIREBASE_STORAGE_BUCKET="flixcooks-your-project-id.appspot.com"
|
|
||||||
FIREBASE_MESSAGING_SENDER_ID="123456789012"
|
# --- Production / Coolify (im Dashboard setzen, nicht committen) ---
|
||||||
FIREBASE_APP_ID="1:123456789012:web:abcdef123456"
|
# 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
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
# Auto detect text files and perform LF normalization
|
* text=auto eol=lf
|
||||||
* text=auto
|
*.sh text eol=lf
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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' \
|
||||||
|
'<Directory /var/www/html>' \
|
||||||
|
' AllowOverride None' \
|
||||||
|
' Require all granted' \
|
||||||
|
'</Directory>' \
|
||||||
|
> /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"]
|
||||||
@@ -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**.
|
- `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**.
|
- `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.
|
- `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**:
|
- **Support & Layouts**:
|
||||||
- `partials/`: Contains modular templates (`head.php`, `header.php`, `footer.php`) to maintain a clean DRY structure.
|
- `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`.
|
- `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).
|
- `assets/fc-local.js`: Browser-side storage for favorites and dietary goals.
|
||||||
- `data/`: Houses `recipes.json`, our flat-file recipe database.
|
- `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
|
### ⚙️ Backend & Data
|
||||||
- **Engine**: Vanilla PHP.
|
- **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.
|
- **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
|
## 🚀 Local Development Setup
|
||||||
@@ -51,7 +48,8 @@ Follow these simple steps to spin up the local development environment.
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
Make sure you have the following installed on your local machine:
|
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
|
- A modern web browser
|
||||||
|
|
||||||
### 1. Set Up Environment Variables
|
### 1. Set Up Environment Variables
|
||||||
@@ -59,14 +57,63 @@ Make sure you have the following installed on your local machine:
|
|||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
2. Open `.env` and fill in your actual **Firebase project settings** (API keys, project identifier, authentication domain, etc.):
|
2. Set **`DATABASE_URL`** in `.env` (required). See [Local Postgres (Docker)](#local-postgres-docker) below.
|
||||||
```env
|
3. Seed recipes once: `php scripts/db-seed.php` (imports `data/recipes.json` into SQL tables).
|
||||||
FIREBASE_API_KEY="AIzaSyYourApiKeyHere"
|
|
||||||
FIREBASE_AUTH_DOMAIN="flixcooks-your-project-id.firebaseapp.com"
|
### 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
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Start the Development Server
|
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)
|
#### 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:
|
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
|
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:
|
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`).
|
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`).
|
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
|
## 📈 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.
|
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).
|
Architectural learnings, conventions, and configuration updates are maintained in the central knowledge base: [.agents/brain.md](file:///.agents/brain.md).
|
||||||
|
|||||||
@@ -2,9 +2,36 @@
|
|||||||
session_start();
|
session_start();
|
||||||
require __DIR__ . '/helpers.php';
|
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;
|
$authed = isset($_SESSION['fc_admin']) && $_SESSION['fc_admin'] === true;
|
||||||
|
|
||||||
|
if ($ADMIN_KEY === '') {
|
||||||
|
http_response_code(503);
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FlixCooks Admin Unavailable</title>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
<style>
|
||||||
|
body { display: grid; place-items: center; min-height: 100vh; }
|
||||||
|
.login-card { background: #fff; padding: 24px; border-radius: 16px; box-shadow: var(--shadow); width: min(420px, 90vw); }
|
||||||
|
.error { color: #b00020; margin: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-card">
|
||||||
|
<h1>Admin unavailable</h1>
|
||||||
|
<p class="error">FLIXCOOKS_ADMIN_KEY is not configured.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
if (!$authed && isset($_POST['password'])) {
|
if (!$authed && isset($_POST['password'])) {
|
||||||
if (hash_equals($ADMIN_KEY, $_POST['password'])) {
|
if (hash_equals($ADMIN_KEY, $_POST['password'])) {
|
||||||
$_SESSION['fc_admin'] = true;
|
$_SESSION['fc_admin'] = true;
|
||||||
@@ -49,7 +76,11 @@ if (!$authed) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
$allRecipes = load_recipes();
|
$allRecipes = load_recipes();
|
||||||
|
} catch (DatabaseUnavailableException $e) {
|
||||||
|
handle_database_unavailable($e);
|
||||||
|
}
|
||||||
$siteSettings = load_site_settings();
|
$siteSettings = load_site_settings();
|
||||||
$message = null;
|
$message = null;
|
||||||
$errors = [];
|
$errors = [];
|
||||||
@@ -165,12 +196,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_
|
|||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete') {
|
||||||
$slugDel = trim($_POST['slug'] ?? '');
|
$slugDel = trim($_POST['slug'] ?? '');
|
||||||
$before = count($allRecipes);
|
if ($slugDel !== '' && delete_recipe($slugDel)) {
|
||||||
$allRecipes = array_values(array_filter($allRecipes, fn($r) => ($r['slug'] ?? '') !== $slugDel));
|
|
||||||
if ($before !== count($allRecipes) && save_recipes($allRecipes)) {
|
|
||||||
$message = 'Recipe deleted.';
|
$message = 'Recipe deleted.';
|
||||||
|
$allRecipes = load_recipes();
|
||||||
} else {
|
} else {
|
||||||
$message = 'Could not delete. Check permissions.';
|
$message = 'Could not delete recipe.';
|
||||||
}
|
}
|
||||||
$editing = null;
|
$editing = null;
|
||||||
$editIndex = 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 = [
|
$record = [
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'hero' => $hero,
|
'hero' => $hero,
|
||||||
@@ -309,16 +332,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($slugOriginal && $editIndex !== null) {
|
if ($slugOriginal && $slugOriginal !== $slug) {
|
||||||
$allRecipes[$editIndex] = $record;
|
delete_recipe($slugOriginal);
|
||||||
} else {
|
|
||||||
$allRecipes[] = $record;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (save_recipes($allRecipes)) {
|
if (save_recipe($record)) {
|
||||||
$message = $slugOriginal ? 'Saved changes.' : 'Saved! New recipe added.';
|
$message = $slugOriginal ? 'Saved changes.' : 'Saved! New recipe added.';
|
||||||
|
$allRecipes = load_recipes();
|
||||||
} else {
|
} else {
|
||||||
$message = 'Could not save the file. Check permissions on data/recipes.json.';
|
$message = 'Could not save to database.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
// PHP session synchronizer endpoint for Firebase Auth
|
|
||||||
|
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
|
|
||||||
$action = $_POST['action'] ?? '';
|
|
||||||
|
|
||||||
if ($action === 'login') {
|
|
||||||
$uid = $_POST['uid'] ?? '';
|
|
||||||
$email = $_POST['email'] ?? '';
|
|
||||||
$token = $_POST['token'] ?? '';
|
|
||||||
|
|
||||||
if ($uid !== '') {
|
|
||||||
$_SESSION['fc_user'] = [
|
|
||||||
'uid' => $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']);
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
@@ -34,13 +34,51 @@ function load_env() {
|
|||||||
// Automatically load on include
|
// Automatically load on include
|
||||||
load_env();
|
load_env();
|
||||||
|
|
||||||
function get_firebase_config(): array {
|
class DatabaseUnavailableException extends RuntimeException
|
||||||
return [
|
{
|
||||||
'apiKey' => getenv('FIREBASE_API_KEY') ?: '',
|
}
|
||||||
'authDomain' => getenv('FIREBASE_AUTH_DOMAIN') ?: '',
|
|
||||||
'projectId' => getenv('FIREBASE_PROJECT_ID') ?: '',
|
/**
|
||||||
'storageBucket' => getenv('FIREBASE_STORAGE_BUCKET') ?: '',
|
* Get a PDO connection to the database.
|
||||||
'messagingSenderId' => getenv('FIREBASE_MESSAGING_SENDER_ID') ?: '',
|
*/
|
||||||
'appId' => getenv('FIREBASE_APP_ID') ?: '',
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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
|
||||||
|
This is good practice for ensuring the database is ready before the application starts. However, the This is good practice for ensuring the database is ready before the application starts. However, the `healthcheck` in the `postgres` service has `interval: 5s` and `timeout: 5s`. If the database takes slightly longer to become fully ready than these parameters allow, the `web` service might still fail to start. Consider increasing the `interval` or `timeout` slightly, or ensuring the `pg_isready` command is robust enough to handle initial startup states.
|
|||||||
|
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:
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
It's good practice to include It's good practice to include `set -euo pipefail` in shell scripts to make them more robust by exiting on unbound variables (`-u`), non-zero exit codes (`-e`), and pipeline failures (`-o pipefail`). This is already present, which is excellent.
|
|||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd /var/www/html
|
||||||
|
|
||||||
|
echo "[flixcooks] Waiting for database..."
|
||||||
|
TRIES=0
|
||||||
|
MAX_TRIES="${DB_WAIT_MAX_TRIES:-30}"
|
||||||
|
Making the maximum number of retries configurable via an environment variable is a good flexible design. The default of 30 attempts (with a 2-second sleep) provides a generous 60-second wait, which is generally sufficient. Ensure this timeout is documented or understood by users deploying the application. Making the maximum number of retries configurable via an environment variable is a good flexible design. The default of 30 attempts (with a 2-second sleep) provides a generous 60-second wait, which is generally sufficient. Ensure this timeout is documented or understood by users deploying the application.
|
|||||||
|
|
||||||
|
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 "$@"
|
||||||
@@ -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 |
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Container health check (Coolify / Docker HEALTHCHECK).
|
||||||
|
*/
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
Wrapping the Wrapping the `load_recipes()` call in a `try...catch` block is essential for handling potential `DatabaseUnavailableException` errors gracefully, especially on pages that are not intended to show a maintenance page.
|
|||||||
|
if (!extension_loaded('pdo_pgsql')) {
|
||||||
|
throw new RuntimeException('pdo_pgsql extension missing');
|
||||||
|
}
|
||||||
|
require_once __DIR__ . '/helpers.php';
|
||||||
|
require_database();
|
||||||
|
$response = ['status' => '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);
|
||||||
|
}
|
||||||
@@ -7,31 +7,381 @@ if (session_status() === PHP_SESSION_NONE) {
|
|||||||
|
|
||||||
require_once __DIR__ . '/config.php';
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
function load_recipes(): array {
|
function handle_database_unavailable(DatabaseUnavailableException $e): void {
|
||||||
$path = __DIR__ . '/data/recipes.json';
|
http_response_code(503);
|
||||||
if (!file_exists($path)) {
|
$dbError = $e->getMessage();
|
||||||
return [];
|
include __DIR__ . '/maintenance/db-unavailable.php';
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
$json = file_get_contents($path);
|
|
||||||
$data = json_decode($json, true);
|
function db_table_has_column(PDO $pdo, string $table, string $column): bool {
|
||||||
if (!is_array($data)) {
|
$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();
|
||||||
|
}
|
||||||
|
|
||||||
|
This is a concise and efficient way to check for the existence of a column in This is a concise and efficient way to check for the existence of a column in `information_schema.columns`.
|
|||||||
|
function apply_recipe_schema(PDO $pdo): void {
|
||||||
|
$path = __DIR__ . '/scripts/schema.sql';
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
Loading the schema from an external SQL file ( Loading the schema from an external SQL file (`scripts/schema.sql`) is a good practice for maintainability and readability. Throwing a `RuntimeException` if the file is missing is appropriate.
|
|||||||
|
throw new RuntimeException('Missing scripts/schema.sql');
|
||||||
|
}
|
||||||
|
$pdo->exec(file_get_contents($path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function recipe_empty_i18n(): array {
|
||||||
|
return [
|
||||||
|
'title' => '',
|
||||||
|
'description' => '',
|
||||||
|
'category' => '',
|
||||||
|
'difficulty' => '',
|
||||||
|
'tags' => [],
|
||||||
|
This helper function provides a clean default structure for internationalized recipe data, ensuring consistency. This helper function provides a clean default structure for internationalized recipe data, ensuring consistency.
|
|||||||
|
'ingredients' => [],
|
||||||
|
'utensils' => [],
|
||||||
|
'steps' => [],
|
||||||
|
The default value for 'description' is an empty string. While functional, for internationalization, it's often beneficial to provide a placeholder string like The default value for 'description' is an empty string. While functional, for internationalization, it's often beneficial to provide a placeholder string like `null` or a specific marker (e.g., `__('default_description')`) that can be explicitly translated or identified as missing. This can help in debugging or ensuring all fields are eventually populated.
|
|||||||
|
'step_videos' => [],
|
||||||
|
Similar to the 'description', an empty string for 'category' might be better represented by Similar to the 'description', an empty string for 'category' might be better represented by `null` or a translatable placeholder to distinguish between an intentionally empty category and a missing one.
|
|||||||
|
'step_timers' => [],
|
||||||
|
Similar to 'description' and 'category', an empty string for 'difficulty' could be improved by using Similar to 'description' and 'category', an empty string for 'difficulty' could be improved by using `null` or a translatable placeholder. This helps differentiate between an unset difficulty and a deliberately empty one.
|
|||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function recipe_base_from_row(array $row): array {
|
||||||
|
return [
|
||||||
|
It's good that It's good that `step_videos` is initialized as an empty array. However, the `save_recipe` function inserts `video_url` from the input directly. If the input `step_videos` array contains `null` or empty strings, they will be inserted as such. Consider adding a trim or filter for empty strings here if they are not intended to be stored.
|
|||||||
|
'slug' => $row['slug'],
|
||||||
|
Similar to Similar to `step_videos`, `step_timers` is initialized as an empty array. The `save_recipe` function converts timer raw values to `int` or `null`. If the input array contains non-numeric strings, they will result in `null`. Ensure that any non-numeric or empty string values are handled consistently, perhaps by filtering them out before insertion.
|
|||||||
|
'hero' => $row['hero'] ?? '',
|
||||||
|
'prep_time' => (int) ($row['prep_time'] ?? 0),
|
||||||
|
'cook_time' => (int) ($row['cook_time'] ?? 0),
|
||||||
|
'total_time' => (int) ($row['total_time'] ?? 0),
|
||||||
|
This function correctly extracts base recipe data from a database row, handling potential missing keys with default values. The explicit type casting ( This function correctly extracts base recipe data from a database row, handling potential missing keys with default values. The explicit type casting (`(int)`, `(bool)`) is good for data integrity.
|
|||||||
|
'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;
|
||||||
|
This function attempts to decode legacy JSONB values. It handles arrays, strings, and objects that can be JSON encoded. However, it might be beneficial to add explicit handling or error logging for unexpected data types passed to This function attempts to decode legacy JSONB values. It handles arrays, strings, and objects that can be JSON encoded. However, it might be beneficial to add explicit handling or error logging for unexpected data types passed to `$value` to prevent potential `TypeError` or other runtime errors if the input is not as expected.
|
|||||||
|
}
|
||||||
|
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)) {
|
||||||
|
This check correctly identifies if the legacy 'data' column exists, preventing unnecessary migration steps. This is a robust way to handle schema evolution. This check correctly identifies if the legacy 'data' column exists, preventing unnecessary migration steps. This is a robust way to handle schema evolution.
|
|||||||
|
$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');
|
||||||
|
|
||||||
|
Dropping existing tables before applying the schema is a common migration strategy. However, if this script is ever run on a production database with existing data, this will result in data loss. Ensure this is only intended for development/testing environments or that a proper migration system is in place for production. Dropping existing tables before applying the schema is a common migration strategy. However, if this script is ever run on a production database with existing data, this will result in data loss. Ensure this is only intended for development/testing environments or that a proper migration system is in place for production.
|
|||||||
|
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')) {
|
||||||
|
Using a static variable Using a static variable `$ready` to ensure schema application only runs once per request is an efficient optimization. This prevents redundant database operations.
|
|||||||
|
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);
|
||||||
|
This function is crucial for database connectivity and schema management. The fallback logic for This function is crucial for database connectivity and schema management. The fallback logic for `DATABASE_URL` being unset is good. Throwing a `DatabaseUnavailableException` is a clear way to signal a critical error.
|
|||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
This function orchestrates the loading of all recipe data from the database, including translations, tags, ingredients, etc. It's well-structured, though the multiple This function orchestrates the loading of all recipe data from the database, including translations, tags, ingredients, etc. It's well-structured, though the multiple `SELECT` statements could potentially be optimized if performance becomes an issue with a very large number of recipes or related data.
|
|||||||
|
}
|
||||||
|
|
||||||
|
if ($recipes === []) {
|
||||||
return [];
|
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'])) {
|
if (!empty($recipe['hero']) && is_string($recipe['hero'])) {
|
||||||
$recipe['hero'] = normalize_asset_path($recipe['hero']);
|
$recipe['hero'] = normalize_asset_path($recipe['hero']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unset($recipe);
|
unset($recipe);
|
||||||
|
|
||||||
return $data;
|
return $list;
|
||||||
}
|
}
|
||||||
|
|
||||||
function save_recipes(array $recipes): bool {
|
function load_recipes(): array {
|
||||||
$path = __DIR__ . '/data/recipes.json';
|
$pdo = require_database();
|
||||||
$json = json_encode($recipes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
return hydrate_recipes_from_db($pdo);
|
||||||
return (bool) file_put_contents($path, $json);
|
}
|
||||||
|
|
||||||
|
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'])) {
|
||||||
|
This function handles saving a recipe to the database, including its translations and related items. It uses transactions for atomicity, which is excellent. However, the repeated calls to This function handles saving a recipe to the database, including its translations and related items. It uses transactions for atomicity, which is excellent. However, the repeated calls to `DELETE` for all related tables before inserting new data can be inefficient for updates. If a recipe is updated frequently, consider an `UPSERT` approach or more targeted updates for related data rather than full deletes and re-inserts.
|
|||||||
|
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
|
||||||
|
Clearing all other featured recipes when a new one is marked as featured is a good logic for ensuring only one recipe is featured at a time. This prevents ambiguity. Clearing all other featured recipes when a new one is marked as featured is a good logic for ensuring only one recipe is featured at a time. This prevents ambiguity.
|
|||||||
|
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 (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
These delete statements are executed for every save, even if the data hasn't changed. This can be inefficient. If the goal is to update, consider only deleting/inserting what has changed, or using These delete statements are executed for every save, even if the data hasn't changed. This can be inefficient. If the goal is to update, consider only deleting/inserting what has changed, or using `ON CONFLICT` clauses where applicable in the `INSERT` statements for related tables if the database supports it.
|
|||||||
|
$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) {
|
||||||
|
Hardcoding languages Hardcoding languages `en` and `de` might become problematic if more languages are added. It would be more maintainable to fetch the list of supported languages from a configuration or a dedicated table if the application grows.
|
|||||||
|
$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());
|
||||||
|
The ternary operator The ternary operator `($timerRaw !== '' && is_numeric($timerRaw)) ? (int) $timerRaw : null;` correctly handles non-numeric or empty timer values. This is good input sanitization.
|
|||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
require __DIR__ . '/helpers.php';
|
require __DIR__ . '/helpers.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$allRecipes = load_recipes();
|
||||||
|
Loading all recipes on every page request might be inefficient if the recipe list is very large and rarely changes. Consider caching the recipe data if performance becomes an issue, especially for pages like Loading all recipes on every page request might be inefficient if the recipe list is very large and rarely changes. Consider caching the recipe data if performance becomes an issue, especially for pages like `index.php` and `login.php` where the full list might not be immediately necessary for rendering.
|
|||||||
|
} catch (DatabaseUnavailableException $e) {
|
||||||
|
handle_database_unavailable($e);
|
||||||
|
}
|
||||||
|
|
||||||
// Language & inputs
|
// Language & inputs
|
||||||
$lang = strtolower((string) (filter_input(INPUT_GET, 'lang', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?: 'en')) === 'de' ? 'de' : 'en';
|
$lang = strtolower((string) (filter_input(INPUT_GET, 'lang', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?: 'en')) === 'de' ? 'de' : 'en';
|
||||||
$q = trim((string) (filter_input(INPUT_GET, 'q', FILTER_UNSAFE_RAW) ?? ''));
|
$q = trim((string) (filter_input(INPUT_GET, 'q', FILTER_UNSAFE_RAW) ?? ''));
|
||||||
@@ -84,7 +90,6 @@ $copy = [
|
|||||||
$t = $copy[$lang];
|
$t = $copy[$lang];
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
$allRecipes = load_recipes();
|
|
||||||
$localizedAll = localize_recipes($allRecipes, $lang);
|
$localizedAll = localize_recipes($allRecipes, $lang);
|
||||||
$recipes = filter_recipes($allRecipes, $q ?: null, $tag ?: null, $lang);
|
$recipes = filter_recipes($allRecipes, $q ?: null, $tag ?: null, $lang);
|
||||||
$recipes = array_values(array_filter($recipes, fn($r) => empty($r['coming_soon'])));
|
$recipes = array_values(array_filter($recipes, fn($r) => empty($r['coming_soon'])));
|
||||||
@@ -920,43 +925,38 @@ $recipesJson = json_encode(array_values(array_map(function($r) use ($lang) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hook into auth state
|
function filterRecipesByGoal(goal) {
|
||||||
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;
|
var filtered = indexRecipeBank;
|
||||||
|
|
||||||
if (goal === 'weight_loss') {
|
if (goal === 'weight_loss') {
|
||||||
filtered = indexRecipeBank.filter(r =>
|
filtered = indexRecipeBank.filter(function(r) {
|
||||||
r.tags.some(t => t.toLowerCase().includes('low-carb') || t.toLowerCase().includes('diet')) ||
|
return r.tags.some(function(t) {
|
||||||
(r.category && r.category.toLowerCase().includes('salad'))
|
var lower = t.toLowerCase();
|
||||||
);
|
return lower.includes('low-carb') || lower.includes('diet');
|
||||||
|
}) || (r.category && r.category.toLowerCase().includes('salad'));
|
||||||
|
});
|
||||||
} else if (goal === 'muscle_gain') {
|
} else if (goal === 'muscle_gain') {
|
||||||
filtered = indexRecipeBank.filter(r =>
|
filtered = indexRecipeBank.filter(function(r) {
|
||||||
r.tags.some(t => t.toLowerCase().includes('high-protein') || t.toLowerCase().includes('meat') || t.toLowerCase().includes('fleisch'))
|
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') {
|
} else if (goal === 'healthy') {
|
||||||
filtered = indexRecipeBank.filter(r =>
|
filtered = indexRecipeBank.filter(function(r) {
|
||||||
r.tags.some(t => t.toLowerCase().includes('vegetarian') || t.toLowerCase().includes('healthy'))
|
return r.tags.some(function(t) {
|
||||||
);
|
var lower = t.toLowerCase();
|
||||||
|
return lower.includes('vegetarian') || lower.includes('healthy');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback if empty
|
|
||||||
if (filtered.length === 0) filtered = indexRecipeBank;
|
if (filtered.length === 0) filtered = indexRecipeBank;
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
renderSwipeCarousel(filtered, i18nGoal[goal] || null);
|
window.addEventListener('DOMContentLoaded', function() {
|
||||||
}
|
var goal = window.fcLocal ? window.fcLocal.getGoal() : '';
|
||||||
});
|
if (goal) {
|
||||||
} else {
|
renderSwipeCarousel(filterRecipesByGoal(goal), i18nGoal[goal] || null);
|
||||||
// Default unauthenticated view
|
|
||||||
renderSwipeCarousel(indexRecipeBank, null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
renderSwipeCarousel(indexRecipeBank, null);
|
renderSwipeCarousel(indexRecipeBank, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
require __DIR__ . '/helpers.php';
|
require __DIR__ . '/helpers.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$allRecipes = load_recipes();
|
||||||
|
} catch (DatabaseUnavailableException $e) {
|
||||||
|
handle_database_unavailable($e);
|
||||||
|
}
|
||||||
|
|
||||||
$lang = (isset($_GET['lang']) && strtolower($_GET['lang']) === 'de') ? 'de' : 'en';
|
$lang = (isset($_GET['lang']) && strtolower($_GET['lang']) === 'de') ? 'de' : 'en';
|
||||||
|
|
||||||
$copy = [
|
$copy = [
|
||||||
@@ -23,9 +29,9 @@ $copy = [
|
|||||||
'your_goal' => 'Your dietary goal',
|
'your_goal' => 'Your dietary goal',
|
||||||
'saved_recipes' => 'Your Saved Favorites',
|
'saved_recipes' => 'Your Saved Favorites',
|
||||||
'no_favorites' => "You haven't saved any recipes yet. Explore our collection and tap the heart icon!",
|
'no_favorites' => "You haven't saved any recipes yet. Explore our collection and tap the heart icon!",
|
||||||
'logout' => 'Log Out',
|
'save_goal' => 'Save goal',
|
||||||
'err_pass_match' => 'Passwords do not match.',
|
'goal_saved' => 'Dietary goal saved.',
|
||||||
'err_fill_fields'=> 'Please fill in all fields.',
|
'local_note' => 'Favorites and goals are stored in this browser only.',
|
||||||
],
|
],
|
||||||
'de' => [
|
'de' => [
|
||||||
'page_title' => 'Mein Profil & Favoriten | FlixCooks',
|
'page_title' => 'Mein Profil & Favoriten | FlixCooks',
|
||||||
@@ -46,9 +52,9 @@ $copy = [
|
|||||||
'your_goal' => 'Dein Ernährungsziel',
|
'your_goal' => 'Dein Ernährungsziel',
|
||||||
'saved_recipes' => 'Deine gespeicherten Favoriten',
|
'saved_recipes' => 'Deine gespeicherten Favoriten',
|
||||||
|
This function maps internal goal identifiers to user-friendly labels. It's a clean way to handle translations and display logic. This function maps internal goal identifiers to user-friendly labels. It's a clean way to handle translations and display logic.
|
|||||||
'no_favorites' => "Du hast noch keine Rezepte gespeichert. Entdecke unsere Küche und klicke auf das Herz-Symbol!",
|
'no_favorites' => "Du hast noch keine Rezepte gespeichert. Entdecke unsere Küche und klicke auf das Herz-Symbol!",
|
||||||
'logout' => 'Abmelden',
|
'save_goal' => 'Ziel speichern',
|
||||||
'err_pass_match' => 'Die Passwörter stimmen nicht überein.',
|
'goal_saved' => 'Ernährungsziel gespeichert.',
|
||||||
'err_fill_fields'=> 'Bitte fülle alle Felder aus.',
|
'local_note' => 'Favoriten und Ziele werden nur in diesem Browser gespeichert.',
|
||||||
]
|
]
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -56,10 +62,6 @@ $t = $copy[$lang];
|
|||||||
$pageTitle = $t['page_title'];
|
$pageTitle = $t['page_title'];
|
||||||
$description = $t['meta_desc'];
|
$description = $t['meta_desc'];
|
||||||
|
|
||||||
// Check PHP session state
|
|
||||||
$user = $_SESSION['fc_user'] ?? null;
|
|
||||||
|
|
||||||
$allRecipes = load_recipes();
|
|
||||||
$allRecipesLocal = localize_recipes($allRecipes, $lang);
|
$allRecipesLocal = localize_recipes($allRecipes, $lang);
|
||||||
|
|
||||||
// Formatter helper for JS usage
|
// Formatter helper for JS usage
|
||||||
@@ -376,53 +378,18 @@ include __DIR__ . '/partials/header.php';
|
|||||||
<main class="auth-page">
|
<main class="auth-page">
|
||||||
<div class="auth-alert" id="authAlert"></div>
|
<div class="auth-alert" id="authAlert"></div>
|
||||||
|
|
||||||
<!-- ── 1. UNAUTHENTICATED STATE (Login/Register Card) ── -->
|
<div class="profile-shell" id="profileShell" style="display: grid;">
|
||||||
<div class="auth-card" id="authCard" style="display: <?php echo $user ? 'none' : 'block'; ?>;">
|
<aside class="profile-sidebar">
|
||||||
<div class="auth-tabs">
|
<div class="profile-meta">
|
||||||
<button class="auth-tab active" onclick="switchTab('login')"><?php echo $t['login']; ?></button>
|
<p><?php echo $t['welcome']; ?></p>
|
||||||
<button class="auth-tab" onclick="switchTab('signup')"><?php echo $t['signup']; ?></button>
|
<p style="font-size: 0.9rem; color: var(--muted); margin-top: 0.5rem;"><?php echo e($t['local_note']); ?></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login Form -->
|
<form class="auth-form active" id="goalForm" onsubmit="handleSaveGoal(event)" style="margin-top: 1.5rem;">
|
||||||
<form class="auth-form active" id="loginForm" onsubmit="handleLogin(event)">
|
|
||||||
<div class="auth-field">
|
<div class="auth-field">
|
||||||
<label for="loginEmail"><?php echo $t['email']; ?></label>
|
<label for="profileGoal"><?php echo $t['goal']; ?></label>
|
||||||
<input type="email" id="loginEmail" placeholder="you@example.com" required autocomplete="username">
|
<select id="profileGoal" required>
|
||||||
</div>
|
<option value="" disabled><?php echo $t['goal_placeholder']; ?></option>
|
||||||
<div class="auth-field">
|
|
||||||
<label for="loginPassword"><?php echo $t['password']; ?></label>
|
|
||||||
<input type="password" id="loginPassword" placeholder="••••••••" required autocomplete="current-password">
|
|
||||||
</div>
|
|
||||||
<div class="auth-btn-row">
|
|
||||||
<button class="btn-reveal" type="submit">
|
|
||||||
<span class="btn-reveal-text"><?php echo $t['login']; ?></span>
|
|
||||||
<span class="spinner"></span>
|
|
||||||
<span class="btn-reveal-arrow">→</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="auth-switch-text">
|
|
||||||
<?php echo $t['no_account']; ?> <button type="button" onclick="switchTab('signup')"><?php echo $t['signup']; ?></button>
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Sign-up Form -->
|
|
||||||
<form class="auth-form" id="signupForm" onsubmit="handleSignup(event)">
|
|
||||||
<div class="auth-field">
|
|
||||||
<label for="signupEmail"><?php echo $t['email']; ?></label>
|
|
||||||
<input type="email" id="signupEmail" placeholder="you@example.com" required autocomplete="username">
|
|
||||||
</div>
|
|
||||||
<div class="auth-field">
|
|
||||||
<label for="signupPassword"><?php echo $t['password']; ?></label>
|
|
||||||
<input type="password" id="signupPassword" placeholder="••••••••" required autocomplete="new-password">
|
|
||||||
</div>
|
|
||||||
<div class="auth-field">
|
|
||||||
<label for="signupConfirm"><?php echo $t['confirm_pass']; ?></label>
|
|
||||||
<input type="password" id="signupConfirm" placeholder="••••••••" required autocomplete="new-password">
|
|
||||||
</div>
|
|
||||||
<div class="auth-field">
|
|
||||||
<label for="signupGoal"><?php echo $t['goal']; ?></label>
|
|
||||||
<select id="signupGoal" required>
|
|
||||||
<option value="" disabled selected><?php echo $t['goal_placeholder']; ?></option>
|
|
||||||
<option value="weight_loss"><?php echo $t['goal_loose']; ?></option>
|
<option value="weight_loss"><?php echo $t['goal_loose']; ?></option>
|
||||||
<option value="muscle_gain"><?php echo $t['goal_gain']; ?></option>
|
<option value="muscle_gain"><?php echo $t['goal_gain']; ?></option>
|
||||||
<option value="healthy"><?php echo $t['goal_healthy']; ?></option>
|
<option value="healthy"><?php echo $t['goal_healthy']; ?></option>
|
||||||
@@ -430,41 +397,18 @@ include __DIR__ . '/partials/header.php';
|
|||||||
</div>
|
</div>
|
||||||
<div class="auth-btn-row">
|
<div class="auth-btn-row">
|
||||||
<button class="btn-reveal" type="submit">
|
<button class="btn-reveal" type="submit">
|
||||||
<span class="btn-reveal-text"><?php echo $t['signup']; ?></span>
|
<span class="btn-reveal-text"><?php echo $t['save_goal']; ?></span>
|
||||||
<span class="spinner"></span>
|
|
||||||
<span class="btn-reveal-arrow">→</span>
|
<span class="btn-reveal-arrow">→</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="auth-switch-text">
|
|
||||||
<?php echo $t['have_account']; ?> <button type="button" onclick="switchTab('login')"><?php echo $t['login']; ?></button>
|
|
||||||
</p>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── 2. AUTHENTICATED STATE (Dashboard & Favorites) ── -->
|
<div class="profile-details" style="margin-top: 1.5rem;">
|
||||||
<div class="profile-shell" id="profileShell" style="display: <?php echo $user ? 'grid' : 'none'; ?>;">
|
|
||||||
<aside class="profile-sidebar">
|
|
||||||
<div style="display: flex; align-items: center; gap: 1.5rem;">
|
|
||||||
<div class="profile-avatar" id="avatarChar">
|
|
||||||
<?php echo $user ? strtoupper(substr($user['email'], 0, 1)) : ''; ?>
|
|
||||||
</div>
|
|
||||||
<div class="profile-meta">
|
|
||||||
<p><?php echo $t['welcome']; ?></p>
|
|
||||||
<h2 id="profileEmailVal"><?php echo $user ? e($user['email']) : ''; ?></h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="profile-details">
|
|
||||||
<div class="profile-stat-box">
|
<div class="profile-stat-box">
|
||||||
<span class="profile-stat-label"><?php echo $t['your_goal']; ?></span>
|
<span class="profile-stat-label"><?php echo $t['your_goal']; ?></span>
|
||||||
<span class="profile-stat-val" id="profileGoalVal">-</span>
|
<span class="profile-stat-val" id="profileGoalVal">-</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn-reveal" onclick="handleLogout()" style="margin-top: 1rem; border-color: rgba(0,0,0,0.15);">
|
|
||||||
<span class="btn-reveal-text"><?php echo $t['logout']; ?></span>
|
|
||||||
<span class="btn-reveal-arrow">→</span>
|
|
||||||
</button>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="profile-main">
|
<section class="profile-main">
|
||||||
@@ -487,135 +431,41 @@ include __DIR__ . '/partials/header.php';
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Expose recipe bank to JavaScript
|
|
||||||
const recipeBank = <?php echo $recipesJson; ?>;
|
const recipeBank = <?php echo $recipesJson; ?>;
|
||||||
const langText = <?php echo json_encode($t); ?>;
|
const langText = <?php echo json_encode($t); ?>;
|
||||||
|
|
||||||
function switchTab(tab) {
|
function goalLabel(goal) {
|
||||||
const tabs = document.querySelectorAll('.auth-tab');
|
if (goal === 'weight_loss') return langText.goal_loose;
|
||||||
const forms = document.querySelectorAll('.auth-form');
|
if (goal === 'muscle_gain') return langText.goal_gain;
|
||||||
const alertEl = document.getElementById('authAlert');
|
if (goal === 'healthy') return langText.goal_healthy;
|
||||||
alertEl.style.display = 'none';
|
return '-';
|
||||||
|
|
||||||
if (tab === 'login') {
|
|
||||||
tabs[0].classList.add('active');
|
|
||||||
tabs[1].classList.remove('active');
|
|
||||||
forms[0].classList.add('active');
|
|
||||||
forms[1].classList.remove('active');
|
|
||||||
} else {
|
|
||||||
tabs[0].classList.remove('active');
|
|
||||||
tabs[1].classList.add('active');
|
|
||||||
forms[0].classList.remove('active');
|
|
||||||
forms[1].classList.add('active');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAlert(message) {
|
function showAlert(message) {
|
||||||
const alertEl = document.getElementById('authAlert');
|
const alertEl = document.getElementById('authAlert');
|
||||||
alertEl.textContent = message;
|
alertEl.textContent = message;
|
||||||
alertEl.style.display = 'block';
|
alertEl.style.display = 'block';
|
||||||
// Scroll to alert smoothly
|
|
||||||
window.scrollTo({ top: alertEl.offsetTop - 120, behavior: 'smooth' });
|
window.scrollTo({ top: alertEl.offsetTop - 120, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogin(e) {
|
function handleSaveGoal(e) {
|
||||||
|
This check ensures that favorite rendering only proceeds if This check ensures that favorite rendering only proceeds if `window.fcLocal` is available, preventing potential errors. This is good defensive programming.
This check prevents errors if This check prevents errors if `window.fcLocal` isn't defined, ensuring the script doesn't crash. It's good defensive programming.
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const email = document.getElementById('loginEmail').value;
|
if (!window.fcLocal) return;
|
||||||
const pass = document.getElementById('loginPassword').value;
|
const goal = document.getElementById('profileGoal').value;
|
||||||
const btn = e.target.querySelector('button[type="submit"]');
|
window.fcLocal.setGoal(goal);
|
||||||
const alertEl = document.getElementById('authAlert');
|
document.getElementById('profileGoalVal').textContent = goalLabel(goal);
|
||||||
|
showAlert(langText.goal_saved);
|
||||||
alertEl.style.display = 'none';
|
|
||||||
btn.disabled = true;
|
|
||||||
|
|
||||||
window.auth.signInWithEmailAndPassword(email, pass)
|
|
||||||
.then(userCredential => {
|
|
||||||
// Sync happens automatically via onAuthStateChanged observer in head.php!
|
|
||||||
// The observer will reload the page when it receives the new state
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
btn.disabled = false;
|
|
||||||
let errMsg = error.message;
|
|
||||||
if (error.code === 'auth/wrong-password' || error.code === 'auth/user-not-found') {
|
|
||||||
errMsg = "Invalid email or password.";
|
|
||||||
}
|
|
||||||
showAlert(errMsg);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
This function dynamically renders the user's favorite recipes. It correctly handles cases where This function dynamically renders the user's favorite recipes. It correctly handles cases where `window.fcLocal` is not available or when there are no favorites.
|
|||||||
|
|
||||||
function handleSignup(e) {
|
function renderFavorites() {
|
||||||
e.preventDefault();
|
|
||||||
const email = document.getElementById('signupEmail').value;
|
|
||||||
const pass = document.getElementById('signupPassword').value;
|
|
||||||
const confirm = document.getElementById('signupConfirm').value;
|
|
||||||
const goal = document.getElementById('signupGoal').value;
|
|
||||||
const btn = e.target.querySelector('button[type="submit"]');
|
|
||||||
const alertEl = document.getElementById('authAlert');
|
|
||||||
|
|
||||||
alertEl.style.display = 'none';
|
|
||||||
|
|
||||||
if (pass !== confirm) {
|
|
||||||
showAlert(langText.err_pass_match);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
btn.disabled = true;
|
|
||||||
|
|
||||||
window.auth.createUserWithEmailAndPassword(email, pass)
|
|
||||||
.then(userCredential => {
|
|
||||||
const user = userCredential.user;
|
|
||||||
// Save profile details to Firestore
|
|
||||||
return window.db.collection('users').doc(user.uid).set({
|
|
||||||
email: email,
|
|
||||||
goal: goal,
|
|
||||||
createdAt: firebase.firestore.FieldValue.serverTimestamp()
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// Sync is handled by observer in head.php!
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
btn.disabled = false;
|
|
||||||
showAlert(error.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLogout() {
|
|
||||||
window.auth.signOut()
|
|
||||||
.then(() => {
|
|
||||||
// Sync observer takes care of PHP session termination and reloads page
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Profile and Favorites dashboard renderer ──
|
|
||||||
function initDashboard(user) {
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
const goalVal = document.getElementById('profileGoalVal');
|
|
||||||
const grid = document.getElementById('favoritesGrid');
|
const grid = document.getElementById('favoritesGrid');
|
||||||
const emptyState = document.getElementById('favoritesEmpty');
|
const emptyState = document.getElementById('favoritesEmpty');
|
||||||
|
if (!window.fcLocal) return;
|
||||||
|
|
||||||
// Fetch user profile from Firestore
|
const slugs = window.fcLocal.getFavorites();
|
||||||
window.db.collection('users').doc(user.uid).get()
|
|
||||||
.then(doc => {
|
|
||||||
if (doc.exists) {
|
|
||||||
const data = doc.data();
|
|
||||||
let goalString = '-';
|
|
||||||
if (data.goal === 'weight_loss') goalString = langText.goal_loose;
|
|
||||||
else if (data.goal === 'muscle_gain') goalString = langText.goal_gain;
|
|
||||||
else if (data.goal === 'healthy') goalString = langText.goal_healthy;
|
|
||||||
|
|
||||||
goalVal.textContent = goalString;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => console.error("Error loading profile: ", err));
|
|
||||||
|
|
||||||
// Load Saved Favorites
|
|
||||||
window.db.collection('user_favorites').where('userId', '==', user.uid).get()
|
|
||||||
.then(snapshot => {
|
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
|
|
||||||
if (snapshot.empty) {
|
if (slugs.length === 0) {
|
||||||
grid.style.display = 'none';
|
grid.style.display = 'none';
|
||||||
emptyState.style.display = 'block';
|
emptyState.style.display = 'block';
|
||||||
return;
|
return;
|
||||||
@@ -624,58 +474,38 @@ function initDashboard(user) {
|
|||||||
emptyState.style.display = 'none';
|
emptyState.style.display = 'none';
|
||||||
grid.style.display = 'grid';
|
grid.style.display = 'grid';
|
||||||
|
|
||||||
snapshot.forEach(doc => {
|
slugs.forEach(function(slug) {
|
||||||
const fav = doc.data();
|
const recipe = recipeBank.find(function(r) { return r.slug === slug; });
|
||||||
const recipe = recipeBank.find(r => r.slug === fav.recipeSlug);
|
if (!recipe) return;
|
||||||
|
|
||||||
if (recipe) {
|
|
||||||
const card = document.createElement('article');
|
const card = document.createElement('article');
|
||||||
card.className = 'card card--bare reveal-target revealed';
|
card.className = 'card card--bare reveal-target revealed';
|
||||||
card.innerHTML = `
|
card.innerHTML =
|
||||||
<a href="${recipe.url}" class="card__image">
|
'<a href="' + recipe.url + '" class="card__image">' +
|
||||||
<img src="${recipe.hero}" alt="${recipe.title}" loading="lazy">
|
'<img src="' + recipe.hero + '" alt="' + recipe.title + '" loading="lazy">' +
|
||||||
</a>
|
'</a>' +
|
||||||
<div class="card__body">
|
'<div class="card__body">' +
|
||||||
<h4><a href="${recipe.url}">${recipe.title}</a></h4>
|
'<h4><a href="' + recipe.url + '">' + recipe.title + '</a></h4>' +
|
||||||
<div class="meta">
|
'<div class="meta">' +
|
||||||
<span>⏱ ${recipe.total_time} min</span>
|
'<span>⏱ ' + recipe.total_time + ' min</span>' +
|
||||||
<span>🙂 ${recipe.difficulty}</span>
|
'<span>🙂 ' + recipe.difficulty + '</span>' +
|
||||||
</div>
|
'</div>' +
|
||||||
</div>
|
'</div>';
|
||||||
`;
|
|
||||||
grid.appendChild(card);
|
grid.appendChild(card);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// If none of the favorite slugs matched recipes in current language:
|
|
||||||
if (grid.children.length === 0) {
|
if (grid.children.length === 0) {
|
||||||
grid.style.display = 'none';
|
grid.style.display = 'none';
|
||||||
emptyState.style.display = 'block';
|
emptyState.style.display = 'block';
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error("Error fetching favorites: ", err);
|
|
||||||
grid.innerHTML = '<p>Error loading favorites. Please try again.</p>';
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe auth state for dashboard renderer
|
window.addEventListener('DOMContentLoaded', function() {
|
||||||
window.addEventListener('DOMContentLoaded', () => {
|
if (!window.fcLocal) return;
|
||||||
window.auth.onAuthStateChanged(user => {
|
const goal = window.fcLocal.getGoal();
|
||||||
const authCard = document.getElementById('authCard');
|
const select = document.getElementById('profileGoal');
|
||||||
const profileShell = document.getElementById('profileShell');
|
if (goal && select) select.value = goal;
|
||||||
|
document.getElementById('profileGoalVal').textContent = goalLabel(goal);
|
||||||
if (user) {
|
renderFavorites();
|
||||||
document.getElementById('profileEmailVal').textContent = user.email;
|
|
||||||
document.getElementById('avatarChar').textContent = user.email.substr(0,1).toUpperCase();
|
|
||||||
authCard.style.display = 'none';
|
|
||||||
profileShell.style.display = 'grid';
|
|
||||||
initDashboard(user);
|
|
||||||
} else {
|
|
||||||
authCard.style.display = 'block';
|
|
||||||
profileShell.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FlixCooks – Datenbank nicht verfügbar</title>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
Including a general stylesheet like Including a general stylesheet like `/assets/style.css` on a maintenance page is good for consistent branding and layout. Ensure this file is available and correctly linked even when the main application might be having issues.
|
|||||||
|
<style>
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.db-error-card {
|
||||||
|
max-width: 32rem;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: var(--radius, 16px);
|
||||||
|
border: 1px solid var(--stroke, rgba(255,255,255,0.12));
|
||||||
|
background: var(--surface-2, rgba(255,255,255,0.04));
|
||||||
|
}
|
||||||
|
.db-error-card h1 { margin-top: 0; }
|
||||||
|
.db-error-card code {
|
||||||
|
display: block;
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: left;
|
||||||
|
word-break: break-all;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="db-error-card">
|
||||||
|
<h1>Datenbank nicht verfügbar</h1>
|
||||||
|
<p>FlixCooks benötigt eine laufende PostgreSQL-Verbindung. Ohne Datenbank werden keine Rezepte angezeigt.</p>
|
||||||
|
<?php if (!empty($dbError)): ?>
|
||||||
|
Displaying the specific Displaying the specific `$dbError` message within a `<code>` block is excellent for debugging and providing actionable information to the user about what went wrong.
|
|||||||
|
<code><?php echo htmlspecialchars($dbError, ENT_QUOTES, 'UTF-8'); ?></code>
|
||||||
|
<?php endif; ?>
|
||||||
|
<p><strong>Lokal prüfen:</strong></p>
|
||||||
|
<ol style="text-align: left; margin: 0 auto; max-width: 22rem;">
|
||||||
|
Providing explicit, step-by-step instructions for local debugging on a maintenance page is a very user-friendly and helpful practice. This significantly aids users in resolving common local setup issues. Providing explicit, step-by-step instructions for local debugging on a maintenance page is a very user-friendly and helpful practice. This significantly aids users in resolving common local setup issues.
|
|||||||
|
<li><code>DATABASE_URL</code> in <code>.env</code> setzen</li>
|
||||||
|
<li><code>docker compose -f docker-compose.dev.yml up -d</code></li>
|
||||||
|
<li><code>php scripts/db-check.php</code></li>
|
||||||
|
<li>Leere DB: <code>php scripts/db-seed.php</code></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -61,37 +61,23 @@
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
// ── Newsletter subscription logic ──────────────────────────
|
// ── Newsletter (mailto) ────────────────────────────────────
|
||||||
function handleSubscribe(e) {
|
function handleSubscribe(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var emailInput = document.getElementById('newsletterEmail');
|
var emailInput = document.getElementById('newsletterEmail');
|
||||||
var feedback = document.getElementById('newsletterFeedback');
|
var feedback = document.getElementById('newsletterFeedback');
|
||||||
var form = document.getElementById('newsletterForm');
|
var form = document.getElementById('newsletterForm');
|
||||||
var btn = form.querySelector('button');
|
|
||||||
var email = emailInput.value.trim();
|
var email = emailInput.value.trim();
|
||||||
|
|
||||||
if (!email) return;
|
if (!email) return;
|
||||||
|
|
||||||
btn.disabled = true;
|
var subject = encodeURIComponent('FlixCooks Newsletter');
|
||||||
feedback.className = 'newsletter-feedback';
|
var body = encodeURIComponent('Please add me to the newsletter: ' + email);
|
||||||
feedback.textContent = '';
|
window.location.href = 'mailto:<?php echo e($contactEmail); ?>?subject=' + subject + '&body=' + body;
|
||||||
|
|
||||||
window.db.collection('subscribers').doc(email.toLowerCase()).set({
|
|
||||||
email: email.toLowerCase(),
|
|
||||||
subscribedAt: firebase.firestore.FieldValue.serverTimestamp(),
|
|
||||||
status: 'active'
|
|
||||||
})
|
|
||||||
.then(function() {
|
|
||||||
form.classList.add('success');
|
form.classList.add('success');
|
||||||
emailInput.disabled = true;
|
|
||||||
feedback.className = 'newsletter-feedback success';
|
feedback.className = 'newsletter-feedback success';
|
||||||
feedback.textContent = '<?php echo $langCode === "de" ? "Erfolgreich abonniert!" : "Successfully subscribed!"; ?>';
|
feedback.textContent = '<?php echo $langCode === "de" ? "E-Mail-Programm geöffnet – bitte absenden." : "Email client opened — please send."; ?>';
|
||||||
})
|
|
||||||
.catch(function(error) {
|
|
||||||
btn.disabled = false;
|
|
||||||
feedback.className = 'newsletter-feedback error';
|
|
||||||
feedback.textContent = '<?php echo $langCode === "de" ? "Fehler: " : "Error: "; ?>' + error.message;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Guest Modal Logic ──────────────────────────────────────
|
// ── Guest Modal Logic ──────────────────────────────────────
|
||||||
@@ -121,33 +107,12 @@ document.addEventListener('click', function(e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Favorites Toggle & Management ───────────────────────────
|
// ── Favorites (localStorage) ───────────────────────────────
|
||||||
var currentUser = null;
|
|
||||||
var userFavSlugs = new Set();
|
|
||||||
|
|
||||||
function syncFavorites(uid) {
|
|
||||||
window.db.collection('user_favorites').where('userId', '==', uid).get()
|
|
||||||
.then(function(snapshot) {
|
|
||||||
userFavSlugs.clear();
|
|
||||||
snapshot.forEach(function(doc) {
|
|
||||||
userFavSlugs.add(doc.data().recipeSlug);
|
|
||||||
});
|
|
||||||
updateHeartStates();
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
console.error("Error syncing favorites: ", err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearFavorites() {
|
|
||||||
userFavSlugs.clear();
|
|
||||||
updateHeartStates();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateHeartStates() {
|
function updateHeartStates() {
|
||||||
|
if (!window.fcLocal) return;
|
||||||
document.querySelectorAll('.btn-favorite').forEach(function(btn) {
|
document.querySelectorAll('.btn-favorite').forEach(function(btn) {
|
||||||
var slug = btn.getAttribute('data-slug');
|
var slug = btn.getAttribute('data-slug');
|
||||||
if (userFavSlugs.has(slug)) {
|
if (slug && window.fcLocal.hasFavorite(slug)) {
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
} else {
|
} else {
|
||||||
btn.classList.remove('active');
|
btn.classList.remove('active');
|
||||||
@@ -156,43 +121,12 @@ function updateHeartStates() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleFavorite(slug, btn) {
|
function toggleFavorite(slug, btn) {
|
||||||
if (!currentUser) {
|
if (!window.fcLocal) {
|
||||||
openGuestModal();
|
openGuestModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
window.fcLocal.toggleFavorite(slug);
|
||||||
var docId = currentUser.uid + '_' + slug;
|
updateHeartStates();
|
||||||
var docRef = window.db.collection('user_favorites').doc(docId);
|
|
||||||
|
|
||||||
if (userFavSlugs.has(slug)) {
|
|
||||||
// Optimistic UI update
|
|
||||||
userFavSlugs.delete(slug);
|
|
||||||
btn.classList.remove('active');
|
|
||||||
|
|
||||||
docRef.delete()
|
|
||||||
.catch(function(err) {
|
|
||||||
console.error("Error removing favorite: ", err);
|
|
||||||
// Rollback
|
|
||||||
userFavSlugs.add(slug);
|
|
||||||
btn.classList.add('active');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Optimistic UI update
|
|
||||||
userFavSlugs.add(slug);
|
|
||||||
btn.classList.add('active');
|
|
||||||
|
|
||||||
docRef.set({
|
|
||||||
userId: currentUser.uid,
|
|
||||||
recipeSlug: slug,
|
|
||||||
createdAt: firebase.firestore.FieldValue.serverTimestamp()
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
console.error("Error adding favorite: ", err);
|
|
||||||
// Rollback
|
|
||||||
userFavSlugs.delete(slug);
|
|
||||||
btn.classList.remove('active');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hook up event listeners to all favorite buttons dynamically
|
// Hook up event listeners to all favorite buttons dynamically
|
||||||
@@ -206,18 +140,8 @@ document.addEventListener('click', function(e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup Auth state observer for favorites
|
|
||||||
window.addEventListener('DOMContentLoaded', function() {
|
window.addEventListener('DOMContentLoaded', function() {
|
||||||
if (window.auth) {
|
updateHeartStates();
|
||||||
window.auth.onAuthStateChanged(function(user) {
|
|
||||||
currentUser = user;
|
|
||||||
if (user) {
|
|
||||||
syncFavorites(user.uid);
|
|
||||||
} else {
|
|
||||||
clearFavorites();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
@@ -228,10 +152,10 @@ window.addEventListener('DOMContentLoaded', function() {
|
|||||||
<button class="guest-modal-close" onclick="closeGuestModal()" aria-label="Close modal">×</button>
|
<button class="guest-modal-close" onclick="closeGuestModal()" aria-label="Close modal">×</button>
|
||||||
<div class="guest-modal-icon">❤️</div>
|
<div class="guest-modal-icon">❤️</div>
|
||||||
<h3><?php echo $langCode === 'de' ? 'Lieblingsrezept speichern' : 'Save your favorite recipe'; ?></h3>
|
<h3><?php echo $langCode === 'de' ? 'Lieblingsrezept speichern' : 'Save your favorite recipe'; ?></h3>
|
||||||
<p><?php echo $langCode === 'de' ? 'Erstelle ein kostenloses Konto oder melde dich an, um Rezepte in deinen Favoriten zu speichern und deine Ernährungsziele zu verwalten.' : 'Create a free account or login to save recipes to your favorites and manage your dietary goals.'; ?></p>
|
<p><?php echo $langCode === 'de' ? 'Speichere Rezepte lokal in deinem Browser und verwalte dein Ernährungsziel im Profil.' : 'Save recipes locally in your browser and manage your dietary goal in your profile.'; ?></p>
|
||||||
<div class="guest-modal-actions">
|
<div class="guest-modal-actions">
|
||||||
<a class="btn-reveal" href="/login.php<?php echo $langSuffix; ?>">
|
<a class="btn-reveal" href="/login.php<?php echo $langSuffix; ?>">
|
||||||
<span class="btn-reveal-text"><?php echo $langCode === 'de' ? 'Jetzt Anmelden' : 'Login / Register'; ?></span>
|
<span class="btn-reveal-text"><?php echo $langCode === 'de' ? 'Zum Profil' : 'Go to profile'; ?></span>
|
||||||
<span class="btn-reveal-arrow">→</span>
|
<span class="btn-reveal-arrow">→</span>
|
||||||
</a>
|
</a>
|
||||||
<button class="underline-link" onclick="closeGuestModal()"><?php echo $langCode === 'de' ? 'Später' : 'Maybe later'; ?></button>
|
<button class="underline-link" onclick="closeGuestModal()"><?php echo $langCode === 'de' ? 'Später' : 'Maybe later'; ?></button>
|
||||||
|
|||||||
@@ -19,49 +19,7 @@ $description = $description ?? 'Seasonal recipes, tested tips, and approachable
|
|||||||
|
|
||||||
<!-- Main stylesheet -->
|
<!-- Main stylesheet -->
|
||||||
<link rel="stylesheet" href="/assets/style.css">
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
<script src="/assets/fc-local.js" defer></script>
|
||||||
<!-- Firebase v10 compat SDKs -->
|
|
||||||
<script src="https://www.gstatic.com/firebasejs/10.8.0/firebase-app-compat.js"></script>
|
|
||||||
<script src="https://www.gstatic.com/firebasejs/10.8.0/firebase-auth-compat.js"></script>
|
|
||||||
<script src="https://www.gstatic.com/firebasejs/10.8.0/firebase-firestore-compat.js"></script>
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
var config = <?php echo json_encode(get_firebase_config()); ?>;
|
|
||||||
firebase.initializeApp(config);
|
|
||||||
window.auth = firebase.auth();
|
|
||||||
window.db = firebase.firestore();
|
|
||||||
|
|
||||||
// Auto-sync PHP Session with Firebase Auth State
|
|
||||||
window.auth.onAuthStateChanged(function(user) {
|
|
||||||
var phpUser = <?php echo isset($_SESSION['fc_user']) ? json_encode($_SESSION['fc_user']) : 'null'; ?>;
|
|
||||||
if (user) {
|
|
||||||
if (!phpUser || phpUser.uid !== user.uid) {
|
|
||||||
user.getIdToken().then(function(token) {
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', '/api/session.php', true);
|
|
||||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
|
||||||
xhr.send('action=login&uid=' + encodeURIComponent(user.uid) + '&email=' + encodeURIComponent(user.email) + '&token=' + encodeURIComponent(token));
|
|
||||||
xhr.onload = function() {
|
|
||||||
if (xhr.status === 200) {
|
|
||||||
if (!phpUser) { window.location.reload(); }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (phpUser) {
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', '/api/session.php', true);
|
|
||||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
|
||||||
xhr.send('action=logout');
|
|
||||||
xhr.onload = function() {
|
|
||||||
window.location.reload();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- GSAP + ScrollTrigger (CDN) -->
|
<!-- GSAP + ScrollTrigger (CDN) -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ $langParamAmp = isset($lang) && $lang === 'de' ? '&lang=de' : '';
|
|||||||
<li>
|
<li>
|
||||||
<a href="/login.php<?php echo $langParam; ?>" class="slide-hover-link" <?php echo $currentPage === 'login.php' ? 'aria-current="page"' : ''; ?>>
|
<a href="/login.php<?php echo $langParam; ?>" class="slide-hover-link" <?php echo $currentPage === 'login.php' ? 'aria-current="page"' : ''; ?>>
|
||||||
<span class="slide-hover-wrapper">
|
<span class="slide-hover-wrapper">
|
||||||
<span class="slide-text-main"><?php echo isset($_SESSION['fc_user']) ? (isset($lang) && $lang === 'de' ? 'Profil' : 'Profile') : (isset($lang) && $lang === 'de' ? 'Anmelden' : 'Login'); ?></span>
|
<span class="slide-text-main"><?php echo isset($lang) && $lang === 'de' ? 'Profil' : 'Profile'; ?></span>
|
||||||
<span class="slide-text-clone" aria-hidden="true"><?php echo isset($_SESSION['fc_user']) ? (isset($lang) && $lang === 'de' ? 'Profil' : 'Profile') : (isset($lang) && $lang === 'de' ? 'Anmelden' : 'Login'); ?></span>
|
<span class="slide-text-clone" aria-hidden="true"><?php echo isset($lang) && $lang === 'de' ? 'Profil' : 'Profile'; ?></span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
require __DIR__ . '/helpers.php';
|
require __DIR__ . '/helpers.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$allRecipes = load_recipes();
|
||||||
|
} catch (DatabaseUnavailableException $e) {
|
||||||
|
handle_database_unavailable($e);
|
||||||
|
}
|
||||||
|
|
||||||
$lang = (isset($_GET['lang']) && strtolower($_GET['lang']) === 'de') ? 'de' : 'en';
|
$lang = (isset($_GET['lang']) && strtolower($_GET['lang']) === 'de') ? 'de' : 'en';
|
||||||
$copy = [
|
$copy = [
|
||||||
'en' => [
|
'en' => [
|
||||||
@@ -46,9 +52,8 @@ $copy = [
|
|||||||
];
|
];
|
||||||
$t = $copy[$lang];
|
$t = $copy[$lang];
|
||||||
|
|
||||||
$allRecipes = load_recipes();
|
|
||||||
$slug = isset($_GET['slug']) ? trim($_GET['slug']) : '';
|
$slug = isset($_GET['slug']) ? trim($_GET['slug']) : '';
|
||||||
$recipeRaw = $slug ? find_recipe_by_slug($allRecipes, $slug) : null;
|
$recipeRaw = $slug ? load_recipe_by_slug($slug) : null;
|
||||||
$recipe = $recipeRaw ? localize_recipe($recipeRaw, $lang) : null;
|
$recipe = $recipeRaw ? localize_recipe($recipeRaw, $lang) : null;
|
||||||
$allRecipesLocal = localize_recipes($allRecipes, $lang);
|
$allRecipesLocal = localize_recipes($allRecipes, $lang);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Kurzer Verbindungstest zur lokalen Postgres-Instanz.
|
||||||
|
* Aufruf (im Projektroot):
|
||||||
|
* php scripts/db-check.php
|
||||||
|
*/
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers.php';
|
||||||
|
|
||||||
|
if (!extension_loaded('pdo_pgsql')) {
|
||||||
|
fwrite(STDERR, "PHP-Extension pdo_pgsql fehlt. z. B.: sudo apt install php-pgsql\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = getenv('DATABASE_URL');
|
||||||
|
if (!$url) {
|
||||||
|
fwrite(STDERR, "DATABASE_URL ist nicht gesetzt. In .env eintragen (siehe .env.example).\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = require_database();
|
||||||
|
} catch (DatabaseUnavailableException $e) {
|
||||||
|
fwrite(STDERR, $e->getMessage() . "\n");
|
||||||
|
fwrite(STDERR, "Prüfe: Docker läuft? docker compose -f docker-compose.dev.yml ps\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = (int) $pdo->query('SELECT COUNT(*) FROM recipes')->fetchColumn();
|
||||||
|
echo "Verbindung OK. Rezepte in DB: {$count}\n";
|
||||||
|
|
||||||
|
if ($count === 0) {
|
||||||
|
echo "Hinweis: Leere DB – einmalig seeden mit: php scripts/db-seed.php\n";
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->query(
|
||||||
|
"SELECT r.slug, t.title
|
||||||
|
FROM recipes r
|
||||||
|
LEFT JOIN recipe_translations t ON t.recipe_slug = r.slug AND t.lang = 'en'
|
||||||
|
LIMIT 5"
|
||||||
|
);
|
||||||
|
echo "\nBeispiel-Zeilen:\n";
|
||||||
|
while ($row = $stmt->fetch()) {
|
||||||
|
echo " - {$row['slug']}: {$row['title']}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Einmaliger Import aus data/recipes.json in die relationale DB.
|
||||||
|
* Die Website liest zur Laufzeit nur noch aus Postgres.
|
||||||
|
*
|
||||||
|
* php scripts/db-seed.php
|
||||||
|
*/
|
||||||
|
require_once dirname(__DIR__) . '/helpers.php';
|
||||||
|
|
||||||
|
$seedPath = dirname(__DIR__) . '/data/recipes.json';
|
||||||
|
if (!file_exists($seedPath)) {
|
||||||
|
fwrite(STDERR, "Seed-Datei fehlt: data/recipes.json\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = file_get_contents($seedPath);
|
||||||
|
$recipes = json_decode($raw, true);
|
||||||
|
if (!is_array($recipes)) {
|
||||||
|
fwrite(STDERR, "Ungültiges JSON in data/recipes.json\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = require_database();
|
||||||
|
} catch (DatabaseUnavailableException $e) {
|
||||||
|
fwrite(STDERR, $e->getMessage() . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
foreach ($recipes as $recipe) {
|
||||||
|
if (!is_array($recipe) || empty($recipe['slug'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (save_recipe($recipe, $pdo)) {
|
||||||
|
$count++;
|
||||||
|
echo " + {$recipe['slug']}\n";
|
||||||
|
} else {
|
||||||
|
fwrite(STDERR, " ! Fehler bei {$recipe['slug']}\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\nImport abgeschlossen: {$count} Rezepte.\n";
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
-- FlixCooks relational recipe schema (PostgreSQL)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
|
slug VARCHAR(255) PRIMARY KEY,
|
||||||
|
hero TEXT NOT NULL DEFAULT '',
|
||||||
|
prep_time INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cook_time INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_time INTEGER NOT NULL DEFAULT 0,
|
||||||
|
servings INTEGER NOT NULL DEFAULT 2,
|
||||||
|
featured BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
coming_soon BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
calories INTEGER NOT NULL DEFAULT 0,
|
||||||
|
protein INTEGER NOT NULL DEFAULT 0,
|
||||||
|
carbs INTEGER NOT NULL DEFAULT 0,
|
||||||
|
fat INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recipe_translations (
|
||||||
|
recipe_slug VARCHAR(255) NOT NULL REFERENCES recipes(slug) ON DELETE CASCADE,
|
||||||
|
lang CHAR(2) NOT NULL CHECK (lang IN ('en', 'de')),
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
category TEXT NOT NULL DEFAULT '',
|
||||||
|
difficulty TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (recipe_slug, lang)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recipe_tags (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
recipe_slug VARCHAR(255) NOT NULL REFERENCES recipes(slug) ON DELETE CASCADE,
|
||||||
|
lang CHAR(2) NOT NULL CHECK (lang IN ('en', 'de')),
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recipe_tags_slug_lang ON recipe_tags (recipe_slug, lang);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recipe_ingredients (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
recipe_slug VARCHAR(255) NOT NULL REFERENCES recipes(slug) ON DELETE CASCADE,
|
||||||
|
lang CHAR(2) NOT NULL CHECK (lang IN ('en', 'de')),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_slug_lang ON recipe_ingredients (recipe_slug, lang);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recipe_utensils (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
recipe_slug VARCHAR(255) NOT NULL REFERENCES recipes(slug) ON DELETE CASCADE,
|
||||||
|
lang CHAR(2) NOT NULL CHECK (lang IN ('en', 'de')),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recipe_utensils_slug_lang ON recipe_utensils (recipe_slug, lang);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recipe_steps (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
recipe_slug VARCHAR(255) NOT NULL REFERENCES recipes(slug) ON DELETE CASCADE,
|
||||||
|
lang CHAR(2) NOT NULL CHECK (lang IN ('en', 'de')),
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
video_url TEXT NOT NULL DEFAULT '',
|
||||||
|
timer_minutes INTEGER,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recipe_steps_slug_lang ON recipe_steps (recipe_slug, lang);
|
||||||
The default value
change-me-in-productionis a good security practice for development environments. However, ensure that this variable is always overridden in production environments. Consider adding a validation check or a more prominent warning if this default value is detected in a production context, perhaps during the application's startup.