diff --git a/.agents/TODO.md b/.agents/TODO.md index 8331ede..2e7c01c 100644 --- a/.agents/TODO.md +++ b/.agents/TODO.md @@ -12,10 +12,10 @@ Dieses Dokument enthält den aktuellen Entwicklungsstand und detaillierte Aufgab - Fullscreen Overlay-Navigation (`LiquidOverlayMenu` & `SlideUpTextHover`) - Geschmeidiges Scrollverhalten (`LenisSmoothScroll` & `IndexSectionIndicator`) - Layout-Raster (`FloemaLayoutGrid`) & Premium-Buttons -- [x] **Phase 2: Authentifizierung & Firebase Integration** - - Firebase-Projekt Setup & Anbindung - - Newsletter-System (Firebase Firestore) - - Benutzerprofile (Registrierung, Login, Favoriten) +- [x] **Phase 2: Profil & Personalisierung (lokal)** + - Ernährungsziel & Favoriten im Browser (`fc-local.js`) + - Newsletter per Mailto + - Profilseite `login.php` - [x] **Phase 3: Nährwerte & Rezepterweiterung** - Datenmodell & Admin.php Erweiterung - 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) - Filteralgorithmus nach Benutzer-Ernährungszielen - Mikro-Animationen & Quick-Save Funktion -- [/] **Phase 6: Firebase Firestore Datenbank-Migration & Seeding** - - Firebase CLI-Login & Projekt-Initialisierung - - .env-Konfiguration mit Firebase App Credentials - - Client-seitiger "Seed Firestore"-Button in admin.php - - Firestore REST API Integration in helpers.php - - Echtzeit-Zwei-Wege-Sync bei Rezeptänderungen in admin.php +- [x] **Phase 6: PostgreSQL Datenbank** + - `DATABASE_URL` in `.env`, Docker Compose für lokale Dev-DB + - `init_db()` / `save_recipes()` in `helpers.php` + - Fallback auf `data/recipes.json` --- @@ -66,26 +64,17 @@ Das visuelle Fundament der Website. Die Etablierung des Premium-Designs stellt s --- -### 🔒 Phase 2: Authentifizierung & Firebase Integration -Das Fundament für Personalisierung und Newsletter-Abos über eine sichere Firebase-Anbindung (Auth & Firestore). +### 🔒 Phase 2: Profil & Personalisierung (lokal) +Favoriten, Ernährungsziel und Newsletter ohne Cloud-Backend. -- [x] **Firebase Setup & Initialisierung** - - [x] Firebase SDK Client-Side Einbindung in `partials/head.php` or separaten Helper - - [x] Firebase Config in umweltabhängigen Settings/Variablen auslagern - - [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] **Profilseite `login.php`** + - [x] Ernährungsziel wählen und in `localStorage` speichern (`fc-local.js`) + - [x] Gespeicherte Favoriten anzeigen - [x] **Favoriten-Funktion (Rezept-Bookmarks)** - - [x] Firestore-Collection `user_favorites` anlegen (Format: `userId` -> Liste von `recipeSlugs`) - - [x] "Rezept speichern" Button (Herz-Icon) auf den Rezeptkarten und Rezept-Details mit dynamischem Login-Check - - [x] Mikro-Animationen für das Herz-Icon (Scale up/down, Fülleffekte mit `var(--ease-overshoot)`) -- [x] **Firebase-gestütztes Newsletter-Abo** - - [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) + - [x] Herz-Icon auf Karten, Speicherung in `localStorage` + - [x] Mikro-Animationen für das Herz-Icon +- [x] **Newsletter** + - [x] Footer-Formular mit `mailto:`-Weiterleitung --- @@ -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. - [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] **Fullscreen Cooking-Mode Overlay** - [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] Flüssiges Einblenden per Scroll-Reveal (`reveal-target`) mit Anbindung an `LenisSmoothScroll` - [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] Personalisierte "Für dich empfohlen"-Badge auf den Karten anzeigen - [x] **Mikro-Interaktionen & Quick-Save** - [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) --- -### 🔥 Phase 6: Firebase Firestore Datenbank-Migration & Seeding -Migration des lokalen JSON-Flachdateispeichers auf eine skalierbare, cloudbasierte Firestore-Struktur. +### 🔥 Phase 6: PostgreSQL Datenbank +Rezepte in Postgres (JSONB), optional lokal per Docker. -- [/] **Firebase-Projekt & CLI-Setup** - - [x] Firebase-Verzeichnis-Umgebung initialisieren und anbinden - - [/] Firebase CLI Login durchführen und aktiven Account verknüpfen - - [ ] Firebase Project & Web App ermitteln oder neu anlegen - - [ ] Lokale `.env`-Konfiguration mit Firebase SDK Parametern befüllen -- [ ] **Client-seitiges Daten-Seeding** - - [ ] "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 +- [x] **`DATABASE_URL` & Docker Compose** + - [x] `docker-compose.dev.yml` für lokale Postgres-Instanz + - [x] `init_db()` legt Tabelle `recipes` an und seedet aus `recipes.json` +- [x] **PHP-Datenzugriff** + - [x] `load_recipes()` / `save_recipes()` mit JSON-Fallback + - [x] `load_recipe_by_slug()` in `recipe.php` --- diff --git a/.agents/brain.md b/.agents/brain.md index a31ba54..9011b1e 100644 --- a/.agents/brain.md +++ b/.agents/brain.md @@ -4,11 +4,11 @@ This document summarizes architectural knowledge, conventions, and learnings for ## 1. Project Architecture & Stack - **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. - **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). -- **Config:** `config.php` loads `.env`, exposes `get_firebase_config()`, `get_db_connection()`. Never commit `.env` (see `.gitignore`). +- **Frontend:** Server-rendered PHP (`index.php`, `recipe.php`, …), Vanilla JS/CSS. Profile/favorites via `assets/fc-local.js`. +- **Config:** `config.php` loads `.env`, `get_db_connection()`. Never commit `.env` (see `.gitignore`). ## 2. Design & Aesthetics - **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 -### Table `recipes` (only app table today) -Created by `init_db()` in `helpers.php` if missing: +### Relational schema (`scripts/schema.sql`) +Created by `ensure_recipe_schema()` on first DB use. Legacy JSONB `recipes.data` is migrated once automatically. -| Column | Type | Role | -|-------------|-------------|------| -| `slug` | `VARCHAR(255)` PRIMARY KEY | Stable recipe ID (URLs: `recipe.php?slug=…`) | -| `data` | `JSONB` NOT NULL | **Entire recipe document** (title, i18n, ingredients, steps, nutrition, …) | -| `created_at`| `TIMESTAMP` | Auto on insert | -| `updated_at`| `TIMESTAMP` | Set on `ON CONFLICT` update in `save_recipes()` | +| Table | Role | +|-------|------| +| `recipes` | slug, hero, times, servings, nutrition columns, featured, coming_soon | +| `recipe_translations` | title, description, category, difficulty (en/de) | +| `recipe_tags`, `recipe_ingredients`, `recipe_utensils`, `recipe_steps` | Ordered lists per language | -**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 -1. `get_db_connection()` in `config.php` parses `DATABASE_URL` → PDO `pgsql:` DSN. -2. If no URL or connection fails → `load_recipes_local()` reads `data/recipes.json` only. -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`. -4. `load_recipes()` / `load_recipe_by_slug()` decode `data` JSONB to PHP arrays. -5. `save_recipes()` (admin): upsert all recipes in Postgres **and** write `data/recipes.json` as backup. +1. `DATABASE_URL` required → `require_database()` or HTTP 503 (`maintenance/db-unavailable.php`). +2. `load_recipes()` → SQL → PHP arrays for templates. +3. Admin: `save_recipe()`, `delete_recipe()`, `clear_featured_recipes()`. +4. One-time import: `php scripts/db-seed.php` from `data/recipes.json` (not read at runtime). ### `config.php` functions -- `load_env()` — parses `.env` into `getenv()` / `$_ENV` / `$_SERVER`. -- `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()` — returns `PDO` or `null`; logs failures, does not throw. +- `load_env()` — parses `.env`. +- `get_db_connection()` — PDO or `null`. +- `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) -- 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). - 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 - Copy from `.env.example`; never commit `.env`. - **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. -- **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). +- **`DATABASE_URL` is mandatory** for recipe pages; omitting it shows the DB unavailable page. ### Environment separation (important) - One database per environment (local Docker / staging / production). - Never point a dev branch `.env` at production Postgres. -- Export/import between envs: `pg_dump` / `psql` when needed; document URLs in 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 | |--------|--------|-----| -| 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: connection failed, host `postgres.railway.internal` | Internal Railway hostname from local machine | Use Docker local URL or Railway public URL | -| 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 | +| DB OK but 0 recipes | Empty tables | `php scripts/db-seed.php` | --- ## 8. Completed Milestones - **Phase 3 (Nutrition):** `calories`, `protein`, `carbs`, `fat` on recipes; admin + UI. - **Phase 4 (Cooking Mode):** `step_videos`, `step_timers`; fullscreen overlay. -- **Phase 6 (Postgres):** `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`. -- **Bugfix (May 2026):** Restored `get_firebase_config()` after postgres migration regression. -- **Local profile data:** Favorites/goals via `assets/fc-local.js` (`localStorage`). Newsletter may use `mailto:` or Firestore depending on page. +- **Local profile data:** Favorites/goals via `assets/fc-local.js` (`localStorage`). Newsletter via `mailto:`. ## 9. Next Steps See `.agents/TODO.md` (e.g. PWA & offline support). README `Local Postgres (Docker)` and `Postgres in diesem Projekt` sections mirror setup for humans. diff --git a/.env.example b/.env.example index b1d9ce7..744a47b 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # FlixCooks – lokale Entwicklung (.env wird nicht committed) # Kopieren: cp .env.example .env -# --- Postgres (docker-compose.dev.yml) --- -# Nur setzen, wenn du die lokale DB testen willst. Ohne DATABASE_URL → Fallback auf data/recipes.json +# --- Postgres (Pflicht für die Website) --- +# docker compose -f docker-compose.dev.yml up -d DATABASE_URL="postgresql://flixcooks:flixcooks_dev@127.0.0.1:5432/flixcooks_dev" diff --git a/.firebaserc b/.firebaserc deleted file mode 100644 index 8d0ccc0..0000000 --- a/.firebaserc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "projects": { - "default": "flixcooks" - } -} diff --git a/README.md b/README.md index 6b39884..291170f 100644 --- a/README.md +++ b/README.md @@ -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). - **Support & Layouts**: - `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`. - `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 - **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. --- @@ -56,7 +57,8 @@ Make sure you have the following installed on your local machine: ```bash 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) @@ -99,14 +101,17 @@ SELECT slug, data->>'title' AS title FROM recipes, jsonb_to_record(data) AS x(ti \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 docker compose -f docker-compose.dev.yml down -v 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 @@ -130,27 +135,29 @@ If you prefer running a full local stack: ## 🗄️ 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 | -|-------------|------------|-----------| -| `slug` | `VARCHAR` | Eindeutige ID des Rezepts (URL: `/recipe.php?slug=...`) | -| `data` | `JSONB` | **Gesamtes** Rezept als JSON (Titel, Zutaten, Schritte, i18n, …) | -| `created_at`| `TIMESTAMP`| Erstellzeit | -| `updated_at`| `TIMESTAMP`| Letzte Änderung (wird beim Update gesetzt) | +| Tabelle | Inhalt | +|---------|--------| +| `recipes` | Slug, Zeiten, Hero-URL, Nährwerte, `featured`, `coming_soon` | +| `recipe_translations` | Titel, Beschreibung, Kategorie, Schwierigkeit (EN/DE) | +| `recipe_tags` | Tags pro Sprache | +| `recipe_ingredients` | Zutatenzeilen | +| `recipe_utensils` | Werkzeugzeilen | +| `recipe_steps` | Schritte inkl. Video-URL und Timer | -**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. -2. `init_db()` in `helpers.php` erstellt `recipes`, falls nicht vorhanden. -3. Ist die Tabelle leer → Import aus `data/recipes.json`. -4. `load_recipes()` liest alle Zeilen, dekodiert `data` zurück zu PHP-Arrays. +1. `DATABASE_URL` in `.env` → Verbindung über `config.php`. +2. Beim ersten Request: Tabellen anlegen (`ensure_recipe_schema()`). Alte JSONB-Tabelle wird einmalig migriert. +3. `load_recipes()` liest per SQL; ohne DB → HTTP 503 (`maintenance/db-unavailable.php`). +4. Admin: `save_recipe()` / `delete_recipe()` – direkt in die Tabellen. -**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. --- diff --git a/admin.php b/admin.php index f632277..36ae186 100644 --- a/admin.php +++ b/admin.php @@ -49,7 +49,11 @@ if (!$authed) { exit; } -$allRecipes = load_recipes(); +try { + $allRecipes = load_recipes(); +} catch (DatabaseUnavailableException $e) { + handle_database_unavailable($e); +} $siteSettings = load_site_settings(); $message = null; $errors = []; @@ -165,12 +169,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete') { $slugDel = trim($_POST['slug'] ?? ''); - $before = count($allRecipes); - $allRecipes = array_values(array_filter($allRecipes, fn($r) => ($r['slug'] ?? '') !== $slugDel)); - if ($before !== count($allRecipes) && save_recipes($allRecipes)) { + if ($slugDel !== '' && delete_recipe($slugDel)) { $message = 'Recipe deleted.'; + $allRecipes = load_recipes(); } else { - $message = 'Could not delete. Check permissions.'; + $message = 'Could not delete recipe.'; } $editing = 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 = [ 'slug' => $slug, 'hero' => $hero, @@ -309,16 +305,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet ], ]; - if ($slugOriginal && $editIndex !== null) { - $allRecipes[$editIndex] = $record; - } else { - $allRecipes[] = $record; + if ($featured) { + clear_featured_recipes(); + } + if ($slugOriginal && $slugOriginal !== $slug) { + delete_recipe($slugOriginal); } - if (save_recipes($allRecipes)) { + if (save_recipe($record)) { $message = $slugOriginal ? 'Saved changes.' : 'Saved! New recipe added.'; + $allRecipes = load_recipes(); } 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; } .pill { background: rgba(255,255,255,0.06); color: var(--ink); } - - - - -
@@ -388,9 +374,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet Recipes Site settings
- - - View site Back @@ -654,65 +637,5 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet - - - - - - - - - - - - diff --git a/api/session.php b/api/session.php deleted file mode 100644 index 9aecee2..0000000 --- a/api/session.php +++ /dev/null @@ -1,40 +0,0 @@ - $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']); -} diff --git a/assets/fc-local.js b/assets/fc-local.js new file mode 100644 index 0000000..7df4708 --- /dev/null +++ b/assets/fc-local.js @@ -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); diff --git a/config.php b/config.php index 349655c..2422301 100644 --- a/config.php +++ b/config.php @@ -34,15 +34,8 @@ function load_env() { // Automatically load on include load_env(); -function get_firebase_config(): array { - 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') ?: '', - ]; +class DatabaseUnavailableException extends RuntimeException +{ } /** diff --git a/helpers.php b/helpers.php index d1b0147..d9cb7cf 100644 --- a/helpers.php +++ b/helpers.php @@ -7,136 +7,371 @@ if (session_status() === PHP_SESSION_NONE) { require_once __DIR__ . '/config.php'; -function init_db() { - $pdo = get_db_connection(); - if (!$pdo) return; - - try { - $pdo->exec("CREATE TABLE IF NOT EXISTS recipes ( - slug VARCHAR(255) PRIMARY KEY, - data JSONB NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )"); - - $stmt = $pdo->query("SELECT COUNT(*) FROM recipes"); - if ($stmt && $stmt->fetchColumn() == 0) { - $path = __DIR__ . '/data/recipes.json'; - if (file_exists($path)) { - $json = file_get_contents($path); - $data = json_decode($json, true); - if (is_array($data) && count($data) > 0) { - $insert = $pdo->prepare("INSERT INTO recipes (slug, data) VALUES (:slug, :data)"); - foreach ($data as $recipe) { - if (isset($recipe['slug'])) { - $insert->execute([ - 'slug' => $recipe['slug'], - 'data' => json_encode($recipe, JSON_UNESCAPED_UNICODE) - ]); - } - } - } - } +function handle_database_unavailable(DatabaseUnavailableException $e): void { + http_response_code(503); + $dbError = $e->getMessage(); + include __DIR__ . '/maintenance/db-unavailable.php'; + exit; +} + +function db_table_has_column(PDO $pdo, string $table, string $column): bool { + $stmt = $pdo->prepare( + 'SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?' + ); + $stmt->execute([$table, $column]); + return (bool) $stmt->fetchColumn(); +} + +function apply_recipe_schema(PDO $pdo): void { + $path = __DIR__ . '/scripts/schema.sql'; + if (!file_exists($path)) { + throw new RuntimeException('Missing scripts/schema.sql'); + } + $pdo->exec(file_get_contents($path)); +} + +function recipe_empty_i18n(): array { + return [ + 'title' => '', + 'description' => '', + 'category' => '', + 'difficulty' => '', + 'tags' => [], + 'ingredients' => [], + 'utensils' => [], + 'steps' => [], + 'step_videos' => [], + 'step_timers' => [], + ]; +} + +function recipe_base_from_row(array $row): array { + return [ + 'slug' => $row['slug'], + 'hero' => $row['hero'] ?? '', + 'prep_time' => (int) ($row['prep_time'] ?? 0), + 'cook_time' => (int) ($row['cook_time'] ?? 0), + 'total_time' => (int) ($row['total_time'] ?? 0), + 'servings' => (int) ($row['servings'] ?? 2), + 'featured' => (bool) ($row['featured'] ?? false), + 'coming_soon' => (bool) ($row['coming_soon'] ?? false), + 'nutrition' => [ + 'calories' => (int) ($row['calories'] ?? 0), + 'protein' => (int) ($row['protein'] ?? 0), + 'carbs' => (int) ($row['carbs'] ?? 0), + 'fat' => (int) ($row['fat'] ?? 0), + ], + 'i18n' => [ + 'en' => recipe_empty_i18n(), + 'de' => recipe_empty_i18n(), + ], + ]; +} + +function decode_legacy_jsonb_value($value): ?array { + if (is_array($value)) { + return $value; + } + if (is_string($value)) { + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : null; + } + if ($value !== null) { + $decoded = json_decode(json_encode($value), true); + return is_array($decoded) ? $decoded : null; + } + return null; +} + +function migrate_legacy_jsonb_storage(PDO $pdo): void { + if (!db_table_has_column($pdo, 'recipes', 'data')) { + return; + } + + $legacy = []; + $stmt = $pdo->query('SELECT slug, data FROM recipes'); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $recipe = decode_legacy_jsonb_value($row['data']); + if (is_array($recipe) && !empty($recipe['slug'])) { + $legacy[] = $recipe; } - } 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'); + + apply_recipe_schema($pdo); + + foreach ($legacy as $recipe) { + save_recipe($recipe, $pdo); } } -function load_recipes_local(): array { +function ensure_recipe_schema(PDO $pdo): void { + static $ready = false; + if ($ready) { + return; + } + + if (db_table_has_column($pdo, 'recipes', 'data') && !db_table_has_column($pdo, 'recipes', 'calories')) { + migrate_legacy_jsonb_storage($pdo); + $ready = true; + return; + } + + apply_recipe_schema($pdo); + $ready = true; +} + +function require_database(): PDO { $pdo = get_db_connection(); - $data = []; - if (!$pdo) { - $path = __DIR__ . '/data/recipes.json'; - if (file_exists($path)) { - $json = file_get_contents($path); - $data = json_decode($json, true) ?: []; + $message = getenv('DATABASE_URL') + ? 'Database connection failed. Check DATABASE_URL and that Postgres is running.' + : 'DATABASE_URL is not set in .env.'; + throw new DatabaseUnavailableException($message); + } + + ensure_recipe_schema($pdo); + return $pdo; +} + +function init_db(): void { + require_database(); +} + +function hydrate_recipes_from_db(PDO $pdo): array { + $recipes = []; + $stmt = $pdo->query( + 'SELECT * FROM recipes ORDER BY featured DESC, updated_at DESC, slug ASC' + ); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $recipes[$row['slug']] = recipe_base_from_row($row); + } + + if ($recipes === []) { + return []; + } + + $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; } - } else { - static $initialized = false; - if (!$initialized) { - init_db(); - $initialized = true; - } - - try { - $stmt = $pdo->query("SELECT data FROM recipes"); - if ($stmt) { - while ($row = $stmt->fetch()) { - $recipe = json_decode($row['data'], true); - if (is_array($recipe)) { - $data[] = $recipe; - } - } - } - } catch (\Exception $e) { - error_log("Failed to load recipes from DB: " . $e->getMessage()); + $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'])) { $recipe['hero'] = normalize_asset_path($recipe['hero']); } } unset($recipe); - return $data; + return $list; } 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 { - 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 { - $pdo = get_db_connection(); - $path = __DIR__ . '/data/recipes.json'; - - if (!$pdo) { - $json = json_encode($recipes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - return (bool) file_put_contents($path, $json); +function clear_featured_recipes(?PDO $pdo = null): void { + $pdo = $pdo ?? require_database(); + $pdo->exec('UPDATE recipes SET featured = FALSE'); +} + +function delete_recipe(string $slug): bool { + $pdo = require_database(); + $stmt = $pdo->prepare('DELETE FROM recipes WHERE slug = ?'); + $stmt->execute([$slug]); + return $stmt->rowCount() > 0; +} + +function save_recipe(array $recipe, ?PDO $pdo = null): bool { + if (empty($recipe['slug'])) { + return false; } - + + $pdo = $pdo ?? require_database(); + $nutrition = $recipe['nutrition'] ?? []; + $slug = $recipe['slug']; + try { $pdo->beginTransaction(); - $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) { - if (isset($recipe['slug'])) { - $slugs[] = $recipe['slug']; - $insert->execute([ - 'slug' => $recipe['slug'], - 'data' => json_encode($recipe, JSON_UNESCAPED_UNICODE) - ]); + + $stmt = $pdo->prepare( + 'INSERT INTO recipes ( + slug, hero, prep_time, cook_time, total_time, servings, + featured, coming_soon, calories, protein, carbs, fat, updated_at + ) VALUES ( + :slug, :hero, :prep_time, :cook_time, :total_time, :servings, + :featured, :coming_soon, :calories, :protein, :carbs, :fat, NOW() + ) + ON CONFLICT (slug) DO UPDATE SET + hero = EXCLUDED.hero, + prep_time = EXCLUDED.prep_time, + cook_time = EXCLUDED.cook_time, + total_time = EXCLUDED.total_time, + servings = EXCLUDED.servings, + featured = EXCLUDED.featured, + coming_soon = EXCLUDED.coming_soon, + calories = EXCLUDED.calories, + protein = EXCLUDED.protein, + carbs = EXCLUDED.carbs, + fat = EXCLUDED.fat, + updated_at = NOW()' + ); + $stmt->bindValue(':slug', $slug); + $stmt->bindValue(':hero', $recipe['hero'] ?? ''); + $stmt->bindValue(':prep_time', (int) ($recipe['prep_time'] ?? 0), PDO::PARAM_INT); + $stmt->bindValue(':cook_time', (int) ($recipe['cook_time'] ?? 0), PDO::PARAM_INT); + $stmt->bindValue(':total_time', (int) ($recipe['total_time'] ?? 0), PDO::PARAM_INT); + $stmt->bindValue(':servings', (int) ($recipe['servings'] ?? 2), PDO::PARAM_INT); + $stmt->bindValue(':featured', !empty($recipe['featured']), PDO::PARAM_BOOL); + $stmt->bindValue(':coming_soon', !empty($recipe['coming_soon']), PDO::PARAM_BOOL); + $stmt->bindValue(':calories', (int) ($nutrition['calories'] ?? 0), PDO::PARAM_INT); + $stmt->bindValue(':protein', (int) ($nutrition['protein'] ?? 0), PDO::PARAM_INT); + $stmt->bindValue(':carbs', (int) ($nutrition['carbs'] ?? 0), PDO::PARAM_INT); + $stmt->bindValue(':fat', (int) ($nutrition['fat'] ?? 0), PDO::PARAM_INT); + $stmt->execute(); + + $pdo->prepare('DELETE FROM recipe_translations WHERE recipe_slug = ?')->execute([$slug]); + $pdo->prepare('DELETE FROM recipe_tags WHERE recipe_slug = ?')->execute([$slug]); + $pdo->prepare('DELETE FROM recipe_ingredients WHERE recipe_slug = ?')->execute([$slug]); + $pdo->prepare('DELETE FROM recipe_utensils WHERE recipe_slug = ?')->execute([$slug]); + $pdo->prepare('DELETE FROM recipe_steps WHERE recipe_slug = ?')->execute([$slug]); + + $translationStmt = $pdo->prepare( + 'INSERT INTO recipe_translations (recipe_slug, lang, title, description, category, difficulty) + VALUES (?, ?, ?, ?, ?, ?)' + ); + $tagStmt = $pdo->prepare( + 'INSERT INTO recipe_tags (recipe_slug, lang, tag, sort_order) VALUES (?, ?, ?, ?)' + ); + $ingredientStmt = $pdo->prepare( + 'INSERT INTO recipe_ingredients (recipe_slug, lang, content, sort_order) VALUES (?, ?, ?, ?)' + ); + $utensilStmt = $pdo->prepare( + 'INSERT INTO recipe_utensils (recipe_slug, lang, content, sort_order) VALUES (?, ?, ?, ?)' + ); + $stepStmt = $pdo->prepare( + 'INSERT INTO recipe_steps (recipe_slug, lang, content, video_url, timer_minutes, sort_order) + VALUES (?, ?, ?, ?, ?, ?)' + ); + + foreach (['en', 'de'] as $lang) { + $block = $recipe['i18n'][$lang] ?? []; + $translationStmt->execute([ + $slug, + $lang, + $block['title'] ?? '', + $block['description'] ?? '', + $block['category'] ?? '', + $block['difficulty'] ?? '', + ]); + + foreach (array_values($block['tags'] ?? []) as $i => $tag) { + $tag = trim((string) $tag); + if ($tag !== '') { + $tagStmt->execute([$slug, $lang, $tag, $i]); + } + } + + 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(); - - // Also update local JSON as backup - $json = json_encode($recipes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - file_put_contents($path, $json); - return true; - } catch (Exception $e) { + } catch (\Exception $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } - error_log("Failed to save recipes to DB: " . $e->getMessage()); + error_log('save_recipe failed: ' . $e->getMessage()); return false; } } diff --git a/index.php b/index.php index ed2c95a..894e4de 100644 --- a/index.php +++ b/index.php @@ -1,6 +1,12 @@ empty($r['coming_soon']))); @@ -920,43 +925,38 @@ $recipesJson = json_encode(array_values(array_map(function($r) use ($lang) { }); } - // Hook into auth state - window.addEventListener('DOMContentLoaded', function() { - if (window.auth && window.db) { - window.auth.onAuthStateChanged(function(user) { - if (user) { - window.db.collection('users').doc(user.uid).get() - .then(function(doc) { - if (doc.exists) { - var goal = doc.data().goal; - var filtered = indexRecipeBank; - - 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); - } + function filterRecipesByGoal(goal) { + var filtered = indexRecipeBank; + if (goal === 'weight_loss') { + filtered = indexRecipeBank.filter(function(r) { + return r.tags.some(function(t) { + var lower = t.toLowerCase(); + return lower.includes('low-carb') || lower.includes('diet'); + }) || (r.category && r.category.toLowerCase().includes('salad')); }); + } 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 { renderSwipeCarousel(indexRecipeBank, null); } diff --git a/login.php b/login.php index 5fddef4..cb1f87a 100644 --- a/login.php +++ b/login.php @@ -1,6 +1,12 @@ 'Your dietary goal', 'saved_recipes' => 'Your Saved Favorites', 'no_favorites' => "You haven't saved any recipes yet. Explore our collection and tap the heart icon!", - 'logout' => 'Log Out', - 'err_pass_match' => 'Passwords do not match.', - 'err_fill_fields'=> 'Please fill in all fields.', + 'save_goal' => 'Save goal', + 'goal_saved' => 'Dietary goal saved.', + 'local_note' => 'Favorites and goals are stored in this browser only.', ], 'de' => [ 'page_title' => 'Mein Profil & Favoriten | FlixCooks', @@ -46,9 +52,9 @@ $copy = [ 'your_goal' => 'Dein Ernährungsziel', 'saved_recipes' => 'Deine gespeicherten Favoriten', 'no_favorites' => "Du hast noch keine Rezepte gespeichert. Entdecke unsere Küche und klicke auf das Herz-Symbol!", - 'logout' => 'Abmelden', - 'err_pass_match' => 'Die Passwörter stimmen nicht überein.', - 'err_fill_fields'=> 'Bitte fülle alle Felder aus.', + 'save_goal' => 'Ziel speichern', + 'goal_saved' => 'Ernährungsziel gespeichert.', + 'local_note' => 'Favoriten und Ziele werden nur in diesem Browser gespeichert.', ] ]; @@ -56,10 +62,6 @@ $t = $copy[$lang]; $pageTitle = $t['page_title']; $description = $t['meta_desc']; -// Check PHP session state -$user = $_SESSION['fc_user'] ?? null; - -$allRecipes = load_recipes(); $allRecipesLocal = localize_recipes($allRecipes, $lang); // Formatter helper for JS usage @@ -376,95 +378,37 @@ include __DIR__ . '/partials/header.php';
- -
-
- - -
- - -
-
- - -
-
- - -
-
- -
-

- -

-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-

- -

-
-
- - -
+
@@ -487,195 +431,81 @@ include __DIR__ . '/partials/header.php';
diff --git a/maintenance/db-unavailable.php b/maintenance/db-unavailable.php new file mode 100644 index 0000000..8cfbc41 --- /dev/null +++ b/maintenance/db-unavailable.php @@ -0,0 +1,52 @@ + + + + + + FlixCooks – Datenbank nicht verfügbar + + + + +
+

Datenbank nicht verfügbar

+

FlixCooks benötigt eine laufende PostgreSQL-Verbindung. Ohne Datenbank werden keine Rezepte angezeigt.

+ + + +

Lokal prüfen:

+
    +
  1. DATABASE_URL in .env setzen
  2. +
  3. docker compose -f docker-compose.dev.yml up -d
  4. +
  5. php scripts/db-check.php
  6. +
  7. Leere DB: php scripts/db-seed.php
  8. +
+
+ + diff --git a/partials/footer.php b/partials/footer.php index 792a0c9..34eda16 100644 --- a/partials/footer.php +++ b/partials/footer.php @@ -61,37 +61,23 @@ })(); -// ── Newsletter subscription logic ────────────────────────── +// ── Newsletter (mailto) ──────────────────────────────────── function handleSubscribe(e) { e.preventDefault(); var emailInput = document.getElementById('newsletterEmail'); var feedback = document.getElementById('newsletterFeedback'); var form = document.getElementById('newsletterForm'); - var btn = form.querySelector('button'); var email = emailInput.value.trim(); if (!email) return; - btn.disabled = true; - feedback.className = 'newsletter-feedback'; - feedback.textContent = ''; + var subject = encodeURIComponent('FlixCooks Newsletter'); + var body = encodeURIComponent('Please add me to the newsletter: ' + email); + window.location.href = 'mailto:?subject=' + subject + '&body=' + body; - window.db.collection('subscribers').doc(email.toLowerCase()).set({ - email: email.toLowerCase(), - subscribedAt: firebase.firestore.FieldValue.serverTimestamp(), - status: 'active' - }) - .then(function() { - form.classList.add('success'); - emailInput.disabled = true; - feedback.className = 'newsletter-feedback success'; - feedback.textContent = ''; - }) - .catch(function(error) { - btn.disabled = false; - feedback.className = 'newsletter-feedback error'; - feedback.textContent = '' + error.message; - }); + form.classList.add('success'); + feedback.className = 'newsletter-feedback success'; + feedback.textContent = ''; } // ── Guest Modal Logic ────────────────────────────────────── @@ -121,33 +107,12 @@ document.addEventListener('click', function(e) { } }); -// ── Favorites Toggle & Management ─────────────────────────── -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(); -} - +// ── Favorites (localStorage) ─────────────────────────────── function updateHeartStates() { + if (!window.fcLocal) return; document.querySelectorAll('.btn-favorite').forEach(function(btn) { var slug = btn.getAttribute('data-slug'); - if (userFavSlugs.has(slug)) { + if (slug && window.fcLocal.hasFavorite(slug)) { btn.classList.add('active'); } else { btn.classList.remove('active'); @@ -156,43 +121,12 @@ function updateHeartStates() { } function toggleFavorite(slug, btn) { - if (!currentUser) { + if (!window.fcLocal) { openGuestModal(); return; } - - var docId = currentUser.uid + '_' + slug; - 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'); - }); - } + window.fcLocal.toggleFavorite(slug); + updateHeartStates(); } // 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() { - if (window.auth) { - window.auth.onAuthStateChanged(function(user) { - currentUser = user; - if (user) { - syncFavorites(user.uid); - } else { - clearFavorites(); - } - }); - } + updateHeartStates(); }); })(); @@ -228,10 +152,10 @@ window.addEventListener('DOMContentLoaded', function() {
❤️

-

+

- + diff --git a/partials/head.php b/partials/head.php index 68f9746..0636bb0 100644 --- a/partials/head.php +++ b/partials/head.php @@ -19,49 +19,7 @@ $description = $description ?? 'Seasonal recipes, tested tips, and approachable - - - - - - + diff --git a/partials/header.php b/partials/header.php index 3ce6e5b..e554769 100644 --- a/partials/header.php +++ b/partials/header.php @@ -58,8 +58,8 @@ $langParamAmp = isset($lang) && $lang === 'de' ? '&lang=de' : '';
  • > - - + +
  • diff --git a/recipe.php b/recipe.php index 0259b04..2b434b0 100644 --- a/recipe.php +++ b/recipe.php @@ -1,6 +1,12 @@ [ @@ -46,7 +52,6 @@ $copy = [ ]; $t = $copy[$lang]; -$allRecipes = load_recipes(); $slug = isset($_GET['slug']) ? trim($_GET['slug']) : ''; $recipeRaw = $slug ? load_recipe_by_slug($slug) : null; $recipe = $recipeRaw ? localize_recipe($recipeRaw, $lang) : null; diff --git a/scripts/db-check.php b/scripts/db-check.php index 54f3e57..83869d3 100644 --- a/scripts/db-check.php +++ b/scripts/db-check.php @@ -18,20 +18,26 @@ if (!$url) { exit(1); } -$pdo = get_db_connection(); -if (!$pdo) { - fwrite(STDERR, "Keine DB-Verbindung zu: {$url}\n"); +try { + $pdo = require_database(); +} catch (DatabaseUnavailableException $e) { + fwrite(STDERR, $e->getMessage() . "\n"); fwrite(STDERR, "Prüfe: Docker läuft? docker compose -f docker-compose.dev.yml ps\n"); exit(1); } -init_db(); - $count = (int) $pdo->query('SELECT COUNT(*) FROM recipes')->fetchColumn(); echo "Verbindung OK. Rezepte in DB: {$count}\n"; -if ($count > 0) { - $stmt = $pdo->query("SELECT slug, data->'i18n'->'en'->>'title' AS title FROM recipes LIMIT 5"); +if ($count === 0) { + echo "Hinweis: Leere DB – einmalig seeden mit: php scripts/db-seed.php\n"; +} else { + $stmt = $pdo->query( + "SELECT r.slug, t.title + FROM recipes r + LEFT JOIN recipe_translations t ON t.recipe_slug = r.slug AND t.lang = 'en' + LIMIT 5" + ); echo "\nBeispiel-Zeilen:\n"; while ($row = $stmt->fetch()) { echo " - {$row['slug']}: {$row['title']}\n"; diff --git a/scripts/db-seed.php b/scripts/db-seed.php new file mode 100644 index 0000000..3d9184f --- /dev/null +++ b/scripts/db-seed.php @@ -0,0 +1,43 @@ +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"; diff --git a/scripts/schema.sql b/scripts/schema.sql new file mode 100644 index 0000000..7aa5983 --- /dev/null +++ b/scripts/schema.sql @@ -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);