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:
2026-05-23 10:23:28 +02:00
parent 547778bba5
commit 6577db475a
20 changed files with 823 additions and 789 deletions
+28 -50
View File
@@ -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
View File
@@ -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
View File
@@ -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"
-5
View File
@@ -1,5 +0,0 @@
{
"projects": {
"default": "flixcooks"
}
}
+28 -21
View File
@@ -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.
--- ---
+16 -93
View File
@@ -49,7 +49,11 @@ if (!$authed) {
exit; exit;
} }
$allRecipes = load_recipes(); try {
$allRecipes = load_recipes();
} catch (DatabaseUnavailableException $e) {
handle_database_unavailable($e);
}
$siteSettings = load_site_settings(); $siteSettings = load_site_settings();
$message = null; $message = null;
$errors = []; $errors = [];
@@ -165,12 +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>
-40
View File
@@ -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']);
}
+62
View File
@@ -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
View File
@@ -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') ?: '',
];
} }
/** /**
+317 -82
View File
@@ -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 {
$pdo = get_db_connection(); http_response_code(503);
if (!$pdo) return; $dbError = $e->getMessage();
include __DIR__ . '/maintenance/db-unavailable.php';
exit;
}
try { function db_table_has_column(PDO $pdo, string $table, string $column): bool {
$pdo->exec("CREATE TABLE IF NOT EXISTS recipes ( $stmt = $pdo->prepare(
slug VARCHAR(255) PRIMARY KEY, 'SELECT 1 FROM information_schema.columns
data JSONB NOT NULL, WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, );
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP $stmt->execute([$table, $column]);
)"); return (bool) $stmt->fetchColumn();
}
$stmt = $pdo->query("SELECT COUNT(*) FROM recipes"); function apply_recipe_schema(PDO $pdo): void {
if ($stmt && $stmt->fetchColumn() == 0) { $path = __DIR__ . '/scripts/schema.sql';
$path = __DIR__ . '/data/recipes.json'; if (!file_exists($path)) {
if (file_exists($path)) { throw new RuntimeException('Missing scripts/schema.sql');
$json = file_get_contents($path); }
$data = json_decode($json, true); $pdo->exec(file_get_contents($path));
if (is_array($data) && count($data) > 0) { }
$insert = $pdo->prepare("INSERT INTO recipes (slug, data) VALUES (:slug, :data)");
foreach ($data as $recipe) { function recipe_empty_i18n(): array {
if (isset($recipe['slug'])) { return [
$insert->execute([ 'title' => '',
'slug' => $recipe['slug'], 'description' => '',
'data' => json_encode($recipe, JSON_UNESCAPED_UNICODE) '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');
} catch (\Exception $e) { $pdo->exec('DROP TABLE IF EXISTS recipe_ingredients CASCADE');
error_log("DB Init Error: " . $e->getMessage()); $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 load_recipes_local(): array { 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();
$data = [];
if (!$pdo) { if (!$pdo) {
$path = __DIR__ . '/data/recipes.json'; $message = getenv('DATABASE_URL')
if (file_exists($path)) { ? 'Database connection failed. Check DATABASE_URL and that Postgres is running.'
$json = file_get_contents($path); : 'DATABASE_URL is not set in .env.';
$data = json_decode($json, true) ?: []; throw new DatabaseUnavailableException($message);
}
} else {
static $initialized = false;
if (!$initialized) {
init_db();
$initialized = true;
} }
try { ensure_recipe_schema($pdo);
$stmt = $pdo->query("SELECT data FROM recipes"); return $pdo;
if ($stmt) { }
while ($row = $stmt->fetch()) {
$recipe = json_decode($row['data'], true); function init_db(): void {
if (is_array($recipe)) { require_database();
$data[] = $recipe; }
function hydrate_recipes_from_db(PDO $pdo): array {
$recipes = [];
$stmt = $pdo->query(
'SELECT * FROM recipes ORDER BY featured DESC, updated_at DESC, slug ASC'
);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$recipes[$row['slug']] = recipe_base_from_row($row);
} }
if ($recipes === []) {
return [];
} }
$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;
} }
} catch (\Exception $e) { $recipes[$slug]['i18n'][$lang] = array_merge(recipe_empty_i18n(), [
error_log("Failed to load recipes from DB: " . $e->getMessage()); 'title' => $row['title'],
'description' => $row['description'],
'category' => $row['category'],
'difficulty' => $row['difficulty'],
]);
}
$stmt = $pdo->query('SELECT recipe_slug, lang, tag FROM recipe_tags ORDER BY sort_order, id');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
$recipes[$row['recipe_slug']]['i18n'][$row['lang']]['tags'][] = $row['tag'];
} }
} }
foreach ($data as &$recipe) { $stmt = $pdo->query('SELECT recipe_slug, lang, content FROM recipe_ingredients ORDER BY sort_order, id');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
$recipes[$row['recipe_slug']]['i18n'][$row['lang']]['ingredients'][] = $row['content'];
}
}
$stmt = $pdo->query('SELECT recipe_slug, lang, content FROM recipe_utensils ORDER BY sort_order, id');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
$recipes[$row['recipe_slug']]['i18n'][$row['lang']]['utensils'][] = $row['content'];
}
}
$stmt = $pdo->query(
'SELECT recipe_slug, lang, content, video_url, timer_minutes
FROM recipe_steps ORDER BY sort_order, id'
);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (!isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
continue;
}
$lang = &$recipes[$row['recipe_slug']]['i18n'][$row['lang']];
$lang['steps'][] = $row['content'];
$lang['step_videos'][] = $row['video_url'] ?? '';
$lang['step_timers'][] = $row['timer_minutes'] !== null ? (string) $row['timer_minutes'] : '';
unset($lang);
}
$list = array_values($recipes);
foreach ($list as &$recipe) {
if (!empty($recipe['hero']) && is_string($recipe['hero'])) { if (!empty($recipe['hero']) && is_string($recipe['hero'])) {
$recipe['hero'] = normalize_asset_path($recipe['hero']); $recipe['hero'] = normalize_asset_path($recipe['hero']);
} }
} }
unset($recipe); unset($recipe);
return $data; return $list;
} }
function 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) { function delete_recipe(string $slug): bool {
$json = json_encode($recipes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); $pdo = require_database();
return (bool) file_put_contents($path, $json); $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;
} }
} }
+31 -31
View File
@@ -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);
} }
+59 -229
View File
@@ -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>
+52
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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";
+43
View File
@@ -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";
+70
View File
@@ -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);