Refactor database handling to require PostgreSQL connection, removing fallback to JSON. Implement error handling for database unavailability in key files. Update .env.example to reflect mandatory DATABASE_URL for local development. Remove Firebase configuration and related code from the project.
This commit is contained in:
+28
-50
@@ -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,12 +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
|
||||||
- [/] **Phase 6: Firebase Firestore Datenbank-Migration & Seeding**
|
- [x] **Phase 6: PostgreSQL Datenbank**
|
||||||
- Firebase CLI-Login & Projekt-Initialisierung
|
- `DATABASE_URL` in `.env`, Docker Compose für lokale Dev-DB
|
||||||
- .env-Konfiguration mit Firebase App Credentials
|
- `init_db()` / `save_recipes()` in `helpers.php`
|
||||||
- Client-seitiger "Seed Firestore"-Button in admin.php
|
- Fallback auf `data/recipes.json`
|
||||||
- Firestore REST API Integration in helpers.php
|
|
||||||
- Echtzeit-Zwei-Wege-Sync bei Rezeptänderungen in admin.php
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -66,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)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -112,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
|
||||||
@@ -134,36 +123,25 @@ 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: Firebase Firestore Datenbank-Migration & Seeding
|
### 🔥 Phase 6: PostgreSQL Datenbank
|
||||||
Migration des lokalen JSON-Flachdateispeichers auf eine skalierbare, cloudbasierte Firestore-Struktur.
|
Rezepte in Postgres (JSONB), optional lokal per Docker.
|
||||||
|
|
||||||
- [/] **Firebase-Projekt & CLI-Setup**
|
- [x] **`DATABASE_URL` & Docker Compose**
|
||||||
- [x] Firebase-Verzeichnis-Umgebung initialisieren und anbinden
|
- [x] `docker-compose.dev.yml` für lokale Postgres-Instanz
|
||||||
- [/] Firebase CLI Login durchführen und aktiven Account verknüpfen
|
- [x] `init_db()` legt Tabelle `recipes` an und seedet aus `recipes.json`
|
||||||
- [ ] Firebase Project & Web App ermitteln oder neu anlegen
|
- [x] **PHP-Datenzugriff**
|
||||||
- [ ] Lokale `.env`-Konfiguration mit Firebase SDK Parametern befüllen
|
- [x] `load_recipes()` / `save_recipes()` mit JSON-Fallback
|
||||||
- [ ] **Client-seitiges Daten-Seeding**
|
- [x] `load_recipe_by_slug()` in `recipe.php`
|
||||||
- [ ] "Seed Firestore"-Aktion im Admin-Bereich (`admin.php`) einbauen
|
|
||||||
- [ ] Batch-Dokumentenupload der `recipes.json`-Einträge in die Firestore Collection `recipes` über JS SDK
|
|
||||||
- [ ] Firestore Security Rules für den öffentlichen Lesezugriff anpassen
|
|
||||||
- [ ] **Firestore Server-seitiger Abruf (PHP REST API)**
|
|
||||||
- [ ] Firestore REST API Parser-Helfer in `helpers.php` implementieren (Transformation von Firestore Key-Maps in Standard-Arrays)
|
|
||||||
- [ ] `load_recipes()` auf REST API GET `/recipes` umstellen
|
|
||||||
- [ ] Neue Funktion `load_recipe_by_slug($slug)` für gezielten Einzelabruf implementieren und in `recipe.php` integrieren
|
|
||||||
- [ ] **Zwei-Wege-Echtzeit-Synchronisierung (Admin)**
|
|
||||||
- [ ] `save_recipes()` so anpassen, dass lokales JSON als Fallback-Backup erhalten bleibt
|
|
||||||
- [ ] Client-seitigen Trigger nach erfolgreichem PHP-Speichern in `admin.php` ausführen, um Änderungen sofort in Firestore zu spiegeln
|
|
||||||
- [ ] Funktionstest: Hinzufügen, Editieren und Löschen von Rezepten über die Admin-Konsole verifizieren
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+25
-32
@@ -4,11 +4,11 @@ This document summarizes architectural knowledge, conventions, and learnings for
|
|||||||
|
|
||||||
## 1. Project Architecture & Stack
|
## 1. Project Architecture & Stack
|
||||||
- **Backend:** Vanilla PHP. No heavy frameworks.
|
- **Backend:** Vanilla PHP. No heavy frameworks.
|
||||||
- **Database:** PostgreSQL (optional) via `DATABASE_URL` in `.env`, with automatic seed from and backup to `data/recipes.json`. All data access lives in `helpers.php`; connection logic in `config.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`.
|
||||||
- **Site settings (legal pages):** Flat-file `data/site.json` via `load_site_settings()` / `save_site_settings()` — not in Postgres.
|
- **Site settings (legal pages):** Flat-file `data/site.json` via `load_site_settings()` / `save_site_settings()` — not in Postgres.
|
||||||
- **Admin Panel (`admin.php`):** Lightweight CMS. Textareas use one line per array element (`ingredients`, `steps`, `step_videos`, `step_timers`).
|
- **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. Firebase compat SDKs in `partials/head.php` for auth, Firestore (newsletter/bookmarks where used).
|
- **Frontend:** Server-rendered PHP (`index.php`, `recipe.php`, …), Vanilla JS/CSS. Profile/favorites via `assets/fc-local.js`.
|
||||||
- **Config:** `config.php` loads `.env`, exposes `get_firebase_config()`, `get_db_connection()`. Never commit `.env` (see `.gitignore`).
|
- **Config:** `config.php` loads `.env`, `get_db_connection()`. Never commit `.env` (see `.gitignore`).
|
||||||
|
|
||||||
## 2. Design & Aesthetics
|
## 2. Design & Aesthetics
|
||||||
- **CSS:** Custom properties (`var(--ease-out-expo)`, `var(--surface-1)`), glassmorphism, `FloemaLayoutGrid`.
|
- **CSS:** Custom properties (`var(--ease-out-expo)`, `var(--surface-1)`), glassmorphism, `FloemaLayoutGrid`.
|
||||||
@@ -26,29 +26,27 @@ This document summarizes architectural knowledge, conventions, and learnings for
|
|||||||
|
|
||||||
## 5. PostgreSQL — Schema & Data Flow
|
## 5. PostgreSQL — Schema & Data Flow
|
||||||
|
|
||||||
### Table `recipes` (only app table today)
|
### Relational schema (`scripts/schema.sql`)
|
||||||
Created by `init_db()` in `helpers.php` if missing:
|
Created by `ensure_recipe_schema()` on first DB use. Legacy JSONB `recipes.data` is migrated once automatically.
|
||||||
|
|
||||||
| Column | Type | Role |
|
| Table | Role |
|
||||||
|-------------|-------------|------|
|
|-------|------|
|
||||||
| `slug` | `VARCHAR(255)` PRIMARY KEY | Stable recipe ID (URLs: `recipe.php?slug=…`) |
|
| `recipes` | slug, hero, times, servings, nutrition columns, featured, coming_soon |
|
||||||
| `data` | `JSONB` NOT NULL | **Entire recipe document** (title, i18n, ingredients, steps, nutrition, …) |
|
| `recipe_translations` | title, description, category, difficulty (en/de) |
|
||||||
| `created_at`| `TIMESTAMP` | Auto on insert |
|
| `recipe_tags`, `recipe_ingredients`, `recipe_utensils`, `recipe_steps` | Ordered lists per language |
|
||||||
| `updated_at`| `TIMESTAMP` | Set on `ON CONFLICT` update in `save_recipes()` |
|
|
||||||
|
|
||||||
**Design choice:** Document-in-a-row (JSONB), not normalized columns. PHP already works with JSON arrays; avoids schema migrations for every new recipe field. PostgreSQL can still query inside JSON (`data->'i18n'->'en'->>'title'`).
|
PHP still exposes the same nested arrays (`i18n`, `nutrition`, …) via `hydrate_recipes_from_db()`.
|
||||||
|
|
||||||
### Runtime flow
|
### Runtime flow
|
||||||
1. `get_db_connection()` in `config.php` parses `DATABASE_URL` → PDO `pgsql:` DSN.
|
1. `DATABASE_URL` required → `require_database()` or HTTP 503 (`maintenance/db-unavailable.php`).
|
||||||
2. If no URL or connection fails → `load_recipes_local()` reads `data/recipes.json` only.
|
2. `load_recipes()` → SQL → PHP arrays for templates.
|
||||||
3. If connected → `init_db()` once per request (static flag): `CREATE TABLE IF NOT EXISTS`, then if `COUNT(*) = 0` → seed all rows from `data/recipes.json`.
|
3. Admin: `save_recipe()`, `delete_recipe()`, `clear_featured_recipes()`.
|
||||||
4. `load_recipes()` / `load_recipe_by_slug()` decode `data` JSONB to PHP arrays.
|
4. One-time import: `php scripts/db-seed.php` from `data/recipes.json` (not read at runtime).
|
||||||
5. `save_recipes()` (admin): upsert all recipes in Postgres **and** write `data/recipes.json` as backup.
|
|
||||||
|
|
||||||
### `config.php` functions
|
### `config.php` functions
|
||||||
- `load_env()` — parses `.env` into `getenv()` / `$_ENV` / `$_SERVER`.
|
- `load_env()` — parses `.env`.
|
||||||
- `get_firebase_config()` — returns Firebase web config array from `FIREBASE_*` env vars. **Required** by `partials/head.php` and `admin.php`. Was accidentally removed in postgres commit `e7f35d7`; restored (undefined function caused HTTP 500).
|
- `get_db_connection()` — PDO or `null`.
|
||||||
- `get_db_connection()` — returns `PDO` or `null`; logs failures, does not throw.
|
- `DatabaseUnavailableException` — thrown when DB is required but missing.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -79,7 +77,7 @@ docker exec -it flixcooks-postgres-dev psql -U flixcooks -d flixcooks_dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
### PHP requirements (WSL/Linux)
|
### PHP requirements (WSL/Linux)
|
||||||
- Extension **`php-pgsql`** (or `php8.5-pgsql`) required; without it: log `could not find driver`, fallback to JSON.
|
- 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).
|
- 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`.
|
- Verify: `php -m | grep pgsql` → expect `pdo_pgsql`, `pgsql`.
|
||||||
|
|
||||||
@@ -92,14 +90,12 @@ php scripts/db-check.php # after .env + pgsql OK
|
|||||||
### `.env` rules for agents
|
### `.env` rules for agents
|
||||||
- Copy from `.env.example`; never commit `.env`.
|
- Copy from `.env.example`; never commit `.env`.
|
||||||
- **Local:** use `127.0.0.1` Docker URL above.
|
- **Local:** use `127.0.0.1` Docker URL above.
|
||||||
- **Railway production:** `postgres.railway.internal` only works **inside** Railway network — not from local WSL. For local access to hosted DB use Railway **public** proxy URL from dashboard, or prefer Docker for dev.
|
- **`DATABASE_URL` is mandatory** for recipe pages; omitting it shows the DB unavailable page.
|
||||||
- **Firebase:** `FIREBASE_API_KEY`, `FIREBASE_AUTH_DOMAIN`, `FIREBASE_PROJECT_ID`, `FIREBASE_STORAGE_BUCKET`, `FIREBASE_MESSAGING_SENDER_ID`, `FIREBASE_APP_ID` — empty values break client `firebase.initializeApp()` in browser.
|
|
||||||
- Omit `DATABASE_URL` entirely to force JSON-only mode (UI work without Postgres).
|
|
||||||
|
|
||||||
### Environment separation (important)
|
### Environment separation (important)
|
||||||
- One database per environment (local Docker / staging / production).
|
- One database per environment (local Docker / staging / production).
|
||||||
- Never point a dev branch `.env` at production Postgres.
|
- Never point a dev branch `.env` at production Postgres.
|
||||||
- Export/import between envs: `pg_dump` / `psql` when needed; document URLs in platform secrets (Railway variables), not in repo.
|
- Export/import between envs: `pg_dump` / `psql` when needed; document URLs in hosting secrets, not in repo.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -107,21 +103,18 @@ php scripts/db-check.php # after .env + pgsql OK
|
|||||||
|
|
||||||
| Symptom | Cause | Fix |
|
| Symptom | Cause | Fix |
|
||||||
|--------|--------|-----|
|
|--------|--------|-----|
|
||||||
| HTTP 500, `Call to undefined function get_firebase_config()` | Function missing from `config.php` | Ensure `get_firebase_config()` exists in `config.php` |
|
| 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` |
|
| Log: `could not find driver` | `php-pgsql` not installed | `sudo apt install php8.5-pgsql` |
|
||||||
| Log: connection failed, host `postgres.railway.internal` | Internal Railway hostname from local machine | Use Docker local URL or Railway public URL |
|
| DB OK but 0 recipes | Empty tables | `php scripts/db-seed.php` |
|
||||||
| Site loads, no recipes from DB | `DATABASE_URL` unset or DB empty and seed file missing | Set URL, ensure `data/recipes.json` exists, hit site or run `db-check.php` |
|
|
||||||
| Recipes work without Docker | Expected fallback | `load_recipes_local()` uses JSON when `get_db_connection()` is null |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Completed Milestones
|
## 8. Completed Milestones
|
||||||
- **Phase 3 (Nutrition):** `calories`, `protein`, `carbs`, `fat` on recipes; admin + UI.
|
- **Phase 3 (Nutrition):** `calories`, `protein`, `carbs`, `fat` on recipes; admin + UI.
|
||||||
- **Phase 4 (Cooking Mode):** `step_videos`, `step_timers`; fullscreen overlay.
|
- **Phase 4 (Cooking Mode):** `step_videos`, `step_timers`; fullscreen overlay.
|
||||||
- **Phase 6 (Postgres):** `recipes` JSONB table; seed from JSON; admin dual-write.
|
- **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 dev Postgres:** `docker-compose.dev.yml`, README section, `scripts/db-check.php`, `.env.example` with `DATABASE_URL`.
|
||||||
- **Bugfix (May 2026):** Restored `get_firebase_config()` after postgres migration regression.
|
- **Local profile data:** Favorites/goals via `assets/fc-local.js` (`localStorage`). Newsletter via `mailto:`.
|
||||||
- **Local profile data:** Favorites/goals via `assets/fc-local.js` (`localStorage`). Newsletter may use `mailto:` or Firestore depending on page.
|
|
||||||
|
|
||||||
## 9. Next Steps
|
## 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.
|
See `.agents/TODO.md` (e.g. PWA & offline support). README `Local Postgres (Docker)` and `Postgres in diesem Projekt` sections mirror setup for humans.
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# FlixCooks – lokale Entwicklung (.env wird nicht committed)
|
# FlixCooks – lokale Entwicklung (.env wird nicht committed)
|
||||||
# Kopieren: cp .env.example .env
|
# Kopieren: cp .env.example .env
|
||||||
|
|
||||||
# --- Postgres (docker-compose.dev.yml) ---
|
# --- Postgres (Pflicht für die Website) ---
|
||||||
# Nur setzen, wenn du die lokale DB testen willst. Ohne DATABASE_URL → Fallback auf data/recipes.json
|
# docker compose -f docker-compose.dev.yml up -d
|
||||||
DATABASE_URL="postgresql://flixcooks:flixcooks_dev@127.0.0.1:5432/flixcooks_dev"
|
DATABASE_URL="postgresql://flixcooks:flixcooks_dev@127.0.0.1:5432/flixcooks_dev"
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"projects": {
|
|
||||||
"default": "flixcooks"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,10 +16,11 @@ The project is architected to remain extremely lightweight and fast, intentional
|
|||||||
- `login.php`: Local profile page for dietary goals and saved favorites (browser storage).
|
- `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`.
|
||||||
- `assets/fc-local.js`: Browser-side storage for favorites and dietary goals.
|
- `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,7 +37,7 @@ FlixCooks uses a modern, carefully curated vanilla tech-stack focused on lightni
|
|||||||
|
|
||||||
### ⚙️ Backend & Data
|
### ⚙️ Backend & Data
|
||||||
- **Engine**: Vanilla PHP.
|
- **Engine**: Vanilla PHP.
|
||||||
- **Database**: PostgreSQL (production/staging) with automatic seed from `data/recipes.json`. Without `DATABASE_URL`, the app falls back to the JSON file.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -56,7 +57,8 @@ 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 optionally set **local Postgres** (see [Local Postgres (Docker)](#local-postgres-docker) below).
|
2. Set **`DATABASE_URL`** in `.env` (required). See [Local Postgres (Docker)](#local-postgres-docker) below.
|
||||||
|
3. Seed recipes once: `php scripts/db-seed.php` (imports `data/recipes.json` into SQL tables).
|
||||||
|
|
||||||
### 2. Local Postgres (Docker)
|
### 2. Local Postgres (Docker)
|
||||||
|
|
||||||
@@ -99,14 +101,17 @@ SELECT slug, data->>'title' AS title FROM recipes, jsonb_to_record(data) AS x(ti
|
|||||||
\q -- beenden
|
\q -- beenden
|
||||||
```
|
```
|
||||||
|
|
||||||
**DB komplett leeren und neu seeden** (lädt wieder aus `data/recipes.json` beim nächsten Seitenaufruf):
|
**DB komplett leeren und neu seeden:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yml down -v
|
docker compose -f docker-compose.dev.yml down -v
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
php scripts/db-seed.php
|
||||||
```
|
```
|
||||||
|
|
||||||
Details zum Schema und Ablauf: Abschnitt unten in dieser README und `helpers.php` → `init_db()`.
|
**Verbindung prüfen:** `php scripts/db-check.php`
|
||||||
|
|
||||||
|
Details zum Schema: Abschnitt unten und `scripts/schema.sql`.
|
||||||
|
|
||||||
### 3. Start the Development Server
|
### 3. Start the Development Server
|
||||||
|
|
||||||
@@ -130,27 +135,29 @@ If you prefer running a full local stack:
|
|||||||
|
|
||||||
## 🗄️ Postgres in diesem Projekt (Kurzüberblick)
|
## 🗄️ Postgres in diesem Projekt (Kurzüberblick)
|
||||||
|
|
||||||
FlixCooks nutzt **eine Tabelle** – kein klassisches „eine Spalte pro Rezeptfeld“-Schema:
|
Rezepte liegen in **normalisierten SQL-Tabellen** (kein JSONB-Blob, kein Laufzeit-Fallback auf Dateien):
|
||||||
|
|
||||||
| Spalte | Typ | Bedeutung |
|
| Tabelle | Inhalt |
|
||||||
|-------------|------------|-----------|
|
|---------|--------|
|
||||||
| `slug` | `VARCHAR` | Eindeutige ID des Rezepts (URL: `/recipe.php?slug=...`) |
|
| `recipes` | Slug, Zeiten, Hero-URL, Nährwerte, `featured`, `coming_soon` |
|
||||||
| `data` | `JSONB` | **Gesamtes** Rezept als JSON (Titel, Zutaten, Schritte, i18n, …) |
|
| `recipe_translations` | Titel, Beschreibung, Kategorie, Schwierigkeit (EN/DE) |
|
||||||
| `created_at`| `TIMESTAMP`| Erstellzeit |
|
| `recipe_tags` | Tags pro Sprache |
|
||||||
| `updated_at`| `TIMESTAMP`| Letzte Änderung (wird beim Update gesetzt) |
|
| `recipe_ingredients` | Zutatenzeilen |
|
||||||
|
| `recipe_utensils` | Werkzeugzeilen |
|
||||||
|
| `recipe_steps` | Schritte inkl. Video-URL und Timer |
|
||||||
|
|
||||||
**Warum JSONB?** Das Rezept ist in PHP/JSON ohnehin ein Objekt. Statt 20+ SQL-Spalten zu pflegen, speichert ihr ein Dokument pro Zeile. PostgreSQL kann in `JSONB` trotzdem indexieren und abfragen (`data->>'title'`), wenn ihr später filtern wollt.
|
Schema: `scripts/schema.sql`. PHP baut daraus dieselben Arrays wie früher (`i18n.en`, `nutrition`, …), damit Templates unverändert bleiben.
|
||||||
|
|
||||||
**Ablauf beim ersten Aufruf mit leerer DB:**
|
**Ablauf:**
|
||||||
|
|
||||||
1. `config.php` liest `DATABASE_URL` → PDO-Verbindung.
|
1. `DATABASE_URL` in `.env` → Verbindung über `config.php`.
|
||||||
2. `init_db()` in `helpers.php` erstellt `recipes`, falls nicht vorhanden.
|
2. Beim ersten Request: Tabellen anlegen (`ensure_recipe_schema()`). Alte JSONB-Tabelle wird einmalig migriert.
|
||||||
3. Ist die Tabelle leer → Import aus `data/recipes.json`.
|
3. `load_recipes()` liest per SQL; ohne DB → HTTP 503 (`maintenance/db-unavailable.php`).
|
||||||
4. `load_recipes()` liest alle Zeilen, dekodiert `data` zurück zu PHP-Arrays.
|
4. Admin: `save_recipe()` / `delete_recipe()` – direkt in die Tabellen.
|
||||||
|
|
||||||
**Admin speichern:** `save_recipes()` schreibt nach Postgres **und** aktualisiert `data/recipes.json` als Backup.
|
**Einmalig Daten laden:** `php scripts/db-seed.php` (aus `data/recipes.json`).
|
||||||
|
|
||||||
Für **Staging/Production** setzt du `DATABASE_URL` in der jeweiligen Hosting-Umgebung – nie Production-Daten in der lokalen Dev-DB mischen.
|
Für **Staging/Production** nur `DATABASE_URL` in der Hosting-Umgebung setzen – nie Production-Daten in der lokalen Dev-DB mischen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ if (!$authed) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
$allRecipes = load_recipes();
|
$allRecipes = load_recipes();
|
||||||
|
} catch (DatabaseUnavailableException $e) {
|
||||||
|
handle_database_unavailable($e);
|
||||||
|
}
|
||||||
$siteSettings = load_site_settings();
|
$siteSettings = load_site_settings();
|
||||||
$message = null;
|
$message = null;
|
||||||
$errors = [];
|
$errors = [];
|
||||||
@@ -165,12 +169,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 +262,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 +305,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($slugOriginal && $editIndex !== null) {
|
if ($featured) {
|
||||||
$allRecipes[$editIndex] = $record;
|
clear_featured_recipes();
|
||||||
} else {
|
}
|
||||||
$allRecipes[] = $record;
|
if ($slugOriginal && $slugOriginal !== $slug) {
|
||||||
|
delete_recipe($slugOriginal);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (save_recipes($allRecipes)) {
|
if (save_recipe($record)) {
|
||||||
$message = $slugOriginal ? 'Saved changes.' : 'Saved! New recipe added.';
|
$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.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,18 +365,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
|
|||||||
.list-item { padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.08); display: flex; justify-content: space-between; align-items: center; }
|
.list-item { padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.08); display: flex; justify-content: space-between; align-items: center; }
|
||||||
.pill { background: rgba(255,255,255,0.06); color: var(--ink); }
|
.pill { background: rgba(255,255,255,0.06); color: var(--ink); }
|
||||||
</style>
|
</style>
|
||||||
<!-- 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();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="admin-header">
|
<div class="admin-header">
|
||||||
@@ -388,9 +374,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
|
|||||||
<a href="/admin.php" class="<?php echo $view === 'recipes' ? 'active' : ''; ?>">Recipes</a>
|
<a href="/admin.php" class="<?php echo $view === 'recipes' ? 'active' : ''; ?>">Recipes</a>
|
||||||
<a href="/admin.php?view=settings" class="<?php echo $view === 'settings' ? 'active' : ''; ?>">Site settings</a>
|
<a href="/admin.php?view=settings" class="<?php echo $view === 'settings' ? 'active' : ''; ?>">Site settings</a>
|
||||||
</div>
|
</div>
|
||||||
<?php if ($view === 'recipes' && !$editing): ?>
|
|
||||||
<button id="btn-seed-firestore" class="button" style="background: var(--accent-gold, #c5a880); color: #000; font-weight: 700; border: none; padding: 10px 14px; border-radius: 10px; cursor: pointer;">Seed Firestore</button>
|
|
||||||
<?php endif; ?>
|
|
||||||
<a class="button" href="/index.php" style="padding:10px 14px;">View site</a>
|
<a class="button" href="/index.php" style="padding:10px 14px;">View site</a>
|
||||||
<?php if ($editing): ?>
|
<?php if ($editing): ?>
|
||||||
<a class="button" href="/admin.php" style="padding:10px 14px;">Back</a>
|
<a class="button" href="/admin.php" style="padding:10px 14px;">Back</a>
|
||||||
@@ -654,65 +637,5 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
|
|||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<script>
|
|
||||||
// Firestore Seeder Logic
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const btnSeed = document.getElementById('btn-seed-firestore');
|
|
||||||
if (btnSeed) {
|
|
||||||
btnSeed.addEventListener('click', async () => {
|
|
||||||
if (!confirm('Bist du sicher, dass du alle Rezepte nach Firestore importieren möchtest? Bestehende Dokumente mit identischem Slug werden überschrieben.')) return;
|
|
||||||
btnSeed.disabled = true;
|
|
||||||
btnSeed.innerText = 'Seeding...';
|
|
||||||
try {
|
|
||||||
const recipes = <?php echo json_encode($allRecipes); ?>;
|
|
||||||
let count = 0;
|
|
||||||
for (const recipe of recipes) {
|
|
||||||
await window.db.collection('recipes').doc(recipe.slug).set(recipe);
|
|
||||||
console.log('Seeded to Firestore:', recipe.slug);
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
alert(`Erfolgreich ${count} Rezepte nach Firestore migriert!`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Seeding Fehler:', err);
|
|
||||||
alert('Fehler beim Seeding: ' + err.message);
|
|
||||||
} finally {
|
|
||||||
btnSeed.disabled = false;
|
|
||||||
btnSeed.innerText = 'Seed Firestore';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Auto-Sync after PHP Save -->
|
|
||||||
<?php if (isset($record) && str_contains($message ?? '', 'Saved')): ?>
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
|
||||||
const recipe = <?php echo json_encode($record); ?>;
|
|
||||||
try {
|
|
||||||
await window.db.collection('recipes').doc(recipe.slug).set(recipe);
|
|
||||||
console.log('Successfully synced saved recipe to Firestore:', recipe.slug);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error syncing saved recipe to Firestore:', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<!-- Auto-Sync after PHP Delete -->
|
|
||||||
<?php if (isset($slugDel) && ($message ?? '') === 'Recipe deleted.'): ?>
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
|
||||||
const slugDel = <?php echo json_encode($slugDel); ?>;
|
|
||||||
try {
|
|
||||||
await window.db.collection('recipes').doc(slugDel).delete();
|
|
||||||
console.log('Successfully deleted recipe from Firestore:', slugDel);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error deleting recipe from Firestore:', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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);
|
||||||
+2
-9
@@ -34,15 +34,8 @@ 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') ?: '',
|
|
||||||
'messagingSenderId' => getenv('FIREBASE_MESSAGING_SENDER_ID') ?: '',
|
|
||||||
'appId' => getenv('FIREBASE_APP_ID') ?: '',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+324
-89
@@ -7,136 +7,371 @@ if (session_status() === PHP_SESSION_NONE) {
|
|||||||
|
|
||||||
require_once __DIR__ . '/config.php';
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
function init_db() {
|
function handle_database_unavailable(DatabaseUnavailableException $e): void {
|
||||||
|
http_response_code(503);
|
||||||
|
$dbError = $e->getMessage();
|
||||||
|
include __DIR__ . '/maintenance/db-unavailable.php';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function db_table_has_column(PDO $pdo, string $table, string $column): bool {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$table, $column]);
|
||||||
|
return (bool) $stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply_recipe_schema(PDO $pdo): void {
|
||||||
|
$path = __DIR__ . '/scripts/schema.sql';
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
throw new RuntimeException('Missing scripts/schema.sql');
|
||||||
|
}
|
||||||
|
$pdo->exec(file_get_contents($path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function recipe_empty_i18n(): array {
|
||||||
|
return [
|
||||||
|
'title' => '',
|
||||||
|
'description' => '',
|
||||||
|
'category' => '',
|
||||||
|
'difficulty' => '',
|
||||||
|
'tags' => [],
|
||||||
|
'ingredients' => [],
|
||||||
|
'utensils' => [],
|
||||||
|
'steps' => [],
|
||||||
|
'step_videos' => [],
|
||||||
|
'step_timers' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function recipe_base_from_row(array $row): array {
|
||||||
|
return [
|
||||||
|
'slug' => $row['slug'],
|
||||||
|
'hero' => $row['hero'] ?? '',
|
||||||
|
'prep_time' => (int) ($row['prep_time'] ?? 0),
|
||||||
|
'cook_time' => (int) ($row['cook_time'] ?? 0),
|
||||||
|
'total_time' => (int) ($row['total_time'] ?? 0),
|
||||||
|
'servings' => (int) ($row['servings'] ?? 2),
|
||||||
|
'featured' => (bool) ($row['featured'] ?? false),
|
||||||
|
'coming_soon' => (bool) ($row['coming_soon'] ?? false),
|
||||||
|
'nutrition' => [
|
||||||
|
'calories' => (int) ($row['calories'] ?? 0),
|
||||||
|
'protein' => (int) ($row['protein'] ?? 0),
|
||||||
|
'carbs' => (int) ($row['carbs'] ?? 0),
|
||||||
|
'fat' => (int) ($row['fat'] ?? 0),
|
||||||
|
],
|
||||||
|
'i18n' => [
|
||||||
|
'en' => recipe_empty_i18n(),
|
||||||
|
'de' => recipe_empty_i18n(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode_legacy_jsonb_value($value): ?array {
|
||||||
|
if (is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
if (is_string($value)) {
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
if ($value !== null) {
|
||||||
|
$decoded = json_decode(json_encode($value), true);
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrate_legacy_jsonb_storage(PDO $pdo): void {
|
||||||
|
if (!db_table_has_column($pdo, 'recipes', 'data')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$legacy = [];
|
||||||
|
$stmt = $pdo->query('SELECT slug, data FROM recipes');
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$recipe = decode_legacy_jsonb_value($row['data']);
|
||||||
|
if (is_array($recipe) && !empty($recipe['slug'])) {
|
||||||
|
$legacy[] = $recipe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->exec('DROP TABLE IF EXISTS recipe_steps CASCADE');
|
||||||
|
$pdo->exec('DROP TABLE IF EXISTS recipe_utensils CASCADE');
|
||||||
|
$pdo->exec('DROP TABLE IF EXISTS recipe_ingredients CASCADE');
|
||||||
|
$pdo->exec('DROP TABLE IF EXISTS recipe_tags CASCADE');
|
||||||
|
$pdo->exec('DROP TABLE IF EXISTS recipe_translations CASCADE');
|
||||||
|
$pdo->exec('DROP TABLE IF EXISTS recipes CASCADE');
|
||||||
|
|
||||||
|
apply_recipe_schema($pdo);
|
||||||
|
|
||||||
|
foreach ($legacy as $recipe) {
|
||||||
|
save_recipe($recipe, $pdo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensure_recipe_schema(PDO $pdo): void {
|
||||||
|
static $ready = false;
|
||||||
|
if ($ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (db_table_has_column($pdo, 'recipes', 'data') && !db_table_has_column($pdo, 'recipes', 'calories')) {
|
||||||
|
migrate_legacy_jsonb_storage($pdo);
|
||||||
|
$ready = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_recipe_schema($pdo);
|
||||||
|
$ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function require_database(): PDO {
|
||||||
$pdo = get_db_connection();
|
$pdo = get_db_connection();
|
||||||
if (!$pdo) return;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
ensure_recipe_schema($pdo);
|
||||||
$pdo->exec("CREATE TABLE IF NOT EXISTS recipes (
|
return $pdo;
|
||||||
slug VARCHAR(255) PRIMARY KEY,
|
}
|
||||||
data JSONB NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)");
|
|
||||||
|
|
||||||
$stmt = $pdo->query("SELECT COUNT(*) FROM recipes");
|
function init_db(): void {
|
||||||
if ($stmt && $stmt->fetchColumn() == 0) {
|
require_database();
|
||||||
$path = __DIR__ . '/data/recipes.json';
|
}
|
||||||
if (file_exists($path)) {
|
|
||||||
$json = file_get_contents($path);
|
function hydrate_recipes_from_db(PDO $pdo): array {
|
||||||
$data = json_decode($json, true);
|
$recipes = [];
|
||||||
if (is_array($data) && count($data) > 0) {
|
$stmt = $pdo->query(
|
||||||
$insert = $pdo->prepare("INSERT INTO recipes (slug, data) VALUES (:slug, :data)");
|
'SELECT * FROM recipes ORDER BY featured DESC, updated_at DESC, slug ASC'
|
||||||
foreach ($data as $recipe) {
|
);
|
||||||
if (isset($recipe['slug'])) {
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
$insert->execute([
|
$recipes[$row['slug']] = recipe_base_from_row($row);
|
||||||
'slug' => $recipe['slug'],
|
}
|
||||||
'data' => json_encode($recipe, JSON_UNESCAPED_UNICODE)
|
|
||||||
|
if ($recipes === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$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']])) {
|
||||||
} catch (\Exception $e) {
|
$recipes[$row['recipe_slug']]['i18n'][$row['lang']]['tags'][] = $row['tag'];
|
||||||
error_log("DB Init Error: " . $e->getMessage());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function load_recipes_local(): array {
|
$stmt = $pdo->query('SELECT recipe_slug, lang, content FROM recipe_ingredients ORDER BY sort_order, id');
|
||||||
$pdo = get_db_connection();
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
$data = [];
|
if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
|
||||||
|
$recipes[$row['recipe_slug']]['i18n'][$row['lang']]['ingredients'][] = $row['content'];
|
||||||
if (!$pdo) {
|
|
||||||
$path = __DIR__ . '/data/recipes.json';
|
|
||||||
if (file_exists($path)) {
|
|
||||||
$json = file_get_contents($path);
|
|
||||||
$data = json_decode($json, true) ?: [];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
static $initialized = false;
|
|
||||||
if (!$initialized) {
|
|
||||||
init_db();
|
|
||||||
$initialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$stmt = $pdo->query("SELECT data FROM recipes");
|
|
||||||
if ($stmt) {
|
|
||||||
while ($row = $stmt->fetch()) {
|
|
||||||
$recipe = json_decode($row['data'], true);
|
|
||||||
if (is_array($recipe)) {
|
|
||||||
$data[] = $recipe;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
error_log("Failed to load recipes from DB: " . $e->getMessage());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($data as &$recipe) {
|
$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 load_recipes(): array {
|
function load_recipes(): array {
|
||||||
return load_recipes_local();
|
$pdo = require_database();
|
||||||
|
return hydrate_recipes_from_db($pdo);
|
||||||
}
|
}
|
||||||
|
|
||||||
function load_recipe_by_slug(string $slug): ?array {
|
function load_recipe_by_slug(string $slug): ?array {
|
||||||
return find_recipe_by_slug(load_recipes_local(), $slug);
|
foreach (load_recipes() as $recipe) {
|
||||||
|
if (($recipe['slug'] ?? '') === $slug) {
|
||||||
|
return $recipe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function save_recipes(array $recipes): bool {
|
function clear_featured_recipes(?PDO $pdo = null): void {
|
||||||
$pdo = get_db_connection();
|
$pdo = $pdo ?? require_database();
|
||||||
$path = __DIR__ . '/data/recipes.json';
|
$pdo->exec('UPDATE recipes SET featured = FALSE');
|
||||||
|
|
||||||
if (!$pdo) {
|
|
||||||
$json = json_encode($recipes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
||||||
return (bool) file_put_contents($path, $json);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
$slugs = [];
|
|
||||||
$insert = $pdo->prepare("INSERT INTO recipes (slug, data) VALUES (:slug, :data) ON CONFLICT (slug) DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP");
|
|
||||||
|
|
||||||
foreach ($recipes as $recipe) {
|
$stmt = $pdo->prepare(
|
||||||
if (isset($recipe['slug'])) {
|
'INSERT INTO recipes (
|
||||||
$slugs[] = $recipe['slug'];
|
slug, hero, prep_time, cook_time, total_time, servings,
|
||||||
$insert->execute([
|
featured, coming_soon, calories, protein, carbs, fat, updated_at
|
||||||
'slug' => $recipe['slug'],
|
) VALUES (
|
||||||
'data' => json_encode($recipe, JSON_UNESCAPED_UNICODE)
|
: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'] ?? '',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
foreach (array_values($block['tags'] ?? []) as $i => $tag) {
|
||||||
|
$tag = trim((string) $tag);
|
||||||
|
if ($tag !== '') {
|
||||||
|
$tagStmt->execute([$slug, $lang, $tag, $i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($slugs)) {
|
foreach (array_values($block['ingredients'] ?? []) as $i => $line) {
|
||||||
$placeholders = implode(',', array_fill(0, count($slugs), '?'));
|
$line = trim((string) $line);
|
||||||
$delete = $pdo->prepare("DELETE FROM recipes WHERE slug NOT IN ($placeholders)");
|
if ($line !== '') {
|
||||||
$delete->execute($slugs);
|
$ingredientStmt->execute([$slug, $lang, $line, $i]);
|
||||||
} else {
|
}
|
||||||
$pdo->exec("DELETE FROM recipes");
|
}
|
||||||
|
|
||||||
|
foreach (array_values($block['utensils'] ?? []) as $i => $line) {
|
||||||
|
$line = trim((string) $line);
|
||||||
|
if ($line !== '') {
|
||||||
|
$utensilStmt->execute([$slug, $lang, $line, $i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$steps = array_values($block['steps'] ?? []);
|
||||||
|
$videos = array_values($block['step_videos'] ?? []);
|
||||||
|
$timers = array_values($block['step_timers'] ?? []);
|
||||||
|
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, $i]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
|
|
||||||
// Also update local JSON as backup
|
|
||||||
$json = json_encode($recipes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
||||||
file_put_contents($path, $json);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (Exception $e) {
|
} catch (\Exception $e) {
|
||||||
if ($pdo->inTransaction()) {
|
if ($pdo->inTransaction()) {
|
||||||
$pdo->rollBack();
|
$pdo->rollBack();
|
||||||
}
|
}
|
||||||
error_log("Failed to save recipes to DB: " . $e->getMessage());
|
error_log('save_recipe failed: ' . $e->getMessage());
|
||||||
return false;
|
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>
|
||||||
|
|||||||
+6
-1
@@ -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,7 +52,6 @@ $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 ? load_recipe_by_slug($slug) : null;
|
$recipeRaw = $slug ? load_recipe_by_slug($slug) : null;
|
||||||
$recipe = $recipeRaw ? localize_recipe($recipeRaw, $lang) : null;
|
$recipe = $recipeRaw ? localize_recipe($recipeRaw, $lang) : null;
|
||||||
|
|||||||
+13
-7
@@ -18,20 +18,26 @@ if (!$url) {
|
|||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
$pdo = get_db_connection();
|
try {
|
||||||
if (!$pdo) {
|
$pdo = require_database();
|
||||||
fwrite(STDERR, "Keine DB-Verbindung zu: {$url}\n");
|
} catch (DatabaseUnavailableException $e) {
|
||||||
|
fwrite(STDERR, $e->getMessage() . "\n");
|
||||||
fwrite(STDERR, "Prüfe: Docker läuft? docker compose -f docker-compose.dev.yml ps\n");
|
fwrite(STDERR, "Prüfe: Docker läuft? docker compose -f docker-compose.dev.yml ps\n");
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
init_db();
|
|
||||||
|
|
||||||
$count = (int) $pdo->query('SELECT COUNT(*) FROM recipes')->fetchColumn();
|
$count = (int) $pdo->query('SELECT COUNT(*) FROM recipes')->fetchColumn();
|
||||||
echo "Verbindung OK. Rezepte in DB: {$count}\n";
|
echo "Verbindung OK. Rezepte in DB: {$count}\n";
|
||||||
|
|
||||||
if ($count > 0) {
|
if ($count === 0) {
|
||||||
$stmt = $pdo->query("SELECT slug, data->'i18n'->'en'->>'title' AS title FROM recipes LIMIT 5");
|
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";
|
echo "\nBeispiel-Zeilen:\n";
|
||||||
while ($row = $stmt->fetch()) {
|
while ($row = $stmt->fetch()) {
|
||||||
echo " - {$row['slug']}: {$row['title']}\n";
|
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