Merge pull request #9 from LordSchmackes/dev
Dev
This commit was merged in pull request #9.
This commit is contained in:
+32
-25
@@ -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
|
||||||
|
|||||||
+119
-25
@@ -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
|
||||||
+10
-8
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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"
|
|
||||||
+37
@@ -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. Start the Development Server
|
### 2. Local Postgres (Docker)
|
||||||
|
|
||||||
|
Für DB-Integration auf einem Dev-Branch – getrennt von Production.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Container starten
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# Warten bis healthy (einmalig prüfen)
|
||||||
|
docker compose -f docker-compose.dev.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
In `.env` (Werte passen zu `docker-compose.dev.yml`):
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL="postgresql://flixcooks:flixcooks_dev@127.0.0.1:5432/flixcooks_dev"
|
||||||
|
```
|
||||||
|
|
||||||
|
**PHP-Extension (WSL/Ubuntu, einmalig):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install php-pgsql
|
||||||
|
# oder passend zur Version: sudo apt install php8.5-pgsql
|
||||||
|
```
|
||||||
|
|
||||||
|
**Datenbank-Shell (zum Lernen / Inspizieren):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it flixcooks-postgres-dev psql -U flixcooks -d flixcooks_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Nützliche SQL-Befehle in `psql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
\dt -- alle Tabellen
|
||||||
|
\d recipes -- Spalten der Tabelle recipes
|
||||||
|
SELECT slug, created_at FROM recipes;
|
||||||
|
SELECT slug, data->>'title' AS title FROM recipes, jsonb_to_record(data) AS x(title text); -- optional
|
||||||
|
\q -- beenden
|
||||||
|
```
|
||||||
|
|
||||||
|
**DB komplett leeren und neu seeden:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml down -v
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
php scripts/db-seed.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verbindung prüfen:** `php scripts/db-check.php`
|
||||||
|
|
||||||
|
Details zum Schema: Abschnitt unten und `scripts/schema.sql`.
|
||||||
|
|
||||||
|
### 3. Start the Development Server
|
||||||
|
|
||||||
#### Option A: PHP Built-in Web Server (Recommended & Easiest)
|
#### 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
$allRecipes = load_recipes();
|
try {
|
||||||
|
$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);
|
||||||
+47
-9
@@ -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
|
||||||
|
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
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd /var/www/html
|
||||||
|
|
||||||
|
echo "[flixcooks] Waiting for database..."
|
||||||
|
TRIES=0
|
||||||
|
MAX_TRIES="${DB_WAIT_MAX_TRIES:-30}"
|
||||||
|
|
||||||
|
until php scripts/db-check.php >/dev/null 2>&1; do
|
||||||
|
TRIES=$((TRIES + 1))
|
||||||
|
if [ "$TRIES" -ge "$MAX_TRIES" ]; then
|
||||||
|
echo "[flixcooks] Database not reachable after ${MAX_TRIES} attempts." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[flixcooks] Applying schema..."
|
||||||
|
php -r "require 'helpers.php'; require_database(); echo \"schema ok\n\";"
|
||||||
|
|
||||||
|
if [ "${RUN_DB_SEED:-false}" = "true" ]; then
|
||||||
|
echo "[flixcooks] Seeding recipes from data/recipes.json..."
|
||||||
|
php scripts/db-seed.php
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[flixcooks] Starting Apache..."
|
||||||
|
exec "$@"
|
||||||
+149
@@ -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 |
|
||||||
|
|
||||||
|
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Container health check (Coolify / Docker HEALTHCHECK).
|
||||||
|
*/
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
}
|
||||||
+362
-12
@@ -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);
|
||||||
|
$dbError = $e->getMessage();
|
||||||
|
include __DIR__ . '/maintenance/db-unavailable.php';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function db_table_has_column(PDO $pdo, string $table, string $column): bool {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$table, $column]);
|
||||||
|
return (bool) $stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply_recipe_schema(PDO $pdo): void {
|
||||||
|
$path = __DIR__ . '/scripts/schema.sql';
|
||||||
if (!file_exists($path)) {
|
if (!file_exists($path)) {
|
||||||
return [];
|
throw new RuntimeException('Missing scripts/schema.sql');
|
||||||
}
|
}
|
||||||
$json = file_get_contents($path);
|
$pdo->exec(file_get_contents($path));
|
||||||
$data = json_decode($json, true);
|
}
|
||||||
if (!is_array($data)) {
|
|
||||||
|
function recipe_empty_i18n(): array {
|
||||||
|
return [
|
||||||
|
'title' => '',
|
||||||
|
'description' => '',
|
||||||
|
'category' => '',
|
||||||
|
'difficulty' => '',
|
||||||
|
'tags' => [],
|
||||||
|
'ingredients' => [],
|
||||||
|
'utensils' => [],
|
||||||
|
'steps' => [],
|
||||||
|
'step_videos' => [],
|
||||||
|
'step_timers' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function recipe_base_from_row(array $row): array {
|
||||||
|
return [
|
||||||
|
'slug' => $row['slug'],
|
||||||
|
'hero' => $row['hero'] ?? '',
|
||||||
|
'prep_time' => (int) ($row['prep_time'] ?? 0),
|
||||||
|
'cook_time' => (int) ($row['cook_time'] ?? 0),
|
||||||
|
'total_time' => (int) ($row['total_time'] ?? 0),
|
||||||
|
'servings' => (int) ($row['servings'] ?? 2),
|
||||||
|
'featured' => (bool) ($row['featured'] ?? false),
|
||||||
|
'coming_soon' => (bool) ($row['coming_soon'] ?? false),
|
||||||
|
'nutrition' => [
|
||||||
|
'calories' => (int) ($row['calories'] ?? 0),
|
||||||
|
'protein' => (int) ($row['protein'] ?? 0),
|
||||||
|
'carbs' => (int) ($row['carbs'] ?? 0),
|
||||||
|
'fat' => (int) ($row['fat'] ?? 0),
|
||||||
|
],
|
||||||
|
'i18n' => [
|
||||||
|
'en' => recipe_empty_i18n(),
|
||||||
|
'de' => recipe_empty_i18n(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode_legacy_jsonb_value($value): ?array {
|
||||||
|
if (is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
if (is_string($value)) {
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
if ($value !== null) {
|
||||||
|
$decoded = json_decode(json_encode($value), true);
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrate_legacy_jsonb_storage(PDO $pdo): void {
|
||||||
|
if (!db_table_has_column($pdo, 'recipes', 'data')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$legacy = [];
|
||||||
|
$stmt = $pdo->query('SELECT slug, data FROM recipes');
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$recipe = decode_legacy_jsonb_value($row['data']);
|
||||||
|
if (is_array($recipe) && !empty($recipe['slug'])) {
|
||||||
|
$legacy[] = $recipe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->exec('DROP TABLE IF EXISTS recipe_steps CASCADE');
|
||||||
|
$pdo->exec('DROP TABLE IF EXISTS recipe_utensils CASCADE');
|
||||||
|
$pdo->exec('DROP TABLE IF EXISTS recipe_ingredients CASCADE');
|
||||||
|
$pdo->exec('DROP TABLE IF EXISTS recipe_tags CASCADE');
|
||||||
|
$pdo->exec('DROP TABLE IF EXISTS recipe_translations CASCADE');
|
||||||
|
$pdo->exec('DROP TABLE IF EXISTS recipes CASCADE');
|
||||||
|
|
||||||
|
apply_recipe_schema($pdo);
|
||||||
|
|
||||||
|
foreach ($legacy as $recipe) {
|
||||||
|
save_recipe($recipe, $pdo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensure_recipe_schema(PDO $pdo): void {
|
||||||
|
static $ready = false;
|
||||||
|
if ($ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (db_table_has_column($pdo, 'recipes', 'data') && !db_table_has_column($pdo, 'recipes', 'calories')) {
|
||||||
|
migrate_legacy_jsonb_storage($pdo);
|
||||||
|
$ready = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_recipe_schema($pdo);
|
||||||
|
$ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function require_database(): PDO {
|
||||||
|
$pdo = get_db_connection();
|
||||||
|
if (!$pdo) {
|
||||||
|
$message = getenv('DATABASE_URL')
|
||||||
|
? 'Database connection failed. Check DATABASE_URL and that Postgres is running.'
|
||||||
|
: 'DATABASE_URL is not set in .env.';
|
||||||
|
throw new DatabaseUnavailableException($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_recipe_schema($pdo);
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init_db(): void {
|
||||||
|
require_database();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrate_recipes_from_db(PDO $pdo): array {
|
||||||
|
$recipes = [];
|
||||||
|
$stmt = $pdo->query(
|
||||||
|
'SELECT * FROM recipes ORDER BY featured DESC, updated_at DESC, slug ASC'
|
||||||
|
);
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$recipes[$row['slug']] = recipe_base_from_row($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($recipes === []) {
|
||||||
return [];
|
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'])) {
|
||||||
|
clear_featured_recipes($pdo);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'INSERT INTO recipes (
|
||||||
|
slug, hero, prep_time, cook_time, total_time, servings,
|
||||||
|
featured, coming_soon, calories, protein, carbs, fat, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:slug, :hero, :prep_time, :cook_time, :total_time, :servings,
|
||||||
|
:featured, :coming_soon, :calories, :protein, :carbs, :fat, NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET
|
||||||
|
hero = EXCLUDED.hero,
|
||||||
|
prep_time = EXCLUDED.prep_time,
|
||||||
|
cook_time = EXCLUDED.cook_time,
|
||||||
|
total_time = EXCLUDED.total_time,
|
||||||
|
servings = EXCLUDED.servings,
|
||||||
|
featured = EXCLUDED.featured,
|
||||||
|
coming_soon = EXCLUDED.coming_soon,
|
||||||
|
calories = EXCLUDED.calories,
|
||||||
|
protein = EXCLUDED.protein,
|
||||||
|
carbs = EXCLUDED.carbs,
|
||||||
|
fat = EXCLUDED.fat,
|
||||||
|
updated_at = NOW()'
|
||||||
|
);
|
||||||
|
$stmt->bindValue(':slug', $slug);
|
||||||
|
$stmt->bindValue(':hero', $recipe['hero'] ?? '');
|
||||||
|
$stmt->bindValue(':prep_time', (int) ($recipe['prep_time'] ?? 0), PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':cook_time', (int) ($recipe['cook_time'] ?? 0), PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':total_time', (int) ($recipe['total_time'] ?? 0), PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':servings', (int) ($recipe['servings'] ?? 2), PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':featured', !empty($recipe['featured']), PDO::PARAM_BOOL);
|
||||||
|
$stmt->bindValue(':coming_soon', !empty($recipe['coming_soon']), PDO::PARAM_BOOL);
|
||||||
|
$stmt->bindValue(':calories', (int) ($nutrition['calories'] ?? 0), PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':protein', (int) ($nutrition['protein'] ?? 0), PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':carbs', (int) ($nutrition['carbs'] ?? 0), PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':fat', (int) ($nutrition['fat'] ?? 0), PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
$pdo->prepare('DELETE FROM recipe_translations WHERE recipe_slug = ?')->execute([$slug]);
|
||||||
|
$pdo->prepare('DELETE FROM recipe_tags WHERE recipe_slug = ?')->execute([$slug]);
|
||||||
|
$pdo->prepare('DELETE FROM recipe_ingredients WHERE recipe_slug = ?')->execute([$slug]);
|
||||||
|
$pdo->prepare('DELETE FROM recipe_utensils WHERE recipe_slug = ?')->execute([$slug]);
|
||||||
|
$pdo->prepare('DELETE FROM recipe_steps WHERE recipe_slug = ?')->execute([$slug]);
|
||||||
|
|
||||||
|
$translationStmt = $pdo->prepare(
|
||||||
|
'INSERT INTO recipe_translations (recipe_slug, lang, title, description, category, difficulty)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$tagStmt = $pdo->prepare(
|
||||||
|
'INSERT INTO recipe_tags (recipe_slug, lang, tag, sort_order) VALUES (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$ingredientStmt = $pdo->prepare(
|
||||||
|
'INSERT INTO recipe_ingredients (recipe_slug, lang, content, sort_order) VALUES (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$utensilStmt = $pdo->prepare(
|
||||||
|
'INSERT INTO recipe_utensils (recipe_slug, lang, content, sort_order) VALUES (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$stepStmt = $pdo->prepare(
|
||||||
|
'INSERT INTO recipe_steps (recipe_slug, lang, content, video_url, timer_minutes, sort_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (['en', 'de'] as $lang) {
|
||||||
|
$block = $recipe['i18n'][$lang] ?? [];
|
||||||
|
$translationStmt->execute([
|
||||||
|
$slug,
|
||||||
|
$lang,
|
||||||
|
$block['title'] ?? '',
|
||||||
|
$block['description'] ?? '',
|
||||||
|
$block['category'] ?? '',
|
||||||
|
$block['difficulty'] ?? '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tagOrder = 0;
|
||||||
|
foreach (array_values($block['tags'] ?? []) as $tag) {
|
||||||
|
$tag = trim((string) $tag);
|
||||||
|
if ($tag !== '') {
|
||||||
|
$tagStmt->execute([$slug, $lang, $tag, $tagOrder++]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$ingredientOrder = 0;
|
||||||
|
foreach (array_values($block['ingredients'] ?? []) as $line) {
|
||||||
|
$line = trim((string) $line);
|
||||||
|
if ($line !== '') {
|
||||||
|
$ingredientStmt->execute([$slug, $lang, $line, $ingredientOrder++]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$utensilOrder = 0;
|
||||||
|
foreach (array_values($block['utensils'] ?? []) as $line) {
|
||||||
|
$line = trim((string) $line);
|
||||||
|
if ($line !== '') {
|
||||||
|
$utensilStmt->execute([$slug, $lang, $line, $utensilOrder++]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$steps = array_values($block['steps'] ?? []);
|
||||||
|
$videos = array_values($block['step_videos'] ?? []);
|
||||||
|
$timers = array_values($block['step_timers'] ?? []);
|
||||||
|
$stepOrder = 0;
|
||||||
|
foreach ($steps as $i => $step) {
|
||||||
|
$step = trim((string) $step);
|
||||||
|
if ($step === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$video = trim((string) ($videos[$i] ?? ''));
|
||||||
|
$timerRaw = $timers[$i] ?? '';
|
||||||
|
$timerMinutes = ($timerRaw !== '' && is_numeric($timerRaw)) ? (int) $timerRaw : null;
|
||||||
|
$stepStmt->execute([$slug, $lang, $step, $video, $timerMinutes, $stepOrder++]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
return true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
error_log('save_recipe failed: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
// 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',
|
||||||
'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) {
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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">
|
||||||
|
<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)): ?>
|
||||||
|
<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;">
|
||||||
|
<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>
|
||||||
+14
-90
@@ -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>
|
||||||
|
|||||||
+1
-43
@@ -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>
|
||||||
|
|||||||
+2
-2
@@ -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>
|
||||||
|
|||||||
+7
-2
@@ -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);
|
||||||
Reference in New Issue
Block a user