Dev #9

Merged
LordSchmackes merged 13 commits from dev into main 2026-05-23 21:30:54 +00:00
20 changed files with 823 additions and 789 deletions
Showing only changes of commit 6577db475a - Show all commits
+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') ?: '',
];
} }
/** /**
+325 -90
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();
}
github-actions[bot] commented 2026-05-23 21:30:28 +00:00 (Migrated from github.com)
Review

This is a concise and efficient way to check for the existence of a column in information_schema.columns.

This is a concise and efficient way to check for the existence of a column in `information_schema.columns`.
$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)) {
github-actions[bot] commented 2026-05-23 21:30:28 +00:00 (Migrated from github.com)
Review

Loading the schema from an external SQL file (scripts/schema.sql) is a good practice for maintainability and readability. Throwing a RuntimeException if the file is missing is appropriate.

Loading the schema from an external SQL file (`scripts/schema.sql`) is a good practice for maintainability and readability. Throwing a `RuntimeException` if the file is missing is appropriate.
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' => [],
github-actions[bot] commented 2026-05-23 21:30:28 +00:00 (Migrated from github.com)
Review

This helper function provides a clean default structure for internationalized recipe data, ensuring consistency.

This helper function provides a clean default structure for internationalized recipe data, ensuring consistency.
} 'ingredients' => [],
} 'utensils' => [],
} 'steps' => [],
github-actions[bot] commented 2026-05-23 21:30:28 +00:00 (Migrated from github.com)
Review

The default value for 'description' is an empty string. While functional, for internationalization, it's often beneficial to provide a placeholder string like null or a specific marker (e.g., __('default_description')) that can be explicitly translated or identified as missing. This can help in debugging or ensuring all fields are eventually populated.

The default value for 'description' is an empty string. While functional, for internationalization, it's often beneficial to provide a placeholder string like `null` or a specific marker (e.g., `__('default_description')`) that can be explicitly translated or identified as missing. This can help in debugging or ensuring all fields are eventually populated.
'step_videos' => [],
github-actions[bot] commented 2026-05-23 21:30:28 +00:00 (Migrated from github.com)
Review

Similar to the 'description', an empty string for 'category' might be better represented by null or a translatable placeholder to distinguish between an intentionally empty category and a missing one.

Similar to the 'description', an empty string for 'category' might be better represented by `null` or a translatable placeholder to distinguish between an intentionally empty category and a missing one.
'step_timers' => [],
github-actions[bot] commented 2026-05-23 21:30:28 +00:00 (Migrated from github.com)
Review

Similar to 'description' and 'category', an empty string for 'difficulty' could be improved by using null or a translatable placeholder. This helps differentiate between an unset difficulty and a deliberately empty one.

Similar to 'description' and 'category', an empty string for 'difficulty' could be improved by using `null` or a translatable placeholder. This helps differentiate between an unset difficulty and a deliberately empty one.
];
}
function recipe_base_from_row(array $row): array {
return [
github-actions[bot] commented 2026-05-23 21:30:28 +00:00 (Migrated from github.com)
Review

It's good that step_videos is initialized as an empty array. However, the save_recipe function inserts video_url from the input directly. If the input step_videos array contains null or empty strings, they will be inserted as such. Consider adding a trim or filter for empty strings here if they are not intended to be stored.

It's good that `step_videos` is initialized as an empty array. However, the `save_recipe` function inserts `video_url` from the input directly. If the input `step_videos` array contains `null` or empty strings, they will be inserted as such. Consider adding a trim or filter for empty strings here if they are not intended to be stored.
'slug' => $row['slug'],
github-actions[bot] commented 2026-05-23 21:30:28 +00:00 (Migrated from github.com)
Review

Similar to step_videos, step_timers is initialized as an empty array. The save_recipe function converts timer raw values to int or null. If the input array contains non-numeric strings, they will result in null. Ensure that any non-numeric or empty string values are handled consistently, perhaps by filtering them out before insertion.

Similar to `step_videos`, `step_timers` is initialized as an empty array. The `save_recipe` function converts timer raw values to `int` or `null`. If the input array contains non-numeric strings, they will result in `null`. Ensure that any non-numeric or empty string values are handled consistently, perhaps by filtering them out before insertion.
'hero' => $row['hero'] ?? '',
'prep_time' => (int) ($row['prep_time'] ?? 0),
'cook_time' => (int) ($row['cook_time'] ?? 0),
'total_time' => (int) ($row['total_time'] ?? 0),
github-actions[bot] commented 2026-05-23 21:30:28 +00:00 (Migrated from github.com)
Review

This function correctly extracts base recipe data from a database row, handling potential missing keys with default values. The explicit type casting ((int), (bool)) is good for data integrity.

This function correctly extracts base recipe data from a database row, handling potential missing keys with default values. The explicit type casting (`(int)`, `(bool)`) is good for data integrity.
'servings' => (int) ($row['servings'] ?? 2),
'featured' => (bool) ($row['featured'] ?? false),
'coming_soon' => (bool) ($row['coming_soon'] ?? false),
'nutrition' => [
'calories' => (int) ($row['calories'] ?? 0),
'protein' => (int) ($row['protein'] ?? 0),
'carbs' => (int) ($row['carbs'] ?? 0),
'fat' => (int) ($row['fat'] ?? 0),
],
'i18n' => [
'en' => recipe_empty_i18n(),
'de' => recipe_empty_i18n(),
],
];
}
function decode_legacy_jsonb_value($value): ?array {
if (is_array($value)) {
return $value;
}
if (is_string($value)) {
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : null;
github-actions[bot] commented 2026-05-23 21:30:28 +00:00 (Migrated from github.com)
Review

This function attempts to decode legacy JSONB values. It handles arrays, strings, and objects that can be JSON encoded. However, it might be beneficial to add explicit handling or error logging for unexpected data types passed to $value to prevent potential TypeError or other runtime errors if the input is not as expected.

This function attempts to decode legacy JSONB values. It handles arrays, strings, and objects that can be JSON encoded. However, it might be beneficial to add explicit handling or error logging for unexpected data types passed to `$value` to prevent potential `TypeError` or other runtime errors if the input is not as expected.
}
if ($value !== null) {
$decoded = json_decode(json_encode($value), true);
return is_array($decoded) ? $decoded : null;
}
return null;
}
function migrate_legacy_jsonb_storage(PDO $pdo): void {
if (!db_table_has_column($pdo, 'recipes', 'data')) {
return;
}
$legacy = [];
$stmt = $pdo->query('SELECT slug, data FROM recipes');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
github-actions[bot] commented 2026-05-23 21:30:28 +00:00 (Migrated from github.com)
Review

This check correctly identifies if the legacy 'data' column exists, preventing unnecessary migration steps. This is a robust way to handle schema evolution.

This check correctly identifies if the legacy 'data' column exists, preventing unnecessary migration steps. This is a robust way to handle schema evolution.
$recipe = decode_legacy_jsonb_value($row['data']);
if (is_array($recipe) && !empty($recipe['slug'])) {
$legacy[] = $recipe;
} }
} catch (\Exception $e) { }
error_log("DB Init Error: " . $e->getMessage());
$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');
github-actions[bot] commented 2026-05-23 21:30:28 +00:00 (Migrated from github.com)
Review

Dropping existing tables before applying the schema is a common migration strategy. However, if this script is ever run on a production database with existing data, this will result in data loss. Ensure this is only intended for development/testing environments or that a proper migration system is in place for production.

Dropping existing tables before applying the schema is a common migration strategy. However, if this script is ever run on a production database with existing data, this will result in data loss. Ensure this is only intended for development/testing environments or that a proper migration system is in place for production.
apply_recipe_schema($pdo);
foreach ($legacy as $recipe) {
save_recipe($recipe, $pdo);
} }
} }
function 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')) {
github-actions[bot] commented 2026-05-23 21:30:28 +00:00 (Migrated from github.com)
Review

Using a static variable $ready to ensure schema application only runs once per request is an efficient optimization. This prevents redundant database operations.

Using a static variable `$ready` to ensure schema application only runs once per request is an efficient optimization. This prevents redundant database operations.
migrate_legacy_jsonb_storage($pdo);
$ready = true;
return;
}
apply_recipe_schema($pdo);
$ready = true;
}
function require_database(): PDO {
$pdo = get_db_connection(); $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);
github-actions[bot] commented 2026-05-23 21:30:28 +00:00 (Migrated from github.com)
Review

This function is crucial for database connectivity and schema management. The fallback logic for DATABASE_URL being unset is good. Throwing a DatabaseUnavailableException is a clear way to signal a critical error.

This function is crucial for database connectivity and schema management. The fallback logic for `DATABASE_URL` being unset is good. Throwing a `DatabaseUnavailableException` is a clear way to signal a critical error.
} }
} 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 = [];
} catch (\Exception $e) { $stmt = $pdo->query(
error_log("Failed to load recipes from DB: " . $e->getMessage()); '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);
github-actions[bot] commented 2026-05-23 21:30:29 +00:00 (Migrated from github.com)
Review

This function orchestrates the loading of all recipe data from the database, including translations, tags, ingredients, etc. It's well-structured, though the multiple SELECT statements could potentially be optimized if performance becomes an issue with a very large number of recipes or related data.

This function orchestrates the loading of all recipe data from the database, including translations, tags, ingredients, etc. It's well-structured, though the multiple `SELECT` statements could potentially be optimized if performance becomes an issue with a very large number of recipes or related data.
}
if ($recipes === []) {
return [];
}
$stmt = $pdo->query('SELECT * FROM recipe_translations');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$slug = $row['recipe_slug'];
$lang = $row['lang'];
if (!isset($recipes[$slug])) {
continue;
}
$recipes[$slug]['i18n'][$lang] = array_merge(recipe_empty_i18n(), [
'title' => $row['title'],
'description' => $row['description'],
'category' => $row['category'],
'difficulty' => $row['difficulty'],
]);
}
$stmt = $pdo->query('SELECT recipe_slug, lang, tag FROM recipe_tags ORDER BY sort_order, id');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
$recipes[$row['recipe_slug']]['i18n'][$row['lang']]['tags'][] = $row['tag'];
} }
} }
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(
github-actions[bot] commented 2026-05-23 21:30:29 +00:00 (Migrated from github.com)
Review

This function handles saving a recipe to the database, including its translations and related items. It uses transactions for atomicity, which is excellent. However, the repeated calls to DELETE for all related tables before inserting new data can be inefficient for updates. If a recipe is updated frequently, consider an UPSERT approach or more targeted updates for related data rather than full deletes and re-inserts.

This function handles saving a recipe to the database, including its translations and related items. It uses transactions for atomicity, which is excellent. However, the repeated calls to `DELETE` for all related tables before inserting new data can be inefficient for updates. If a recipe is updated frequently, consider an `UPSERT` approach or more targeted updates for related data rather than full deletes and re-inserts.
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,
github-actions[bot] commented 2026-05-23 21:30:29 +00:00 (Migrated from github.com)
Review

Clearing all other featured recipes when a new one is marked as featured is a good logic for ensuring only one recipe is featured at a time. This prevents ambiguity.

Clearing all other featured recipes when a new one is marked as featured is a good logic for ensuring only one recipe is featured at a time. This prevents ambiguity.
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(
github-actions[bot] commented 2026-05-23 21:30:29 +00:00 (Migrated from github.com)
Review

These delete statements are executed for every save, even if the data hasn't changed. This can be inefficient. If the goal is to update, consider only deleting/inserting what has changed, or using ON CONFLICT clauses where applicable in the INSERT statements for related tables if the database supports it.

These delete statements are executed for every save, even if the data hasn't changed. This can be inefficient. If the goal is to update, consider only deleting/inserting what has changed, or using `ON CONFLICT` clauses where applicable in the `INSERT` statements for related tables if the database supports it.
'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]);
}
}
github-actions[bot] commented 2026-05-23 21:30:29 +00:00 (Migrated from github.com)
Review

Hardcoding languages en and de might become problematic if more languages are added. It would be more maintainable to fetch the list of supported languages from a configuration or a dedicated table if the application grows.

Hardcoding languages `en` and `de` might become problematic if more languages are added. It would be more maintainable to fetch the list of supported languages from a configuration or a dedicated table if the application grows.
foreach (array_values($block['ingredients'] ?? []) as $i => $line) {
$line = trim((string) $line);
if ($line !== '') {
$ingredientStmt->execute([$slug, $lang, $line, $i]);
}
}
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]);
} }
} }
if (!empty($slugs)) {
$placeholders = implode(',', array_fill(0, count($slugs), '?'));
$delete = $pdo->prepare("DELETE FROM recipes WHERE slug NOT IN ($placeholders)");
$delete->execute($slugs);
} else {
$pdo->exec("DELETE FROM recipes");
}
$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
+37 -37
View File
@@ -1,6 +1,12 @@
<?php <?php
require __DIR__ . '/helpers.php'; require __DIR__ . '/helpers.php';
try {
$allRecipes = load_recipes();
github-actions[bot] commented 2026-05-23 21:30:29 +00:00 (Migrated from github.com)
Review

Loading all recipes on every page request might be inefficient if the recipe list is very large and rarely changes. Consider caching the recipe data if performance becomes an issue, especially for pages like index.php and login.php where the full list might not be immediately necessary for rendering.

Loading all recipes on every page request might be inefficient if the recipe list is very large and rarely changes. Consider caching the recipe data if performance becomes an issue, especially for pages like `index.php` and `login.php` where the full list might not be immediately necessary for rendering.
} catch (DatabaseUnavailableException $e) {
handle_database_unavailable($e);
}
// Language & inputs // Language & inputs
$lang = strtolower((string) (filter_input(INPUT_GET, 'lang', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?: 'en')) === 'de' ? 'de' : 'en'; $lang = strtolower((string) (filter_input(INPUT_GET, 'lang', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?: 'en')) === 'de' ? 'de' : 'en';
$q = trim((string) (filter_input(INPUT_GET, 'q', FILTER_UNSAFE_RAW) ?? '')); $q = trim((string) (filter_input(INPUT_GET, 'q', FILTER_UNSAFE_RAW) ?? ''));
@@ -84,7 +90,6 @@ $copy = [
$t = $copy[$lang]; $t = $copy[$lang];
// Data // Data
$allRecipes = load_recipes();
$localizedAll = localize_recipes($allRecipes, $lang); $localizedAll = localize_recipes($allRecipes, $lang);
$recipes = filter_recipes($allRecipes, $q ?: null, $tag ?: null, $lang); $recipes = filter_recipes($allRecipes, $q ?: null, $tag ?: null, $lang);
$recipes = array_values(array_filter($recipes, fn($r) => empty($r['coming_soon']))); $recipes = array_values(array_filter($recipes, fn($r) => empty($r['coming_soon'])));
@@ -920,43 +925,38 @@ $recipesJson = json_encode(array_values(array_map(function($r) use ($lang) {
}); });
} }
// Hook into auth state function filterRecipesByGoal(goal) {
window.addEventListener('DOMContentLoaded', function() { var filtered = indexRecipeBank;
if (window.auth && window.db) { if (goal === 'weight_loss') {
window.auth.onAuthStateChanged(function(user) { filtered = indexRecipeBank.filter(function(r) {
if (user) { return r.tags.some(function(t) {
window.db.collection('users').doc(user.uid).get() var lower = t.toLowerCase();
.then(function(doc) { return lower.includes('low-carb') || lower.includes('diet');
if (doc.exists) { }) || (r.category && r.category.toLowerCase().includes('salad'));
var goal = doc.data().goal;
var filtered = indexRecipeBank;
if (goal === 'weight_loss') {
filtered = indexRecipeBank.filter(r =>
r.tags.some(t => t.toLowerCase().includes('low-carb') || t.toLowerCase().includes('diet')) ||
(r.category && r.category.toLowerCase().includes('salad'))
);
} else if (goal === 'muscle_gain') {
filtered = indexRecipeBank.filter(r =>
r.tags.some(t => t.toLowerCase().includes('high-protein') || t.toLowerCase().includes('meat') || t.toLowerCase().includes('fleisch'))
);
} else if (goal === 'healthy') {
filtered = indexRecipeBank.filter(r =>
r.tags.some(t => t.toLowerCase().includes('vegetarian') || t.toLowerCase().includes('healthy'))
);
}
// fallback if empty
if (filtered.length === 0) filtered = indexRecipeBank;
renderSwipeCarousel(filtered, i18nGoal[goal] || null);
}
});
} else {
// Default unauthenticated view
renderSwipeCarousel(indexRecipeBank, null);
}
}); });
} else if (goal === 'muscle_gain') {
filtered = indexRecipeBank.filter(function(r) {
return r.tags.some(function(t) {
var lower = t.toLowerCase();
return lower.includes('high-protein') || lower.includes('meat') || lower.includes('fleisch');
});
});
} else if (goal === 'healthy') {
filtered = indexRecipeBank.filter(function(r) {
return r.tags.some(function(t) {
var lower = t.toLowerCase();
return lower.includes('vegetarian') || lower.includes('healthy');
});
});
}
if (filtered.length === 0) filtered = indexRecipeBank;
return filtered;
}
window.addEventListener('DOMContentLoaded', function() {
var goal = window.fcLocal ? window.fcLocal.getGoal() : '';
if (goal) {
renderSwipeCarousel(filterRecipesByGoal(goal), i18nGoal[goal] || null);
} else { } else {
renderSwipeCarousel(indexRecipeBank, null); renderSwipeCarousel(indexRecipeBank, null);
} }
1
+88 -258
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',
github-actions[bot] commented 2026-05-23 21:30:29 +00:00 (Migrated from github.com)
Review

This function maps internal goal identifiers to user-friendly labels. It's a clean way to handle translations and display logic.

This function maps internal goal identifiers to user-friendly labels. It's a clean way to handle translations and display logic.
'no_favorites' => "Du hast noch keine Rezepte gespeichert. Entdecke unsere Küche und klicke auf das Herz-Symbol!", 'no_favorites' => "Du hast noch keine Rezepte gespeichert. Entdecke unsere Küche und klicke auf das Herz-Symbol!",
'logout' => 'Abmelden', 'save_goal' => 'Ziel speichern',
'err_pass_match' => 'Die Passwörter stimmen nicht überein.', 'goal_saved' => 'Ernährungsziel gespeichert.',
'err_fill_fields'=> 'Bitte fülle alle Felder aus.', 'local_note' => 'Favoriten und Ziele werden nur in diesem Browser gespeichert.',
] ]
]; ];
@@ -56,10 +62,6 @@ $t = $copy[$lang];
$pageTitle = $t['page_title']; $pageTitle = $t['page_title'];
$description = $t['meta_desc']; $description = $t['meta_desc'];
// Check PHP session state
$user = $_SESSION['fc_user'] ?? null;
$allRecipes = load_recipes();
$allRecipesLocal = localize_recipes($allRecipes, $lang); $allRecipesLocal = localize_recipes($allRecipes, $lang);
// Formatter helper for JS usage // Formatter helper for JS usage
@@ -376,95 +378,37 @@ 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'; ?>;">
<div class="auth-tabs">
<button class="auth-tab active" onclick="switchTab('login')"><?php echo $t['login']; ?></button>
<button class="auth-tab" onclick="switchTab('signup')"><?php echo $t['signup']; ?></button>
</div>
<!-- Login Form -->
<form class="auth-form active" id="loginForm" onsubmit="handleLogin(event)">
<div class="auth-field">
<label for="loginEmail"><?php echo $t['email']; ?></label>
<input type="email" id="loginEmail" placeholder="you@example.com" required autocomplete="username">
</div>
<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="muscle_gain"><?php echo $t['goal_gain']; ?></option>
<option value="healthy"><?php echo $t['goal_healthy']; ?></option>
</select>
</div>
<div class="auth-btn-row">
<button class="btn-reveal" type="submit">
<span class="btn-reveal-text"><?php echo $t['signup']; ?></span>
<span class="spinner"></span>
<span class="btn-reveal-arrow">→</span>
</button>
</div>
<p class="auth-switch-text">
<?php echo $t['have_account']; ?> <button type="button" onclick="switchTab('login')"><?php echo $t['login']; ?></button>
</p>
</form>
</div>
<!-- ── 2. AUTHENTICATED STATE (Dashboard & Favorites) ── -->
<div class="profile-shell" id="profileShell" style="display: <?php echo $user ? 'grid' : 'none'; ?>;">
<aside class="profile-sidebar"> <aside class="profile-sidebar">
<div style="display: flex; align-items: center; gap: 1.5rem;"> <div class="profile-meta">
<div class="profile-avatar" id="avatarChar"> <p><?php echo $t['welcome']; ?></p>
<?php echo $user ? strtoupper(substr($user['email'], 0, 1)) : ''; ?> <p style="font-size: 0.9rem; color: var(--muted); margin-top: 0.5rem;"><?php echo e($t['local_note']); ?></p>
</div>
<div class="profile-meta">
<p><?php echo $t['welcome']; ?></p>
<h2 id="profileEmailVal"><?php echo $user ? e($user['email']) : ''; ?></h2>
</div>
</div> </div>
<div class="profile-details"> <form class="auth-form active" id="goalForm" onsubmit="handleSaveGoal(event)" style="margin-top: 1.5rem;">
<div class="auth-field">
<label for="profileGoal"><?php echo $t['goal']; ?></label>
<select id="profileGoal" required>
<option value="" disabled><?php echo $t['goal_placeholder']; ?></option>
<option value="weight_loss"><?php echo $t['goal_loose']; ?></option>
<option value="muscle_gain"><?php echo $t['goal_gain']; ?></option>
<option value="healthy"><?php echo $t['goal_healthy']; ?></option>
</select>
</div>
<div class="auth-btn-row">
<button class="btn-reveal" type="submit">
<span class="btn-reveal-text"><?php echo $t['save_goal']; ?></span>
<span class="btn-reveal-arrow">→</span>
</button>
</div>
</form>
<div class="profile-details" style="margin-top: 1.5rem;">
<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,195 +431,81 @@ 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) {
github-actions[bot] commented 2026-05-23 21:30:29 +00:00 (Migrated from github.com)
Review

This check ensures that favorite rendering only proceeds if window.fcLocal is available, preventing potential errors. This is good defensive programming.

This check ensures that favorite rendering only proceeds if `window.fcLocal` is available, preventing potential errors. This is good defensive programming.
github-actions[bot] commented 2026-05-23 21:30:29 +00:00 (Migrated from github.com)
Review

This check prevents errors if window.fcLocal isn't defined, ensuring the script doesn't crash. It's good defensive programming.

This check prevents errors if `window.fcLocal` isn't defined, ensuring the script doesn't crash. It's good defensive programming.
e.preventDefault(); e.preventDefault();
const email = document.getElementById('loginEmail').value; if (!window.fcLocal) return;
const pass = document.getElementById('loginPassword').value; const goal = document.getElementById('profileGoal').value;
const btn = e.target.querySelector('button[type="submit"]'); window.fcLocal.setGoal(goal);
const alertEl = document.getElementById('authAlert'); document.getElementById('profileGoalVal').textContent = goalLabel(goal);
showAlert(langText.goal_saved);
alertEl.style.display = 'none';
btn.disabled = true;
window.auth.signInWithEmailAndPassword(email, pass)
.then(userCredential => {
// Sync happens automatically via onAuthStateChanged observer in head.php!
// The observer will reload the page when it receives the new state
})
.catch(error => {
btn.disabled = false;
let errMsg = error.message;
if (error.code === 'auth/wrong-password' || error.code === 'auth/user-not-found') {
errMsg = "Invalid email or password.";
}
showAlert(errMsg);
});
} }
github-actions[bot] commented 2026-05-23 21:30:29 +00:00 (Migrated from github.com)
Review

This function dynamically renders the user's favorite recipes. It correctly handles cases where window.fcLocal is not available or when there are no favorites.

This function dynamically renders the user's favorite recipes. It correctly handles cases where `window.fcLocal` is not available or when there are no favorites.
function handleSignup(e) { function renderFavorites() {
e.preventDefault(); const grid = document.getElementById('favoritesGrid');
const email = document.getElementById('signupEmail').value; const emptyState = document.getElementById('favoritesEmpty');
const pass = document.getElementById('signupPassword').value; if (!window.fcLocal) return;
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'; const slugs = window.fcLocal.getFavorites();
grid.innerHTML = '';
if (pass !== confirm) { if (slugs.length === 0) {
showAlert(langText.err_pass_match); grid.style.display = 'none';
emptyState.style.display = 'block';
return; return;
} }
btn.disabled = true; emptyState.style.display = 'none';
grid.style.display = 'grid';
window.auth.createUserWithEmailAndPassword(email, pass) slugs.forEach(function(slug) {
.then(userCredential => { const recipe = recipeBank.find(function(r) { return r.slug === slug; });
const user = userCredential.user; if (!recipe) return;
// Save profile details to Firestore const card = document.createElement('article');
return window.db.collection('users').doc(user.uid).set({ card.className = 'card card--bare reveal-target revealed';
email: email, card.innerHTML =
goal: goal, '<a href="' + recipe.url + '" class="card__image">' +
createdAt: firebase.firestore.FieldValue.serverTimestamp() '<img src="' + recipe.hero + '" alt="' + recipe.title + '" loading="lazy">' +
}); '</a>' +
}) '<div class="card__body">' +
.then(() => { '<h4><a href="' + recipe.url + '">' + recipe.title + '</a></h4>' +
// Sync is handled by observer in head.php! '<div class="meta">' +
}) '<span>⏱ ' + recipe.total_time + ' min</span>' +
.catch(error => { '<span>🙂 ' + recipe.difficulty + '</span>' +
btn.disabled = false; '</div>' +
showAlert(error.message); '</div>';
}); grid.appendChild(card);
}
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 emptyState = document.getElementById('favoritesEmpty');
// Fetch user profile from Firestore
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 = '';
if (snapshot.empty) {
grid.style.display = 'none';
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
grid.style.display = 'grid';
snapshot.forEach(doc => {
const fav = doc.data();
const recipe = recipeBank.find(r => r.slug === fav.recipeSlug);
if (recipe) {
const card = document.createElement('article');
card.className = 'card card--bare reveal-target revealed';
card.innerHTML = `
<a href="${recipe.url}" class="card__image">
<img src="${recipe.hero}" alt="${recipe.title}" loading="lazy">
</a>
<div class="card__body">
<h4><a href="${recipe.url}">${recipe.title}</a></h4>
<div class="meta">
<span>⏱ ${recipe.total_time} min</span>
<span>🙂 ${recipe.difficulty}</span>
</div>
</div>
`;
grid.appendChild(card);
}
});
// If none of the favorite slugs matched recipes in current language:
if (grid.children.length === 0) {
grid.style.display = 'none';
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', () => {
window.auth.onAuthStateChanged(user => {
const authCard = document.getElementById('authCard');
const profileShell = document.getElementById('profileShell');
if (user) {
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';
}
}); });
if (grid.children.length === 0) {
grid.style.display = 'none';
emptyState.style.display = 'block';
}
}
window.addEventListener('DOMContentLoaded', function() {
if (!window.fcLocal) return;
const goal = window.fcLocal.getGoal();
const select = document.getElementById('profileGoal');
if (goal && select) select.value = goal;
document.getElementById('profileGoalVal').textContent = goalLabel(goal);
renderFavorites();
}); });
</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">
github-actions[bot] commented 2026-05-23 21:30:29 +00:00 (Migrated from github.com)
Review

Including a general stylesheet like /assets/style.css on a maintenance page is good for consistent branding and layout. Ensure this file is available and correctly linked even when the main application might be having issues.

Including a general stylesheet like `/assets/style.css` on a maintenance page is good for consistent branding and layout. Ensure this file is available and correctly linked even when the main application might be having issues.
<style>
body {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
text-align: center;
}
.db-error-card {
max-width: 32rem;
padding: 2rem;
border-radius: var(--radius, 16px);
border: 1px solid var(--stroke, rgba(255,255,255,0.12));
background: var(--surface-2, rgba(255,255,255,0.04));
}
.db-error-card h1 { margin-top: 0; }
.db-error-card code {
display: block;
margin: 1rem 0;
padding: 0.75rem;
font-size: 0.85rem;
text-align: left;
word-break: break-all;
background: rgba(0,0,0,0.2);
border-radius: 8px;
}
</style>
</head>
<body>
<div class="db-error-card">
<h1>Datenbank nicht verfügbar</h1>
<p>FlixCooks benötigt eine laufende PostgreSQL-Verbindung. Ohne Datenbank werden keine Rezepte angezeigt.</p>
<?php if (!empty($dbError)): ?>
github-actions[bot] commented 2026-05-23 21:30:29 +00:00 (Migrated from github.com)
Review

Displaying the specific $dbError message within a <code> block is excellent for debugging and providing actionable information to the user about what went wrong.

Displaying the specific `$dbError` message within a `<code>` block is excellent for debugging and providing actionable information to the user about what went wrong.
<code><?php echo htmlspecialchars($dbError, ENT_QUOTES, 'UTF-8'); ?></code>
<?php endif; ?>
<p><strong>Lokal prüfen:</strong></p>
<ol style="text-align: left; margin: 0 auto; max-width: 22rem;">
github-actions[bot] commented 2026-05-23 21:30:29 +00:00 (Migrated from github.com)
Review

Providing explicit, step-by-step instructions for local debugging on a maintenance page is a very user-friendly and helpful practice. This significantly aids users in resolving common local setup issues.

Providing explicit, step-by-step instructions for local debugging on a maintenance page is a very user-friendly and helpful practice. This significantly aids users in resolving common local setup issues.
<li><code>DATABASE_URL</code> in <code>.env</code> setzen</li>
<li><code>docker compose -f docker-compose.dev.yml up -d</code></li>
<li><code>php scripts/db-check.php</code></li>
<li>Leere DB: <code>php scripts/db-seed.php</code></li>
</ol>
</div>
</body>
</html>
+16 -92
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({ form.classList.add('success');
email: email.toLowerCase(), feedback.className = 'newsletter-feedback success';
subscribedAt: firebase.firestore.FieldValue.serverTimestamp(), feedback.textContent = '<?php echo $langCode === "de" ? "E-Mail-Programm geöffnet bitte absenden." : "Email client opened — please send."; ?>';
status: 'active'
})
.then(function() {
form.classList.add('success');
emailInput.disabled = true;
feedback.className = 'newsletter-feedback success';
feedback.textContent = '<?php echo $langCode === "de" ? "Erfolgreich abonniert!" : "Successfully subscribed!"; ?>';
})
.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);