From e7f35d71e34e98bb5c19351c5f6192f0a02c2d0c Mon Sep 17 00:00:00 2001 From: LordSchmackes Date: Fri, 22 May 2026 19:07:03 +0200 Subject: [PATCH 01/12] postgres database implementation --- config.php | 47 ++++++++++--- helpers.php | 196 +++++++++++++++++++++++++++++----------------------- 2 files changed, 146 insertions(+), 97 deletions(-) diff --git a/config.php b/config.php index 869259c..441d1a5 100644 --- a/config.php +++ b/config.php @@ -34,13 +34,42 @@ 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') ?: '', - ]; +/** + * Get a PDO connection to the database. + */ +function get_db_connection(): ?PDO { + static $pdo = null; + + if ($pdo !== null) { + return $pdo; + } + + $url = getenv('DATABASE_URL'); + if (!$url) { + return null; + } + + $parsedUrl = parse_url($url); + if ($parsedUrl === false || !isset($parsedUrl['host'], $parsedUrl['user'], $parsedUrl['pass'], $parsedUrl['path'])) { + return null; + } + + $host = $parsedUrl['host']; + $port = $parsedUrl['port'] ?? 5432; + $user = $parsedUrl['user']; + $pass = $parsedUrl['pass']; + $db = ltrim($parsedUrl['path'], '/'); + + $dsn = "pgsql:host=$host;port=$port;dbname=$db"; + + try { + $pdo = new PDO($dsn, $user, $pass, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + return $pdo; + } catch (PDOException $e) { + error_log("Database connection failed: " . $e->getMessage()); + return null; + } } diff --git a/helpers.php b/helpers.php index 5379f03..21f8045 100644 --- a/helpers.php +++ b/helpers.php @@ -7,54 +7,72 @@ if (session_status() === PHP_SESSION_NONE) { require_once __DIR__ . '/config.php'; -function decode_firestore_value($value) { - if (!is_array($value)) return $value; - if (isset($value['stringValue'])) return $value['stringValue']; - if (isset($value['integerValue'])) return (int)$value['integerValue']; - if (isset($value['doubleValue'])) return (float)$value['doubleValue']; - if (isset($value['booleanValue'])) return (bool)$value['booleanValue']; - if (isset($value['nullValue'])) return null; - if (isset($value['arrayValue']['values'])) { - return array_map('decode_firestore_value', $value['arrayValue']['values']); +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) + ]); + } + } + } + } + } + } catch (PDOException $e) { + error_log("DB Init Error: " . $e->getMessage()); } - if (isset($value['mapValue']['fields'])) { - return array_map('decode_firestore_value', $value['mapValue']['fields']); - } - return $value; -} - -function decode_firestore_document(array $doc): array { - if (!isset($doc['fields'])) return []; - return array_map('decode_firestore_value', $doc['fields']); -} - -function firestore_get(string $url): ?array { - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($httpCode !== 200 || !$response) { - return null; - } - - return json_decode($response, true); } function load_recipes_local(): array { - $path = __DIR__ . '/data/recipes.json'; - if (!file_exists($path)) { - return []; - } - $json = file_get_contents($path); - $data = json_decode($json, true); - if (!is_array($data)) { - return []; + $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) ?: []; + } + } 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 (PDOException $e) { + error_log("Failed to load recipes from DB: " . $e->getMessage()); + } } foreach ($data as &$recipe) { @@ -68,57 +86,59 @@ function load_recipes_local(): array { } function load_recipes(): array { - $config = get_firebase_config(); - $projectId = $config['projectId'] ?? ''; - if (empty($projectId)) { - return load_recipes_local(); - } - - $url = "https://firestore.googleapis.com/v1/projects/{$projectId}/databases/(default)/documents/recipes?pageSize=100"; - $res = firestore_get($url); - if (!$res || !isset($res['documents'])) { - return load_recipes_local(); - } - - $recipes = []; - foreach ($res['documents'] as $doc) { - $decoded = decode_firestore_document($doc); - if (!empty($decoded)) { - if (!empty($decoded['hero']) && is_string($decoded['hero'])) { - $decoded['hero'] = normalize_asset_path($decoded['hero']); - } - $recipes[] = $decoded; - } - } - - return $recipes; + return load_recipes_local(); } function load_recipe_by_slug(string $slug): ?array { - $config = get_firebase_config(); - $projectId = $config['projectId'] ?? ''; - if (empty($projectId)) { - return find_recipe_by_slug(load_recipes_local(), $slug); - } - - $url = "https://firestore.googleapis.com/v1/projects/{$projectId}/databases/(default)/documents/recipes/" . urlencode($slug); - $res = firestore_get($url); - if (!$res || isset($res['error'])) { - return find_recipe_by_slug(load_recipes_local(), $slug); - } - - $decoded = decode_firestore_document($res); - if (!empty($decoded['hero']) && is_string($decoded['hero'])) { - $decoded['hero'] = normalize_asset_path($decoded['hero']); - } - - return $decoded; + return find_recipe_by_slug(load_recipes_local(), $slug); } function save_recipes(array $recipes): bool { + $pdo = get_db_connection(); $path = __DIR__ . '/data/recipes.json'; - $json = json_encode($recipes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - return (bool) file_put_contents($path, $json); + + if (!$pdo) { + $json = json_encode($recipes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + return (bool) file_put_contents($path, $json); + } + + 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) + ]); + } + } + + 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) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + error_log("Failed to save recipes to DB: " . $e->getMessage()); + return false; + } } /** -- 2.54.0 From 99a04db1c6db98e40bda0d07538c4a5871c44dfd Mon Sep 17 00:00:00 2001 From: LordSchmackes Date: Fri, 22 May 2026 19:25:06 +0200 Subject: [PATCH 02/12] T --- config.php | 9 +++++++-- helpers.php | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/config.php b/config.php index 441d1a5..7a9c641 100644 --- a/config.php +++ b/config.php @@ -37,13 +37,17 @@ load_env(); /** * Get a PDO connection to the database. */ -function get_db_connection(): ?PDO { +function get_db_connection() { static $pdo = null; if ($pdo !== null) { return $pdo; } + if (!class_exists('PDO')) { + return null; + } + $url = getenv('DATABASE_URL'); if (!$url) { return null; @@ -66,9 +70,10 @@ function get_db_connection(): ?PDO { $pdo = new PDO($dsn, $user, $pass, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_TIMEOUT => 3, ]); return $pdo; - } catch (PDOException $e) { + } catch (\Exception $e) { error_log("Database connection failed: " . $e->getMessage()); return null; } diff --git a/helpers.php b/helpers.php index 21f8045..d1b0147 100644 --- a/helpers.php +++ b/helpers.php @@ -38,7 +38,7 @@ function init_db() { } } } - } catch (PDOException $e) { + } catch (\Exception $e) { error_log("DB Init Error: " . $e->getMessage()); } } @@ -70,7 +70,7 @@ function load_recipes_local(): array { } } } - } catch (PDOException $e) { + } catch (\Exception $e) { error_log("Failed to load recipes from DB: " . $e->getMessage()); } } -- 2.54.0 From 3218aed0a9b918b02eff9c6c1a3ab3034152eb6a Mon Sep 17 00:00:00 2001 From: LordSchmackes Date: Sat, 23 May 2026 09:52:26 +0200 Subject: [PATCH 03/12] firebase fix --- config.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/config.php b/config.php index 7a9c641..349655c 100644 --- a/config.php +++ b/config.php @@ -34,6 +34,17 @@ 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') ?: '', + ]; +} + /** * Get a PDO connection to the database. */ -- 2.54.0 From 547778bba53b9ed9d95a291e2f22f297d24a7aaf Mon Sep 17 00:00:00 2001 From: LordSchmackes Date: Sat, 23 May 2026 10:13:00 +0200 Subject: [PATCH 04/12] Enhance project documentation with updated architecture, PostgreSQL integration, and local development setup. Refine admin panel and GitHub Actions details. Remove Firebase configuration from `.env.example` and clarify database fallback mechanisms. --- .agents/brain.md | 155 +++++++++++++++++++++++++++++++++-------- .env.example | 13 ++-- README.md | 100 +++++++++++++++++++++----- docker-compose.dev.yml | 26 +++++++ scripts/db-check.php | 39 +++++++++++ 5 files changed, 280 insertions(+), 53 deletions(-) create mode 100644 docker-compose.dev.yml create mode 100644 scripts/db-check.php diff --git a/.agents/brain.md b/.agents/brain.md index 9d54165..a31ba54 100644 --- a/.agents/brain.md +++ b/.agents/brain.md @@ -1,41 +1,138 @@ # FlixCooks Project Brain -This document summarizes the architectural knowledge, conventions, and learnings accumulated during our session. +This document summarizes architectural knowledge, conventions, and learnings for humans and AI agents working on this repo. ## 1. Project Architecture & Stack -- **Backend:** Vanilla PHP. The project intentionally avoids heavy frameworks. -- **Database:** Flat-file JSON database (`data/recipes.json`). Data is loaded and saved via utility functions in `helpers.php`. -- **Admin Panel (`admin.php`):** Acts as a lightweight CMS. It uses simple textareas where each line maps to an array element (e.g., for `ingredients`, `steps`, `step_videos`, `step_timers`). This keeps the JSON structure clean and parsing straightforward. -- **Frontend:** Server-rendered PHP templates (`index.php`, `recipe.php`) with Vanilla JavaScript and Vanilla CSS. No Tailwind or heavy component libraries. +- **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`. +- **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`). ## 2. Design & Aesthetics -- **CSS:** Highly customized CSS with modern design tokens (e.g., `var(--ease-out-expo)`, `var(--surface-1)`). -- **Animations:** Employs sophisticated micro-animations, glassmorphism (`backdrop-filter: blur`), and dynamic layouts (e.g., `clip-path` for overlays). -- **Smooth Scrolling:** Uses **Lenis** (`LenisSmoothScroll`). - - *Crucial Rule:* Whenever a fullscreen overlay (like the Cooking Mode) is opened, `lenis.stop()` must be called to prevent background scrolling. When closed, call `lenis.start()`. -- **Preloader:** A custom `CapitoliumPreloader` runs on the homepage. It is cached in `sessionStorage('flixcooks_preloader_seen')` so it only fires once per browsing session. +- **CSS:** Custom properties (`var(--ease-out-expo)`, `var(--surface-1)`), glassmorphism, `FloemaLayoutGrid`. +- **Lenis:** Call `lenis.stop()` when opening fullscreen overlays (e.g. Cooking Mode); `lenis.start()` on close. +- **Preloader:** `CapitoliumPreloader` on homepage; once per session via `sessionStorage('flixcooks_preloader_seen')`. ## 3. Antigravity Agent Configuration -- **Workspace Rules:** Best placed in `.agents/rules/` (e.g., `AGENT.md`). To ensure they are always active, the frontmatter must include: - ```yaml - always_on: true - glob: "*" - ``` -- **Custom Skills:** Can be defined as JSON files in `.agents/skills/`. We successfully created `close_feature.json` to automate the Git workflow of checking out `main`, merging a feature branch, verifying functionality, and deleting the branch. +- **Workspace Rules:** `.agents/rules/` with `always_on: true` and `glob: "*"` in frontmatter. +- **Custom Skills:** `.agents/skills/` (e.g. `close_feature.json` for Git merge workflow). ## 4. GitHub Actions & CI/CD -- **Gemini Code Review Automation:** We integrated `petarzarkov/gemini-code-review-action` to automatically review PRs. - - **Secrets:** Must be passed using `env:` instead of `with:` (e.g., `GEMINI_API_KEY`, `GITHUB_TOKEN`), otherwise the action fails with unexpected input errors. NEVER hardcode API keys in workflow files. - - **Model Naming:** Google's `v1beta` API is very strict. `gemini-1.5-flash` often fails. You must use the fully-qualified name like `gemini-1.5-flash-latest` or `gemini-2.0-flash-lite`. - - **Pinning Versions:** Always pin GitHub Actions to a specific version tag (e.g., `@v1.0.4`) rather than `@latest` to prevent unexpected breaking changes. +- **Gemini PR review:** `petarzarkov/gemini-code-review-action`; secrets via `env:` not `with:`; pin action versions; use full model names (e.g. `gemini-2.0-flash-lite`). -## 5. Completed Milestones -- **Phase 3 (Nutrition):** Implemented. Recipes now store `calories`, `protein`, `carbs`, and `fat`. Admin panel handles inputs, and the UI displays them beautifully. -- **Phase 4 (Interactive Cooking Mode):** Implemented. Recipes now support step-by-step looping background videos and interactive timers (`step_videos`, `step_timers`). The UI utilizes a fullscreen overlay slider with Vanilla JS logic. -- **Phase 6 (Firestore Database Migration):** Migrated recipes database from `data/recipes.json` to Firebase Firestore. - - *Zero-Dependency REST API Read:* Server-side read requests in `helpers.php` use native PHP cURL to query the Firestore REST API `/documents/recipes`. Complex Firestore nested type maps are dynamically parsed into clean standard associative arrays using custom decoders. - - *Dynamic Local Fallback:* In case of rate limits, network failures, or missing `.env` config, all lookup functions automatically fail back to the local `recipes.json` flat-file, guaranteeing 100% database availability and site resilience. - - *Browser-Driven Seeding & Auto-Sync:* Admin seeding and real-time updates are executed client-side in `admin.php` via the authenticated Firebase Client SDK, keeping the local file and Cloud Firestore perfectly in sync without server-side OAuth2 keys. +--- -## 6. Next Steps -According to `TODO.md`, the next major feature block is completing the database seeding (by clicking "Seed Firestore" on `admin.php` in the browser) and verifying all recipe updates reflect in real-time. Afterwards, we can proceed to **Phase 5 (PWA & Offline Support)**. +## 5. PostgreSQL — Schema & Data Flow + +### Table `recipes` (only app table today) +Created by `init_db()` in `helpers.php` if missing: + +| 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()` | + +**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'`). + +### 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. + +### `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. + +--- + +## 6. Local Development — Docker Postgres + +### Files +- `docker-compose.dev.yml` — Postgres 16 Alpine, container `flixcooks-postgres-dev`, port `5432`. +- `.env.example` — template including local `DATABASE_URL`. +- `scripts/db-check.php` — CLI: connect, `init_db()`, print recipe count + sample slugs/titles. + +### Docker credentials (dev only) +``` +POSTGRES_USER=flixcooks +POSTGRES_PASSWORD=flixcooks_dev +POSTGRES_DB=flixcooks_dev +DATABASE_URL="postgresql://flixcooks:flixcooks_dev@127.0.0.1:5432/flixcooks_dev" +``` + +### Commands +```bash +docker compose -f docker-compose.dev.yml up -d # start +docker compose -f docker-compose.dev.yml ps # health +docker compose -f docker-compose.dev.yml down # stop (data kept) +docker compose -f docker-compose.dev.yml down -v # stop + wipe volume → re-seed on next hit + +docker exec -it flixcooks-postgres-dev psql -U flixcooks -d flixcooks_dev +# psql: \dt , \d recipes , SELECT slug FROM recipes; , \q +``` + +### PHP requirements (WSL/Linux) +- Extension **`php-pgsql`** (or `php8.5-pgsql`) required; without it: log `could not find driver`, fallback to JSON. +- Install interactively: `sudo apt install php8.5-pgsql` (sudo in non-interactive agent shells may timeout). +- Verify: `php -m | grep pgsql` → expect `pdo_pgsql`, `pgsql`. + +### App server +```bash +php -S localhost:8000 +php scripts/db-check.php # after .env + pgsql OK +``` + +### `.env` rules for agents +- Copy from `.env.example`; never commit `.env`. +- **Local:** use `127.0.0.1` Docker URL above. +- **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). + +### 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. + +--- + +## 7. Troubleshooting (known issues) + +| 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` | +| 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 | + +--- + +## 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. +- **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. + +## 9. Next Steps +See `.agents/TODO.md` (e.g. PWA & offline support). README `Local Postgres (Docker)` and `Postgres in diesem Projekt` sections mirror setup for humans. + +## 10. Key file map (data layer) +| File | Purpose | +|------|---------| +| `config.php` | `.env`, Firebase config, PDO | +| `helpers.php` | `init_db`, `load_recipes`, `save_recipes`, site settings | +| `data/recipes.json` | Seed + fallback + admin backup | +| `data/site.json` | Imprint/privacy settings | +| `docker-compose.dev.yml` | Local Postgres | +| `scripts/db-check.php` | Connection + seed smoke test | +| `partials/head.php` | Firebase init via `get_firebase_config()` | diff --git a/.env.example b/.env.example index 810ca3e..b1d9ce7 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,6 @@ -# FlixCooks Firebase Configuration -# Replace these placeholder values with your actual Firebase project settings. +# FlixCooks – lokale Entwicklung (.env wird nicht committed) +# Kopieren: cp .env.example .env -FIREBASE_API_KEY="AIzaSyYourApiKeyHere" -FIREBASE_AUTH_DOMAIN="flixcooks-your-project-id.firebaseapp.com" -FIREBASE_PROJECT_ID="flixcooks-your-project-id" -FIREBASE_STORAGE_BUCKET="flixcooks-your-project-id.appspot.com" -FIREBASE_MESSAGING_SENDER_ID="123456789012" -FIREBASE_APP_ID="1:123456789012:web:abcdef123456" +# --- Postgres (docker-compose.dev.yml) --- +# Nur setzen, wenn du die lokale DB testen willst. Ohne DATABASE_URL → Fallback auf data/recipes.json +DATABASE_URL="postgresql://flixcooks:flixcooks_dev@127.0.0.1:5432/flixcooks_dev" diff --git a/README.md b/README.md index 9c2bb3b..6b39884 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,12 @@ The project is architected to remain extremely lightweight and fast, intentional - `index.php`: The atmospheric landing page showcasing featured recipe selections, introducing the brand, and housing the **Sleek Swipe Discovery Carousel**. - `recipe.php`: The immersive recipe detail page, featuring floating macro-nutrition widgets, interactive ingredients lists, and the fullscreen **Step-by-Step Cooking Mode**. - `admin.php`: A custom, lightweight CMS/admin dashboard allowing full CRUD capabilities over the recipe database, dynamic ingredient line parsing, cooking timers, and video URL associations. - - `login.php` & `register.php`: Fully responsive, glassmorphic auth portals powered by Firebase. + - `login.php`: Local profile page for dietary goals and saved favorites (browser storage). - **Support & Layouts**: - `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. - `config.php`: Environment-independent configuration loader which reads runtime secrets from `.env`. - - `api/`: Lightweight, stateless backend endpoints supporting AJAX operations (e.g., newsletter subscriptions, bookmarks, recommendation queries). + - `assets/fc-local.js`: Browser-side storage for favorites and dietary goals. - `data/`: Houses `recipes.json`, our flat-file recipe database. --- @@ -36,13 +36,9 @@ FlixCooks uses a modern, carefully curated vanilla tech-stack focused on lightni ### ⚙️ Backend & Data - **Engine**: Vanilla PHP. -- **Database**: Flat-file JSON database (`data/recipes.json`), allowing lightning-quick load times and simple structural schemas without heavy overhead. +- **Database**: PostgreSQL (production/staging) with automatic seed from `data/recipes.json`. Without `DATABASE_URL`, the app falls back to the JSON file. - **Environment**: Custom `.env` variable parser integrated into PHP bootstrap. -### 🔒 Integrations & Cloud Services -- **Firebase Authentication**: Client and server-side synchronized user sessions for profile management. -- **Firebase Firestore**: Real-time database managing newsletter subscribers and user bookmarks/favorites lists. - --- ## 🚀 Local Development Setup @@ -51,7 +47,8 @@ Follow these simple steps to spin up the local development environment. ### Prerequisites Make sure you have the following installed on your local machine: -- **PHP** (v7.4 or higher recommended) +- **PHP** (8.x recommended) with the **pgsql** extension (`php-pgsql` on Linux/WSL) +- **Docker** (for local Postgres via `docker-compose.dev.yml`) - A modern web browser ### 1. Set Up Environment Variables @@ -59,14 +56,59 @@ Make sure you have the following installed on your local machine: ```bash cp .env.example .env ``` -2. Open `.env` and fill in your actual **Firebase project settings** (API keys, project identifier, authentication domain, etc.): - ```env - FIREBASE_API_KEY="AIzaSyYourApiKeyHere" - FIREBASE_AUTH_DOMAIN="flixcooks-your-project-id.firebaseapp.com" - ... - ``` +2. Open `.env` and optionally set **local Postgres** (see [Local Postgres (Docker)](#local-postgres-docker) below). -### 2. Start the Development Server +### 2. Local Postgres (Docker) + +Für DB-Integration auf einem Dev-Branch – getrennt von Production. + +```bash +# Container starten +docker compose -f docker-compose.dev.yml up -d + +# Warten bis healthy (einmalig prüfen) +docker compose -f docker-compose.dev.yml ps +``` + +In `.env` (Werte passen zu `docker-compose.dev.yml`): + +```env +DATABASE_URL="postgresql://flixcooks:flixcooks_dev@127.0.0.1:5432/flixcooks_dev" +``` + +**PHP-Extension (WSL/Ubuntu, einmalig):** + +```bash +sudo apt install php-pgsql +# oder passend zur Version: sudo apt install php8.5-pgsql +``` + +**Datenbank-Shell (zum Lernen / Inspizieren):** + +```bash +docker exec -it flixcooks-postgres-dev psql -U flixcooks -d flixcooks_dev +``` + +Nützliche SQL-Befehle in `psql`: + +```sql +\dt -- alle Tabellen +\d recipes -- Spalten der Tabelle recipes +SELECT slug, created_at FROM recipes; +SELECT slug, data->>'title' AS title FROM recipes, jsonb_to_record(data) AS x(title text); -- optional +\q -- beenden +``` + +**DB komplett leeren und neu seeden** (lädt wieder aus `data/recipes.json` beim nächsten Seitenaufruf): + +```bash +docker compose -f docker-compose.dev.yml down -v +docker compose -f docker-compose.dev.yml up -d +``` + +Details zum Schema und Ablauf: Abschnitt unten in dieser README und `helpers.php` → `init_db()`. + +### 3. Start the Development Server #### Option A: PHP Built-in Web Server (Recommended & Easiest) You do not need to install complex local servers like Apache or Nginx. Simply run the following command in the root folder of the project: @@ -78,7 +120,7 @@ Then, open your browser and navigate to: http://localhost:8000 ``` -#### Option B: Local Apache Environments (XAMPP / MAMP / WAMP) +#### Option B: Local Apache (XAMPP / MAMP / WAMP) If you prefer running a full local stack: 1. Move or link the project directory inside your local server's document root (e.g., `htdocs` or `www`). 2. Ensure URL rewriting is enabled (the included `.htaccess` file handles caching and custom redirections). @@ -86,6 +128,32 @@ 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: + +| 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) | + +**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. + +**Ablauf beim ersten Aufruf mit leerer DB:** + +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. + +**Admin speichern:** `save_recipes()` schreibt nach Postgres **und** aktualisiert `data/recipes.json` als Backup. + +Für **Staging/Production** setzt du `DATABASE_URL` in der jeweiligen Hosting-Umgebung – nie Production-Daten in der lokalen Dev-DB mischen. + +--- + ## 📈 Development Tracking & Progress All current development tasks, features, and roadmaps are actively tracked and updated in the project’s [.agents/TODO.md](file:///.agents/TODO.md) file. Architectural learnings, conventions, and configuration updates are maintained in the central knowledge base: [.agents/brain.md](file:///.agents/brain.md). diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..4f1a2de --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,26 @@ +# Lokale Postgres-Instanz für Entwicklung (dev branch). +# Start: docker compose -f docker-compose.dev.yml up -d +# Stop: docker compose -f docker-compose.dev.yml down +# Reset: docker compose -f docker-compose.dev.yml down -v (löscht alle Daten!) + +services: + postgres: + image: postgres:16-alpine + container_name: flixcooks-postgres-dev + restart: unless-stopped + ports: + - "5432:5432" + environment: + POSTGRES_USER: flixcooks + POSTGRES_PASSWORD: flixcooks_dev + POSTGRES_DB: flixcooks_dev + volumes: + - flixcooks_pg_dev:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U flixcooks -d flixcooks_dev"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + flixcooks_pg_dev: diff --git a/scripts/db-check.php b/scripts/db-check.php new file mode 100644 index 0000000..54f3e57 --- /dev/null +++ b/scripts/db-check.php @@ -0,0 +1,39 @@ +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"); + echo "\nBeispiel-Zeilen:\n"; + while ($row = $stmt->fetch()) { + echo " - {$row['slug']}: {$row['title']}\n"; + } +} -- 2.54.0 From 6577db475a7153aaa54c024f2a16f427fe921343 Mon Sep 17 00:00:00 2001 From: LordSchmackes Date: Sat, 23 May 2026 10:23:28 +0200 Subject: [PATCH 05/12] Refactor database handling to require PostgreSQL connection, removing fallback to JSON. Implement error handling for database unavailability in key files. Update `.env.example` to reflect mandatory `DATABASE_URL` for local development. Remove Firebase configuration and related code from the project. --- .agents/TODO.md | 78 +++--- .agents/brain.md | 57 ++--- .env.example | 4 +- .firebaserc | 5 - README.md | 49 ++-- admin.php | 109 ++------- api/session.php | 40 --- assets/fc-local.js | 62 +++++ config.php | 11 +- helpers.php | 427 +++++++++++++++++++++++++-------- index.php | 74 +++--- login.php | 348 +++++++-------------------- maintenance/db-unavailable.php | 52 ++++ partials/footer.php | 108 ++------- partials/head.php | 44 +--- partials/header.php | 4 +- recipe.php | 7 +- scripts/db-check.php | 20 +- scripts/db-seed.php | 43 ++++ scripts/schema.sql | 70 ++++++ 20 files changed, 823 insertions(+), 789 deletions(-) delete mode 100644 .firebaserc delete mode 100644 api/session.php create mode 100644 assets/fc-local.js create mode 100644 maintenance/db-unavailable.php create mode 100644 scripts/db-seed.php create mode 100644 scripts/schema.sql 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); -- 2.54.0 From 054141020d49d6dc99c48ec52b07a4ab3fba24fe Mon Sep 17 00:00:00 2001 From: LordSchmackes Date: Sat, 23 May 2026 12:43:51 +0200 Subject: [PATCH 06/12] Update `.env.example` for local and production database configurations, ensuring clarity on usage. Modify `.gitattributes` to enforce LF line endings for shell scripts. Enhance `README.md` with Docker and Coolify setup instructions for production deployment. --- .../coolify_react_cms_stack_ea69bc7d.plan.md | 324 ++++++++++++++++++ .dockerignore | 12 + .env.example | 7 +- .gitattributes | 4 +- Dockerfile | 37 ++ README.md | 8 + docker-compose.yml | 42 +++ docker/entrypoint.sh | 28 ++ docs/COOLIFY.md | 140 ++++++++ health.php | 25 ++ 10 files changed, 624 insertions(+), 3 deletions(-) create mode 100644 .cursor/plans/coolify_react_cms_stack_ea69bc7d.plan.md create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker/entrypoint.sh create mode 100644 docs/COOLIFY.md create mode 100644 health.php diff --git a/.cursor/plans/coolify_react_cms_stack_ea69bc7d.plan.md b/.cursor/plans/coolify_react_cms_stack_ea69bc7d.plan.md new file mode 100644 index 0000000..e0d7bc5 --- /dev/null +++ b/.cursor/plans/coolify_react_cms_stack_ea69bc7d.plan.md @@ -0,0 +1,324 @@ +--- +name: Coolify React CMS Stack +overview: "Empfohlener Standard-Stack: Next.js (React) + Payload CMS (self-hosted) + PostgreSQL, alles als Docker-Images über GitHub → Coolify. Cursor/MCP für die Entwicklung, GitHub Actions für Qualitätssicherung vor dem Deploy." +todos: + - id: scaffold-monorepo + content: "Greenfield-Monorepo anlegen: apps/web (Next standalone), apps/cms (Payload), packages/shared-types" + status: pending + - id: docker-coolify + content: "Dockerfiles, docker-compose.yml, docs/COOLIFY.md (3 Services: Postgres, CMS, Web) nach FlixCooks-Muster" + status: pending + - id: cms-content-model + content: Payload Collections + Webhooks für On-Demand Revalidation; Env-Beispiele in .env.example + status: pending + - id: github-ci + content: "GitHub Actions: lint, typecheck, build, docker build smoke; branch protection auf main" + status: pending + - id: mcp-agents + content: .cursor/mcp.json + .agents/brain.md mit Stack-, Env- und Deploy-Regeln für KI + status: pending + - id: animation-baseline + content: "GSAP/Lenis/Framer-Baseline-Komponenten und Regel: Overlays stoppen Lenis" + status: pending +isProject: false +--- + +# Tech-Stack & Workflow: React-Sites, CMS, Docker, Coolify + +## Empfehlung (Default) + +Du bist unsicher bei CMS und Frontend – hier ist ein **bewährtes Default**, das zu deinen Vorgaben passt (React, Animationen, Docker, Coolify, KI/MCP, CI): + +| Schicht | Technologie | Warum | +|---------|-------------|--------| +| **Frontend** | [Next.js 15](https://nextjs.org) (App Router, TypeScript) | SEO/SSR/ISR, React-Ökosystem, `output: 'standalone'` für ein schlankes Production-Docker-Image | +| **Animationen** | GSAP (+ ScrollTrigger), Framer Motion, Lenis | GSAP für Scroll/Timeline-Premium-Feel; Framer für UI-Micro-Interactions; Lenis kennst du bereits aus FlixCooks | +| **CMS** | [Payload CMS 3](https://payloadcms.com) (eigener Container) | TypeScript, Postgres-native, Admin-UI out of the box, Docker-freundlich, passt zu Coolify wie dein aktuelles Postgres-Setup | +| **Datenbank** | PostgreSQL 16 (Coolify Database Service) | Eine Instanz, getrennte DBs/User für CMS vs. App optional | +| **Runtime / Deploy** | Docker + [Coolify](https://coolify.io) | Git-Webhook → Build → Traefik/HTTPS; du hast das Muster schon in [docs/COOLIFY.md](docs/COOLIFY.md) | +| **Lokale Dev** | `docker compose` (Web + CMS + Postgres) | Parität zu Production, wie [docker-compose.yml](docker-compose.yml) bei FlixCooks | +| **KI-Entwicklung** | Cursor + MCP-Server | Repo-Kontext, GitHub, Docs, optional DB | +| **CI** | GitHub Actions | Lint, Types, Build, Docker-Smoke, optional E2E; PR-Review wie [.github/workflows/gemini-pr-review.yml](.github/workflows/gemini-pr-review.yml) | + +**Alternative Frontend:** Vite + React SPA + nginx-Image – maximal frei für reine Animation-Landingpages, aber schlechteres SEO und kein ISR ohne Extra-Aufwand. **Alternative CMS (weniger Ops):** Sanity/Contentful (Cloud) – nur Frontend-Container auf Coolify. **Alternative CMS (kein Backend):** Tina/Decap + Markdown im Repo – gut für Blogs, schwächer für Redakteur:innen ohne Git. + +--- + +## Zielarchitektur auf Coolify + +```mermaid +flowchart TB + subgraph dev [Entwicklung] + Cursor[Cursor + MCP] + LocalCompose[docker compose] + Cursor --> LocalCompose + end + + subgraph github [GitHub] + Repo[Monorepo] + GHA[GitHub Actions CI] + Repo --> GHA + end + + subgraph coolify [Coolify Server] + PG[(PostgreSQL)] + CMS[Payload CMS Container] + WEB[Next.js Container] + Traefik[Traefik HTTPS] + PG --> CMS + CMS -->|REST/GraphQL| WEB + Traefik --> WEB + Traefik --> CMS + end + + dev -->|push main| Repo + GHA -->|grüner Build| Repo + Repo -->|Webhook Deploy| coolify +``` + +**Drei Coolify-Ressourcen** (analog zu deinem FlixCooks-Setup: Postgres + App): + +1. **PostgreSQL** – internal URL, nicht öffentlich +2. **CMS-App** – Dockerfile aus `apps/cms`, Port z. B. 3001, Env: `DATABASE_URL`, `PAYLOAD_SECRET` +3. **Web-App** – Dockerfile aus `apps/web`, Port 3000, Env: `CMS_URL` (internal), `REVALIDATE_SECRET` für On-Demand-ISR + +Persistenz: Postgres-Volume (Inhalte), optional Volume für CMS-Uploads (`/app/media`). + +--- + +## Repository-Struktur (Greenfield-Vorlage) + +Ein Repo pro „Site-Familie“ oder Monorepo für mehrere Marken: + +``` +my-site/ +├── apps/ +│ ├── web/ # Next.js +│ │ ├── Dockerfile +│ │ ├── src/ +│ │ └── next.config.ts # output: 'standalone' +│ └── cms/ # Payload +│ ├── Dockerfile +│ └── payload.config.ts +├── packages/ +│ └── shared-types/ # optional: gemeinsame TS-Typen CMS ↔ Web +├── docker-compose.yml # lokaler Prod-Parität-Stack +├── docker-compose.dev.yml # nur Postgres (wie bei FlixCooks) +├── .github/workflows/ +│ ├── ci.yml +│ └── gemini-pr-review.yml # optional, aus FlixCooks übernehmen +├── .cursor/ +│ └── mcp.json # MCP-Server für das Team +├── .agents/ +│ ├── brain.md # Architektur für KI (Pattern aus FlixCooks) +│ └── rules/AGENT.md +└── docs/ + └── COOLIFY.md +``` + +--- + +## Frontend-Stack (React + Animationen) + +**Kern:** + +- **Next.js App Router** – Seiten in `app/`, Server Components für CMS-Daten, Client Components nur für Animation/Interaktion +- **TypeScript** – strikt; Typen aus Payload generieren (`payload generate:types`) +- **Styling** – CSS Modules oder Tailwind (nur wenn du es willst; FlixCooks bleibt bei Vanilla CSS – für neue React-Sites ist Tailwind optional, nicht Pflicht) + +**Animation-Toolkit:** + +| Tool | Einsatz | +|------|---------| +| **GSAP + ScrollTrigger** | Hero-Sequences, pinned Sections, komplexe Timelines | +| **Framer Motion** | Hover, Page-Transitions, modale UI | +| **Lenis** | Smooth Scroll (wie FlixCooks: bei Overlays `lenis.stop()`) | +| **(optional) @react-three/fiber** | 3D-Hero nur wenn nötig | + +**CMS-Anbindung im Web:** + +- **Build-Zeit (SSG):** `generateStaticParams` + Fetch von Payload REST für Marketing-Seiten +- **On-Demand Revalidation:** Payload-Webhook → `POST /api/revalidate?secret=...` in Next.js (Inhalt ändert sich ohne Full-Redeploy) +- **Preview:** Draft-Modus mit Payload Preview-URL + Next `draftMode()` + +Env im Web-Container (Coolify): + +- `CMS_URL=http://payload-service:3001` (internal hostname) +- `REVALIDATE_SECRET`, `NEXT_PUBLIC_SITE_URL` + +--- + +## CMS-Stack (Payload auf Coolify) + +**Warum Payload als Default:** Self-hosted, eine Postgres-URL, Admin unter `/admin`, Collections/Blocks für Seitenmodule, Media-Uploads, Webhooks – alles containerisierbar. + +**Coolify Env (CMS):** + +- `DATABASE_URL` – internal Postgres URL (gleiches Muster wie [docs/COOLIFY.md](docs/COOLIFY.md) Zeilen 33–43) +- `PAYLOAD_SECRET` – langer Zufallswert (nur Secrets, nie ins Repo) +- `NEXT_PUBLIC_SERVER_URL` – öffentliche CMS-URL (für Admin-Assets) + +**Erstes Deployment:** analog FlixCooks `RUN_DB_SEED` – einmalig Migration/Seed, danach Flag entfernen. + +**Sicherheit:** CMS-Admin nur über HTTPS; CORS auf Web-Domain beschränken; API-Keys für Preview/Revalidate nur als Secrets. + +--- + +## Docker-Images + +### Web (`apps/web/Dockerfile`) – Next standalone + +Mehrstufig: `node:22-alpine` → `npm ci` → `npm run build` → Runtime nur `.next/standalone` + `static` + `public`. + +- `EXPOSE 3000` +- `HEALTHCHECK` auf `/api/health` (kleine Route: `{ "status": "ok" }`) +- Coolify: Port **3000**, Health Path `/api/health` + +### CMS (`apps/cms/Dockerfile`) + +Payload-Official-Pattern oder Node-Image mit `npm run build && npm run start`. + +- `HEALTHCHECK` auf CMS-Health-Endpoint +- Volume für `/app/media` (Uploads überleben Redeploy) + +### Lokales Parität-Compose + +Orientierung an deinem bestehenden [docker-compose.yml](docker-compose.yml): + +- `postgres` mit `healthcheck` +- `cms` `depends_on: postgres: service_healthy` +- `web` `depends_on: cms` + `DATABASE_URL` nur wenn Web eigene DB braucht (meist nicht – nur CMS nutzt DB) + +Entrypoint-Pattern von [docker/entrypoint.sh](docker/entrypoint.sh) übernehmen: **DB warten → Migration → dann Prozess starten**. + +--- + +## Coolify-Workflow (End-to-End) + +```mermaid +sequenceDiagram + participant Dev as Developer + participant GH as GitHub + participant GHA as GitHub Actions + participant CF as Coolify + participant Web as Next Container + participant CMS as Payload Container + + Dev->>GH: push feature branch + Dev->>GH: open PR + GHA->>GHA: lint typecheck build docker + GHA-->>Dev: PR checks green + Dev->>GH: merge to main + GH->>CF: webhook deploy + CF->>CF: build CMS image + CF->>CF: build Web image + CF->>Web: rolling update + CMS->>Web: optional revalidate webhook +``` + +**Coolify-Konfiguration pro App:** + +| Setting | Web | CMS | +|---------|-----|-----| +| Build Pack | Dockerfile | Dockerfile | +| Branch | `main` | `main` | +| Port | 3000 | 3001 | +| Health | `/api/health` | `/api/health` oder Payload-Default | +| Secrets | `REVALIDATE_SECRET`, `CMS_URL` | `DATABASE_URL`, `PAYLOAD_SECRET` | + +**Checkliste Erstdeploy** (aus [docs/COOLIFY.md](docs/COOLIFY.md) übertragbar): + +1. Postgres healthy, internal URL notieren +2. CMS deployen, Admin anlegen, Collections seeden +3. Web deployen mit internal `CMS_URL` +4. Domain + HTTPS (Traefik) +5. Webhook Payload → Next Revalidate testen +6. `RUN_DB_SEED` / Migration-Flags wieder aus + +--- + +## GitHub Actions (CI vor Coolify) + +**Workflow `ci.yml`** (bei jedem PR + push auf `main`): + +1. **checkout** +2. **Node 22** + Cache (`apps/web`, `apps/cms`) +3. **Parallel jobs oder Matrix:** + - `npm run lint` (ESLint) + - `npm run typecheck` (`tsc --noEmit`) + - `npm run build` (Web + CMS) +4. **Docker build test** (ohne Push): + - `docker build -f apps/web/Dockerfile apps/web` + - `docker build -f apps/cms/Dockerfile apps/cms` +5. **(optional) Playwright** gegen `docker compose up` – Smoke: Startseite, eine CMS-Seite, Health endpoints +6. **(optional) PR Review** – bestehendes Gemini-Workflow aus FlixCooks wiederverwenden + +**Branch-Schutz:** `main` nur mit grünen Required Checks mergebar. + +Coolify deployt **nach** Merge – CI blockiert kaputte Images, Coolify baut das echte Production-Image (oder du pushst zu GHCR – für den Start reicht Coolify-eigener Build). + +--- + +## KI-gestützte Entwicklung mit MCP + +**Cursor `mcp.json` (Team-Standard):** + +| MCP | Zweck | +|-----|--------| +| **GitHub** | Issues, PRs, Actions-Logs aus dem Chat | +| **Context7** (oder Fetch) | Aktuelle Next.js / Payload / GSAP-Docs | +| **Postgres** (optional, nur Dev) | Content/Debugging – nie Production-Credentials im Repo | +| **Filesystem** | Standard in Cursor | + +**Projekt-Wissen für Agenten** (aus FlixCooks übernehmen): + +- [.agents/brain.md](.agents/brain.md) – Architektur, Env-Regeln, Coolify-Hosts +- [.agents/rules/AGENT.md](.agents/rules/AGENT.md) – Commit/PR/TODO-Konventionen +- `docs/COOLIFY.md` – Deploy-Runbook pro Projekt + +**Typischer KI-Workflow:** + +1. Ticket/Issue in GitHub (MCP) +2. Feature-Branch; Agent ändert `apps/web` + Payload-Collection +3. `docker compose up` lokal; Agent nutzt Health-URLs +4. PR → CI grün → Gemini-Review optional +5. Merge → Coolify + +--- + +## Entwickler-Alltag (Kurzablauf) + +1. `cp .env.example .env` – lokale URLs +2. `docker compose -f docker-compose.dev.yml up -d` (nur Postgres) **oder** volles `docker compose up` +3. `npm run dev` in `apps/web` und `apps/cms` (schneller Hot Reload) **oder** alles in Containern +4. In Payload Inhalte pflegen → Webhook triggert Revalidate +5. `git push` → PR → CI → merge → Coolify rebuild + +--- + +## Bezug zu FlixCooks (dieses Repo) + +FlixCooks ist heute **Vanilla PHP + Postgres + eingebautes `admin.php`-CMS** – kein React. Das ist ein **paralleler Stack**, kein Widerspruch: + +| Aspekt | FlixCooks (aktuell) | Neuer React-Stack | +|--------|---------------------|-------------------| +| Frontend | PHP-Templates | Next.js + React | +| CMS | `admin.php` | Payload (Container) | +| DB | Postgres | Postgres | +| Deploy | [Dockerfile](Dockerfile) + [COOLIFY.md](docs/COOLIFY.md) | gleiches Muster, zwei App-Services | +| Animationen | Lenis, Vanilla CSS | Lenis + GSAP + Framer | + +Du kannst FlixCooks auf Coolify weiterbetreiben und **neue Projekte** im Monorepo-Template starten. Eine spätere Migration FlixCooks → Next wäre ein separates Projekt (Content-Export aus Postgres/JSON → Payload-Collections). + +--- + +## Nächste konkrete Schritte (nach Plan-Freigabe) + +1. **Greenfield-Repo** aus der Struktur oben scaffolden (oder `create-payload-app` + Next in Monorepo). +2. **Minimale Collections** in Payload: `pages`, `recipes` (oder `projects`), `siteSettings`, Media. +3. **Dockerfiles + compose** + `docs/COOLIFY.md` vom FlixCooks-Muster kopieren/adaptieren. +4. **GitHub Actions `ci.yml`** anlegen. +5. **Coolify:** Postgres → CMS → Web → Webhook Revalidate testen. +6. **`.cursor/mcp.json` + `.agents/brain.md`** für das neue Repo. + +Wenn du willst, kann im nächsten Schritt ein **konkretes Starter-Repo** (Dateien + minimale Hero-Animation + eine Payload-Collection) direkt in einem neuen Ordner oder Branch angelegt werden. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..88018b8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.agents +.env +.env.* +!.env.example +docker-compose.dev.yml +docker-compose.yml +terminals +*.md +!README.md +.cursor diff --git a/.env.example b/.env.example index 744a47b..8ec31d8 100644 --- a/.env.example +++ b/.env.example @@ -2,5 +2,10 @@ # Kopieren: cp .env.example .env # --- Postgres (Pflicht für die Website) --- -# docker compose -f docker-compose.dev.yml up -d +# Lokal: docker compose -f docker-compose.dev.yml up -d DATABASE_URL="postgresql://flixcooks:flixcooks_dev@127.0.0.1:5432/flixcooks_dev" + +# --- Production / Coolify (im Dashboard setzen, nicht committen) --- +# DATABASE_URL="postgresql://user:pass@postgresql-service:5432/flixcooks" +# FLIXCOOKS_ADMIN_KEY="langes-zufaelliges-passwort" +# RUN_DB_SEED="true" # nur beim allerersten Deploy, danach entfernen diff --git a/.gitattributes b/.gitattributes index dfe0770..f7e2def 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,2 @@ -# Auto detect text files and perform LF normalization -* text=auto +* text=auto eol=lf +*.sh text eol=lf diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2b55a59 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# FlixCooks – Production image (Coolify / any Docker host) +FROM php:8.3-apache-bookworm + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev \ + && docker-php-ext-install pdo_pgsql \ + && a2enmod rewrite headers \ + && rm -rf /var/lib/apt/lists/* + +# Apache: document root + AllowOverride for .htaccess +ENV APACHE_DOCUMENT_ROOT=/var/www/html +RUN sed -ri 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \ + && sed -ri 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf \ + && printf '%s\n' \ + '' \ + ' AllowOverride All' \ + ' Require all granted' \ + '' \ + > /etc/apache2/conf-available/flixcooks.conf \ + && a2enconf flixcooks + +WORKDIR /var/www/html + +COPY --chown=www-data:www-data . /var/www/html + +RUN sed -i 's/\r$//' /var/www/html/docker/entrypoint.sh \ + && chmod +x /var/www/html/docker/entrypoint.sh \ + && mkdir -p /var/www/html/data \ + && chown -R www-data:www-data /var/www/html/data + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD php /var/www/html/health.php >/dev/null || exit 1 + +ENTRYPOINT ["/var/www/html/docker/entrypoint.sh"] +CMD ["apache2-foreground"] diff --git a/README.md b/README.md index 291170f..7f13002 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,14 @@ Schema: `scripts/schema.sql`. PHP baut daraus dieselben Arrays wie früher (`i18 Für **Staging/Production** nur `DATABASE_URL` in der Hosting-Umgebung setzen – nie Production-Daten in der lokalen Dev-DB mischen. +### Docker / Coolify + +Production-Image: `Dockerfile` im Repo-Root. Ausführliche Schritte: [docs/COOLIFY.md](docs/COOLIFY.md). + +```bash +docker compose build && docker compose up -d # lokal testen → http://127.0.0.1:8080 +``` + --- ## 📈 Development Tracking & Progress diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5ec26a7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +# Lokaler Produktions-Test: App-Image + Postgres (ähnlich Coolify mit zwei Services) +# +# docker compose build +# docker compose up -d +# curl http://127.0.0.1:8080/health.php +# +# Erstes Deployment mit Seed: +# RUN_DB_SEED=true docker compose up -d + +services: + web: + build: . + ports: + - "8080:80" + environment: + DATABASE_URL: postgresql://flixcooks:flixcooks_prod@postgres:5432/flixcooks + FLIXCOOKS_ADMIN_KEY: ${FLIXCOOKS_ADMIN_KEY:-change-me-in-production} + RUN_DB_SEED: ${RUN_DB_SEED:-false} + depends_on: + postgres: + condition: service_healthy + volumes: + # Impressum/Datenschutz-Einstellungen persistent halten + - site_data:/var/www/html/data + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: flixcooks + POSTGRES_PASSWORD: flixcooks_prod + POSTGRES_DB: flixcooks + volumes: + - pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U flixcooks -d flixcooks"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + pg_data: + site_data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..c5db388 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -euo pipefail + +cd /var/www/html + +echo "[flixcooks] Waiting for database..." +TRIES=0 +MAX_TRIES="${DB_WAIT_MAX_TRIES:-30}" + +until php scripts/db-check.php >/dev/null 2>&1; do + TRIES=$((TRIES + 1)) + if [ "$TRIES" -ge "$MAX_TRIES" ]; then + echo "[flixcooks] Database not reachable after ${MAX_TRIES} attempts." >&2 + exit 1 + fi + sleep 2 +done + +echo "[flixcooks] Applying schema..." +php -r "require 'helpers.php'; require_database(); echo \"schema ok\n\";" + +if [ "${RUN_DB_SEED:-false}" = "true" ]; then + echo "[flixcooks] Seeding recipes from data/recipes.json..." + php scripts/db-seed.php +fi + +echo "[flixcooks] Starting Apache..." +exec "$@" diff --git a/docs/COOLIFY.md b/docs/COOLIFY.md new file mode 100644 index 0000000..5545c3d --- /dev/null +++ b/docs/COOLIFY.md @@ -0,0 +1,140 @@ +# FlixCooks auf Coolify deployen + +Zwei getrennte Ressourcen: **PostgreSQL** und **PHP-Web-App** (dieses Repo als Dockerfile). + +## Architektur + +``` +┌──────────────────── Coolify Server ────────────────────┐ +│ │ +│ ┌──────────────┐ ┌─────────────────────────┐ │ +│ │ PostgreSQL │◄────────│ FlixCooks (dieses Image) │ │ +│ │ (Service B) │ :5432 │ Apache + PHP 8.3 │ │ +│ └──────────────┘ │ Port 80 → Traefik/HTTPS │ │ +│ ▲ └─────────────────────────┘ │ +│ │ ▲ │ +│ Volume (Daten) Volume optional: │ +│ data/ (site.json) │ +└─────────────────────────────────────────────────────────┘ +``` + +Die App startet **nicht**, wenn `DATABASE_URL` fehlt oder Postgres nicht erreichbar ist. + +--- + +## 1. PostgreSQL in Coolify anlegen + +1. Neues **Database** → PostgreSQL (16). +2. Notieren: + - Benutzer, Passwort, Datenbankname + - **Internal URL** (Host ist oft der Service-Name, z. B. `postgresql-xxxxx` oder was Coolify anzeigt) +3. Format für die App: + +```text +postgresql://USER:PASSWORD@HOST:5432/DATABASE +``` + +Beispiel (Platzhalter durch Coolify-Werte ersetzen): + +```text +postgresql://flixcooks:geheim@postgresql-flixcooks:5432/flixcooks +``` + +**Wichtig:** In der App den **internen** Hostnamen verwenden (gleiches Coolify-Netzwerk), nicht `127.0.0.1`. + +--- + +## 2. Web-App in Coolify anlegen + +1. Neues **Application** → Build Pack: **Dockerfile** (Repository dieses Projekts). +2. Dockerfile-Pfad: `Dockerfile` (Root). +3. Port: **80** (Container exponiert Apache auf 80). +4. Health Check (optional, empfohlen): + - Path: `/health.php` + - Erwartet HTTP 200 mit `{"status":"ok"}` + +### Environment Variables (Pflicht) + +| Variable | Beschreibung | +|----------|----------------| +| `DATABASE_URL` | Interne Postgres-URL von Coolify | +| `FLIXCOOKS_ADMIN_KEY` | Starkes Passwort für `/admin.php` | + +### Environment Variables (optional) + +| Variable | Default | Beschreibung | +|----------|---------|----------------| +| `RUN_DB_SEED` | `false` | Einmalig `true` setzen → importiert `data/recipes.json` beim Start | +| `DB_WAIT_MAX_TRIES` | `30` | Warteversuche bis Postgres da ist (à 2 s) | + +Nach dem ersten erfolgreichen Deploy: `RUN_DB_SEED` wieder auf `false` oder entfernen. + +### Persistent Storage (empfohlen) + +Mount für Impressum/Datenschutz (`data/site.json`): + +| Mount Path (Container) | Inhalt | +|------------------------|--------| +| `/var/www/html/data` | `site.json` bleibt nach Redeploy erhalten | + +Rezepte liegen in Postgres – **kein** Volume für Rezepte nötig. + +--- + +## 3. Erstes Deployment (Checkliste) + +1. Postgres-Service läuft (healthy). +2. App mit `DATABASE_URL` + `FLIXCOOKS_ADMIN_KEY` deployen. +3. Einmalig `RUN_DB_SEED=true` → Redeploy → Rezepte prüfen auf der Startseite. +4. `RUN_DB_SEED` deaktivieren. +5. `https://deine-domain/admin.php` testen. +6. `https://deine-domain/health.php` → `{"status":"ok"}`. + +### Schema ohne Seed + +Tabellen legt der Container beim Start automatisch an (`scripts/schema.sql` via `require_database()`). Ohne Seed ist die DB leer → Seite lädt, aber keine Rezepte, bis du im Admin anlegst oder seedest. + +--- + +## 4. Lokaler Test vor Coolify + +```bash +# Starkes Admin-Passwort setzen +export FLIXCOOKS_ADMIN_KEY="dein-geheimes-passwort" + +# Mit Seed +export RUN_DB_SEED=true +docker compose build +docker compose up -d + +curl http://127.0.0.1:8080/health.php +``` + +--- + +## 5. Updates / Redeploy + +- Neues Image bauen lassen (Git push → Coolify rebuild). +- Postgres-Volume bleibt → Daten bleiben. +- `data/`-Volume bleibt → Site-Settings bleiben. +- Kein manuelles `db-seed` bei Updates, außer du leerst die DB bewusst. + +--- + +## 6. Sicherheit + +- `.env` wird **nicht** ins Image kopiert (`.dockerignore`). +- Admin-Key **nur** über `FLIXCOOKS_ADMIN_KEY` in Coolify, nicht in `.htaccess` für Production verlassen. +- Postgres nicht öffentlich exponieren, wenn nicht nötig (nur interne URL). + +--- + +## 7. Troubleshooting + +| Problem | Lösung | +|---------|--------| +| Container startet nicht | Logs: DB nicht erreichbar → `DATABASE_URL` Host/Passwort prüfen | +| 503 „Datenbank nicht verfügbar“ | Gleiches Netzwerk in Coolify? Internal URL? | +| Leere Seite, Health OK | `RUN_DB_SEED=true` einmalig oder Admin-Rezepte anlegen | +| Admin geht nicht | `FLIXCOOKS_ADMIN_KEY` gesetzt? | +| `site.json` verloren nach Deploy | Volume auf `/var/www/html/data` mounten | diff --git a/health.php b/health.php new file mode 100644 index 0000000..f3d269b --- /dev/null +++ b/health.php @@ -0,0 +1,25 @@ + 'ok']; + echo json_encode($response, JSON_UNESCAPED_UNICODE); +} catch (Throwable $e) { + http_response_code(503); + $response = [ + 'status' => 'error', + 'message' => $e->getMessage(), + ]; + echo json_encode($response, JSON_UNESCAPED_UNICODE); + exit(1); +} -- 2.54.0 From eff0aeb8c31f1ebaaec06cae394a384d23843222 Mon Sep 17 00:00:00 2001 From: LordSchmackes Date: Sat, 23 May 2026 12:59:36 +0200 Subject: [PATCH 07/12] Enhance Docker entrypoint script with detailed logging for database connection attempts and schema application. Implement error handling for database checks and seeding process. Update documentation for environment variables and troubleshooting steps to improve clarity and usability. --- docker/entrypoint.sh | 80 ++++++++++++++++++++++++++++++++++++++++++-- docs/COOLIFY.md | 51 ++++++++++++++++------------ 2 files changed, 107 insertions(+), 24 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index c5db388..07ddb5b 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -3,26 +3,100 @@ set -euo pipefail cd /var/www/html +# region agent log +fc_debug_log() { + local hypothesis="$1" + local message="$2" + local data_json="${3:-{}}" + local data_b64 + data_b64=$(printf '%s' "$data_json" | base64 | tr -d '\n') + FC_DEBUG_HYPOTHESIS="$hypothesis" FC_DEBUG_MESSAGE="$message" FC_DEBUG_DATA_B64="$data_b64" php -r ' + $json = base64_decode(getenv("FC_DEBUG_DATA_B64") ?: ""); + $data = json_decode($json, true); + if (!is_array($data) && str_ends_with($json, "}}")) { + $data = json_decode(substr($json, 0, -1), true); + } + $payload = [ + "sessionId" => "885d37", + "runId" => "coolify-bad-gateway", + "hypothesisId" => getenv("FC_DEBUG_HYPOTHESIS") ?: "", + "location" => "docker/entrypoint.sh", + "message" => getenv("FC_DEBUG_MESSAGE") ?: "", + "data" => [ + "raw_json" => $json, + "parsed" => is_array($data) ? $data : null, + ], + "timestamp" => (int) round(microtime(true) * 1000), + ]; + $line = json_encode($payload, JSON_UNESCAPED_SLASHES) . PHP_EOL; + @file_put_contents("/var/www/html/debug-885d37.log", $line, FILE_APPEND); + fwrite(STDERR, "agent_debug " . $line); + ' +} +# endregion + echo "[flixcooks] Waiting for database..." TRIES=0 MAX_TRIES="${DB_WAIT_MAX_TRIES:-30}" +start_data=$(php -r 'echo json_encode([ + "database_url_present" => getenv("DATABASE_URL") !== false && getenv("DATABASE_URL") !== "", + "run_db_seed" => getenv("RUN_DB_SEED") ?: "", + "db_wait_max_tries" => getenv("DB_WAIT_MAX_TRIES") ?: "30", + "entrypoint_args" => array_slice($argv, 1), + "schema_file_exists" => file_exists("/var/www/html/scripts/schema.sql"), + "seed_file_exists" => file_exists("/var/www/html/data/recipes.json"), +]);' -- "$@") +fc_debug_log "H1,H2,H4" "entrypoint started" "$start_data" -until php scripts/db-check.php >/dev/null 2>&1; do +until db_check_output=$(php scripts/db-check.php 2>&1); do TRIES=$((TRIES + 1)) + fail_data=$(php -r 'echo json_encode([ + "attempt" => (int) $argv[1], + "max_tries" => (int) $argv[2], + "db_check_output" => $argv[3], + ]);' -- "$TRIES" "$MAX_TRIES" "$db_check_output") + fc_debug_log "H1" "db-check failed while waiting" "$fail_data" if [ "$TRIES" -ge "$MAX_TRIES" ]; then echo "[flixcooks] Database not reachable after ${MAX_TRIES} attempts." >&2 + fc_debug_log "H1" "entrypoint exiting because database never became reachable" "$fail_data" exit 1 fi sleep 2 done +success_data=$(php -r 'echo json_encode([ + "attempts_before_success" => (int) $argv[1], + "db_check_output" => $argv[2], +]);' -- "$TRIES" "$db_check_output") +fc_debug_log "H1" "db-check succeeded" "$success_data" echo "[flixcooks] Applying schema..." -php -r "require 'helpers.php'; require_database(); echo \"schema ok\n\";" +if schema_output=$(php -r "require 'helpers.php'; require_database(); echo \"schema ok\n\";" 2>&1); then + schema_data=$(php -r 'echo json_encode(["schema_output" => $argv[1]]);' -- "$schema_output") + fc_debug_log "H2,H4" "schema apply succeeded" "$schema_data" +else + code=$? + schema_data=$(php -r 'echo json_encode(["exit_code" => (int) $argv[1], "schema_output" => $argv[2]]);' -- "$code" "$schema_output") + fc_debug_log "H2,H4" "schema apply failed, entrypoint exiting" "$schema_data" + echo "$schema_output" >&2 + exit "$code" +fi if [ "${RUN_DB_SEED:-false}" = "true" ]; then echo "[flixcooks] Seeding recipes from data/recipes.json..." - php scripts/db-seed.php + if seed_output=$(php scripts/db-seed.php 2>&1); then + seed_data=$(php -r 'echo json_encode(["seed_output" => $argv[1]]);' -- "$seed_output") + fc_debug_log "H4" "seed succeeded" "$seed_data" + else + code=$? + seed_data=$(php -r 'echo json_encode(["exit_code" => (int) $argv[1], "seed_output" => $argv[2]]);' -- "$code" "$seed_output") + fc_debug_log "H4" "seed failed, entrypoint exiting" "$seed_data" + echo "$seed_output" >&2 + exit "$code" + fi +else + fc_debug_log "H4" "seed skipped" '{"run_db_seed":"false"}' fi echo "[flixcooks] Starting Apache..." +fc_debug_log "H3" "executing web server command" "$(php -r 'echo json_encode(["command" => array_slice($argv, 1)]);' -- "$@")" exec "$@" diff --git a/docs/COOLIFY.md b/docs/COOLIFY.md index 5545c3d..f4cbf65 100644 --- a/docs/COOLIFY.md +++ b/docs/COOLIFY.md @@ -26,8 +26,8 @@ Die App startet **nicht**, wenn `DATABASE_URL` fehlt oder Postgres nicht erreich 1. Neues **Database** → PostgreSQL (16). 2. Notieren: - - Benutzer, Passwort, Datenbankname - - **Internal URL** (Host ist oft der Service-Name, z. B. `postgresql-xxxxx` oder was Coolify anzeigt) + - Benutzer, Passwort, Datenbankname + - **Internal URL** (Host ist oft der Service-Name, z. B. `postgresql-xxxxx` oder was Coolify anzeigt) 3. Format für die App: ```text @@ -50,22 +50,26 @@ postgresql://flixcooks:geheim@postgresql-flixcooks:5432/flixcooks 2. Dockerfile-Pfad: `Dockerfile` (Root). 3. Port: **80** (Container exponiert Apache auf 80). 4. Health Check (optional, empfohlen): - - Path: `/health.php` - - Erwartet HTTP 200 mit `{"status":"ok"}` + - Path: `/health.php` + - Erwartet HTTP 200 mit `{"status":"ok"}` ### Environment Variables (Pflicht) -| Variable | Beschreibung | -|----------|----------------| -| `DATABASE_URL` | Interne Postgres-URL von Coolify | + +| Variable | Beschreibung | +| --------------------- | --------------------------------- | +| `DATABASE_URL` | Interne Postgres-URL von Coolify | | `FLIXCOOKS_ADMIN_KEY` | Starkes Passwort für `/admin.php` | + ### Environment Variables (optional) -| Variable | Default | Beschreibung | -|----------|---------|----------------| -| `RUN_DB_SEED` | `false` | Einmalig `true` setzen → importiert `data/recipes.json` beim Start | -| `DB_WAIT_MAX_TRIES` | `30` | Warteversuche bis Postgres da ist (à 2 s) | + +| Variable | Default | Beschreibung | +| ------------------- | ------- | ------------------------------------------------------------------ | +| `RUN_DB_SEED` | `false` | Einmalig `true` setzen → importiert `data/recipes.json` beim Start | +| `DB_WAIT_MAX_TRIES` | `30` | Warteversuche bis Postgres da ist (à 2 s) | + Nach dem ersten erfolgreichen Deploy: `RUN_DB_SEED` wieder auf `false` oder entfernen. @@ -73,9 +77,11 @@ Nach dem ersten erfolgreichen Deploy: `RUN_DB_SEED` wieder auf `false` oder entf Mount für Impressum/Datenschutz (`data/site.json`): -| Mount Path (Container) | Inhalt | -|------------------------|--------| -| `/var/www/html/data` | `site.json` bleibt nach Redeploy erhalten | + +| Mount Path (Container) | Inhalt | +| ---------------------- | ----------------------------------------- | +| `/var/www/html/data` | `site.json` bleibt nach Redeploy erhalten | + Rezepte liegen in Postgres – **kein** Volume für Rezepte nötig. @@ -131,10 +137,13 @@ curl http://127.0.0.1:8080/health.php ## 7. Troubleshooting -| Problem | Lösung | -|---------|--------| -| Container startet nicht | Logs: DB nicht erreichbar → `DATABASE_URL` Host/Passwort prüfen | -| 503 „Datenbank nicht verfügbar“ | Gleiches Netzwerk in Coolify? Internal URL? | -| Leere Seite, Health OK | `RUN_DB_SEED=true` einmalig oder Admin-Rezepte anlegen | -| Admin geht nicht | `FLIXCOOKS_ADMIN_KEY` gesetzt? | -| `site.json` verloren nach Deploy | Volume auf `/var/www/html/data` mounten | + +| Problem | Lösung | +| -------------------------------- | --------------------------------------------------------------- | +| Container startet nicht | Logs: DB nicht erreichbar → `DATABASE_URL` Host/Passwort prüfen | +| 503 „Datenbank nicht verfügbar“ | Gleiches Netzwerk in Coolify? Internal URL? | +| Leere Seite, Health OK | `RUN_DB_SEED=true` einmalig oder Admin-Rezepte anlegen | +| Admin geht nicht | `FLIXCOOKS_ADMIN_KEY` gesetzt? | +| `site.json` verloren nach Deploy | Volume auf `/var/www/html/data` mounten | + + -- 2.54.0 From 587eb4502af12a171763fa1083e072c468f18fb6 Mon Sep 17 00:00:00 2001 From: LordSchmackes Date: Sat, 23 May 2026 13:04:43 +0200 Subject: [PATCH 08/12] Add detailed logging for health requests in health.php and enhance entrypoint.sh with Apache configuration logging --- docker/entrypoint.sh | 11 +++++++++++ health.php | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 07ddb5b..eb779b4 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -98,5 +98,16 @@ else fi echo "[flixcooks] Starting Apache..." +port_data=$(php -r 'echo json_encode([ + "env_port" => getenv("PORT") ?: "", + "env_host" => getenv("HOST") ?: "", + "apache_document_root" => getenv("APACHE_DOCUMENT_ROOT") ?: "", + "expected_container_port" => 80, + "coolify_hint" => "Application port must be 80", +]);') +fc_debug_log "H3,H6" "proxy port context before apache start" "$port_data" +apache_config_output=$(apache2ctl -S 2>&1 || true) +apache_config_data=$(php -r 'echo json_encode(["apache2ctl_S" => $argv[1]]);' -- "$apache_config_output") +fc_debug_log "H3,H6" "apache virtualhost config before start" "$apache_config_data" fc_debug_log "H3" "executing web server command" "$(php -r 'echo json_encode(["command" => array_slice($argv, 1)]);' -- "$@")" exec "$@" diff --git a/health.php b/health.php index f3d269b..f831234 100644 --- a/health.php +++ b/health.php @@ -6,13 +6,43 @@ header('Content-Type: application/json; charset=utf-8'); require __DIR__ . '/config.php'; +// #region agent log +function agent_debug_health_request_log(string $hypothesisId, string $message, array $data = []): void { + $payload = [ + 'sessionId' => '885d37', + 'runId' => 'coolify-bad-gateway', + 'hypothesisId' => $hypothesisId, + 'location' => 'health.php', + 'message' => $message, + 'data' => $data, + 'timestamp' => (int) round(microtime(true) * 1000), + ]; + $line = json_encode($payload, JSON_UNESCAPED_SLASHES) . PHP_EOL; + @file_put_contents(__DIR__ . '/debug-885d37.log', $line, FILE_APPEND); + @error_log('agent_debug ' . $line); +} +// #endregion + try { + agent_debug_health_request_log('H5,H7', 'health request reached PHP', [ + 'sapi' => PHP_SAPI, + 'request_uri' => $_SERVER['REQUEST_URI'] ?? '', + 'http_host' => $_SERVER['HTTP_HOST'] ?? '', + 'server_port' => $_SERVER['SERVER_PORT'] ?? '', + 'database_url_getenv_present' => getenv('DATABASE_URL') !== false && getenv('DATABASE_URL') !== '', + 'database_url_server_present' => isset($_SERVER['DATABASE_URL']) && $_SERVER['DATABASE_URL'] !== '', + 'dot_env_file_exists' => file_exists(__DIR__ . '/.env'), + ]); if (!extension_loaded('pdo_pgsql')) { throw new RuntimeException('pdo_pgsql extension missing'); } require_once __DIR__ . '/helpers.php'; require_database(); $response = ['status' => 'ok']; + agent_debug_health_request_log('H5,H7', 'health request returning ok', [ + 'exit_code' => 0, + 'response' => $response, + ]); echo json_encode($response, JSON_UNESCAPED_UNICODE); } catch (Throwable $e) { http_response_code(503); @@ -20,6 +50,11 @@ try { 'status' => 'error', 'message' => $e->getMessage(), ]; + agent_debug_health_request_log('H5,H7', 'health request returning error', [ + 'exit_code' => 1, + 'response' => $response, + 'error_class' => get_class($e), + ]); echo json_encode($response, JSON_UNESCAPED_UNICODE); exit(1); } -- 2.54.0 From 830950ad6612d9a4677bbd2ffc700f45cd5d3964 Mon Sep 17 00:00:00 2001 From: LordSchmackes Date: Sat, 23 May 2026 13:21:12 +0200 Subject: [PATCH 09/12] Refactor Docker entrypoint script by removing debug logging functions and simplifying database connection checks. Streamline schema application and seeding processes for improved clarity and efficiency. --- docker/entrypoint.sh | 91 ++------------------------------------------ health.php | 35 ----------------- 2 files changed, 3 insertions(+), 123 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index eb779b4..c5db388 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -3,111 +3,26 @@ set -euo pipefail cd /var/www/html -# region agent log -fc_debug_log() { - local hypothesis="$1" - local message="$2" - local data_json="${3:-{}}" - local data_b64 - data_b64=$(printf '%s' "$data_json" | base64 | tr -d '\n') - FC_DEBUG_HYPOTHESIS="$hypothesis" FC_DEBUG_MESSAGE="$message" FC_DEBUG_DATA_B64="$data_b64" php -r ' - $json = base64_decode(getenv("FC_DEBUG_DATA_B64") ?: ""); - $data = json_decode($json, true); - if (!is_array($data) && str_ends_with($json, "}}")) { - $data = json_decode(substr($json, 0, -1), true); - } - $payload = [ - "sessionId" => "885d37", - "runId" => "coolify-bad-gateway", - "hypothesisId" => getenv("FC_DEBUG_HYPOTHESIS") ?: "", - "location" => "docker/entrypoint.sh", - "message" => getenv("FC_DEBUG_MESSAGE") ?: "", - "data" => [ - "raw_json" => $json, - "parsed" => is_array($data) ? $data : null, - ], - "timestamp" => (int) round(microtime(true) * 1000), - ]; - $line = json_encode($payload, JSON_UNESCAPED_SLASHES) . PHP_EOL; - @file_put_contents("/var/www/html/debug-885d37.log", $line, FILE_APPEND); - fwrite(STDERR, "agent_debug " . $line); - ' -} -# endregion - echo "[flixcooks] Waiting for database..." TRIES=0 MAX_TRIES="${DB_WAIT_MAX_TRIES:-30}" -start_data=$(php -r 'echo json_encode([ - "database_url_present" => getenv("DATABASE_URL") !== false && getenv("DATABASE_URL") !== "", - "run_db_seed" => getenv("RUN_DB_SEED") ?: "", - "db_wait_max_tries" => getenv("DB_WAIT_MAX_TRIES") ?: "30", - "entrypoint_args" => array_slice($argv, 1), - "schema_file_exists" => file_exists("/var/www/html/scripts/schema.sql"), - "seed_file_exists" => file_exists("/var/www/html/data/recipes.json"), -]);' -- "$@") -fc_debug_log "H1,H2,H4" "entrypoint started" "$start_data" -until db_check_output=$(php scripts/db-check.php 2>&1); do +until php scripts/db-check.php >/dev/null 2>&1; do TRIES=$((TRIES + 1)) - fail_data=$(php -r 'echo json_encode([ - "attempt" => (int) $argv[1], - "max_tries" => (int) $argv[2], - "db_check_output" => $argv[3], - ]);' -- "$TRIES" "$MAX_TRIES" "$db_check_output") - fc_debug_log "H1" "db-check failed while waiting" "$fail_data" if [ "$TRIES" -ge "$MAX_TRIES" ]; then echo "[flixcooks] Database not reachable after ${MAX_TRIES} attempts." >&2 - fc_debug_log "H1" "entrypoint exiting because database never became reachable" "$fail_data" exit 1 fi sleep 2 done -success_data=$(php -r 'echo json_encode([ - "attempts_before_success" => (int) $argv[1], - "db_check_output" => $argv[2], -]);' -- "$TRIES" "$db_check_output") -fc_debug_log "H1" "db-check succeeded" "$success_data" echo "[flixcooks] Applying schema..." -if schema_output=$(php -r "require 'helpers.php'; require_database(); echo \"schema ok\n\";" 2>&1); then - schema_data=$(php -r 'echo json_encode(["schema_output" => $argv[1]]);' -- "$schema_output") - fc_debug_log "H2,H4" "schema apply succeeded" "$schema_data" -else - code=$? - schema_data=$(php -r 'echo json_encode(["exit_code" => (int) $argv[1], "schema_output" => $argv[2]]);' -- "$code" "$schema_output") - fc_debug_log "H2,H4" "schema apply failed, entrypoint exiting" "$schema_data" - echo "$schema_output" >&2 - exit "$code" -fi +php -r "require 'helpers.php'; require_database(); echo \"schema ok\n\";" if [ "${RUN_DB_SEED:-false}" = "true" ]; then echo "[flixcooks] Seeding recipes from data/recipes.json..." - if seed_output=$(php scripts/db-seed.php 2>&1); then - seed_data=$(php -r 'echo json_encode(["seed_output" => $argv[1]]);' -- "$seed_output") - fc_debug_log "H4" "seed succeeded" "$seed_data" - else - code=$? - seed_data=$(php -r 'echo json_encode(["exit_code" => (int) $argv[1], "seed_output" => $argv[2]]);' -- "$code" "$seed_output") - fc_debug_log "H4" "seed failed, entrypoint exiting" "$seed_data" - echo "$seed_output" >&2 - exit "$code" - fi -else - fc_debug_log "H4" "seed skipped" '{"run_db_seed":"false"}' + php scripts/db-seed.php fi echo "[flixcooks] Starting Apache..." -port_data=$(php -r 'echo json_encode([ - "env_port" => getenv("PORT") ?: "", - "env_host" => getenv("HOST") ?: "", - "apache_document_root" => getenv("APACHE_DOCUMENT_ROOT") ?: "", - "expected_container_port" => 80, - "coolify_hint" => "Application port must be 80", -]);') -fc_debug_log "H3,H6" "proxy port context before apache start" "$port_data" -apache_config_output=$(apache2ctl -S 2>&1 || true) -apache_config_data=$(php -r 'echo json_encode(["apache2ctl_S" => $argv[1]]);' -- "$apache_config_output") -fc_debug_log "H3,H6" "apache virtualhost config before start" "$apache_config_data" -fc_debug_log "H3" "executing web server command" "$(php -r 'echo json_encode(["command" => array_slice($argv, 1)]);' -- "$@")" exec "$@" diff --git a/health.php b/health.php index f831234..f3d269b 100644 --- a/health.php +++ b/health.php @@ -6,43 +6,13 @@ header('Content-Type: application/json; charset=utf-8'); require __DIR__ . '/config.php'; -// #region agent log -function agent_debug_health_request_log(string $hypothesisId, string $message, array $data = []): void { - $payload = [ - 'sessionId' => '885d37', - 'runId' => 'coolify-bad-gateway', - 'hypothesisId' => $hypothesisId, - 'location' => 'health.php', - 'message' => $message, - 'data' => $data, - 'timestamp' => (int) round(microtime(true) * 1000), - ]; - $line = json_encode($payload, JSON_UNESCAPED_SLASHES) . PHP_EOL; - @file_put_contents(__DIR__ . '/debug-885d37.log', $line, FILE_APPEND); - @error_log('agent_debug ' . $line); -} -// #endregion - try { - agent_debug_health_request_log('H5,H7', 'health request reached PHP', [ - 'sapi' => PHP_SAPI, - 'request_uri' => $_SERVER['REQUEST_URI'] ?? '', - 'http_host' => $_SERVER['HTTP_HOST'] ?? '', - 'server_port' => $_SERVER['SERVER_PORT'] ?? '', - 'database_url_getenv_present' => getenv('DATABASE_URL') !== false && getenv('DATABASE_URL') !== '', - 'database_url_server_present' => isset($_SERVER['DATABASE_URL']) && $_SERVER['DATABASE_URL'] !== '', - 'dot_env_file_exists' => file_exists(__DIR__ . '/.env'), - ]); if (!extension_loaded('pdo_pgsql')) { throw new RuntimeException('pdo_pgsql extension missing'); } require_once __DIR__ . '/helpers.php'; require_database(); $response = ['status' => 'ok']; - agent_debug_health_request_log('H5,H7', 'health request returning ok', [ - 'exit_code' => 0, - 'response' => $response, - ]); echo json_encode($response, JSON_UNESCAPED_UNICODE); } catch (Throwable $e) { http_response_code(503); @@ -50,11 +20,6 @@ try { 'status' => 'error', 'message' => $e->getMessage(), ]; - agent_debug_health_request_log('H5,H7', 'health request returning error', [ - 'exit_code' => 1, - 'response' => $response, - 'error_class' => get_class($e), - ]); echo json_encode($response, JSON_UNESCAPED_UNICODE); exit(1); } -- 2.54.0 From d8bb062c636b458d0dea718b0983fee41cbb1ce4 Mon Sep 17 00:00:00 2001 From: LordSchmackes Date: Sat, 23 May 2026 13:29:35 +0200 Subject: [PATCH 10/12] Update Dockerfile to disable AllowOverride for production, enhance admin.php to handle missing FLIXCOOKS_ADMIN_KEY with a user-friendly error page, and remove .htaccess file. Adjust documentation to reflect these changes and clarify admin key configuration. --- .htaccess | 4 ---- Dockerfile | 4 ++-- README.md | 2 +- admin.php | 29 ++++++++++++++++++++++++++++- docs/COOLIFY.md | 2 +- 5 files changed, 32 insertions(+), 9 deletions(-) delete mode 100644 .htaccess diff --git a/.htaccess b/.htaccess deleted file mode 100644 index f96c3a6..0000000 --- a/.htaccess +++ /dev/null @@ -1,4 +0,0 @@ -#KONSOLEH AREA START - PLEASE DO NOT EDIT MANUALLY BETWEEN THESE LINES -DirectoryIndex index.php -#KONSOLEH AREA END - PLEASE ADD MANUAL CHANGES BELOW -SetEnv FLIXCOOKS_ADMIN_KEY "vFDH.N_tVLEKNdR3fhLs" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2b55a59..32682a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,13 +7,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && a2enmod rewrite headers \ && rm -rf /var/lib/apt/lists/* -# Apache: document root + AllowOverride for .htaccess +# Apache: document root, no per-directory overrides in production ENV APACHE_DOCUMENT_ROOT=/var/www/html RUN sed -ri 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \ && sed -ri 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf \ && printf '%s\n' \ '' \ - ' AllowOverride All' \ + ' AllowOverride None' \ ' Require all granted' \ '' \ > /etc/apache2/conf-available/flixcooks.conf \ diff --git a/README.md b/README.md index 7f13002..da8a80d 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ http://localhost:8000 #### Option B: Local Apache (XAMPP / MAMP / WAMP) If you prefer running a full local stack: 1. Move or link the project directory inside your local server's document root (e.g., `htdocs` or `www`). -2. Ensure URL rewriting is enabled (the included `.htaccess` file handles caching and custom redirections). +2. Configure the virtual host to serve `index.php` as the directory index. 3. Access the site via your custom local virtual host (e.g., `http://localhost/flixcooks-website`). --- diff --git a/admin.php b/admin.php index 36ae186..85db7d0 100644 --- a/admin.php +++ b/admin.php @@ -2,9 +2,36 @@ session_start(); require __DIR__ . '/helpers.php'; -$ADMIN_KEY = getenv('FLIXCOOKS_ADMIN_KEY') ?: 'vFDH.N_tVLEKNdR3fhLs'; +$ADMIN_KEY = getenv('FLIXCOOKS_ADMIN_KEY') ?: ''; $authed = isset($_SESSION['fc_admin']) && $_SESSION['fc_admin'] === true; +if ($ADMIN_KEY === '') { + http_response_code(503); + ?> + + + + + + FlixCooks Admin Unavailable + + + + + + + + Date: Sat, 23 May 2026 20:27:06 +0200 Subject: [PATCH 11/12] Update README.md to clarify Apache configuration for local setup, specifying the use of `DirectoryIndex index.php` instead of relying on `.htaccess` overrides. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index da8a80d..3cb670b 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ http://localhost:8000 #### Option B: Local Apache (XAMPP / MAMP / WAMP) If you prefer running a full local stack: 1. Move or link the project directory inside your local server's document root (e.g., `htdocs` or `www`). -2. Configure the virtual host to serve `index.php` as the directory index. +2. Configure the virtual host or Apache server config to use `DirectoryIndex index.php`; this repo does not rely on `.htaccess` overrides. 3. Access the site via your custom local virtual host (e.g., `http://localhost/flixcooks-website`). --- -- 2.54.0 From b338e2bc30d18e986b3633c6d79674adf5de06ff Mon Sep 17 00:00:00 2001 From: LordSchmackes Date: Sat, 23 May 2026 23:18:19 +0200 Subject: [PATCH 12/12] Refactor recipe saving logic in helpers.php to include featured recipe handling and update execution order for tags, ingredients, utensils, and steps. Remove redundant featured recipe clearing from admin.php. --- admin.php | 4 ---- helpers.php | 22 +++++++++++++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/admin.php b/admin.php index 85db7d0..b07411a 100644 --- a/admin.php +++ b/admin.php @@ -332,9 +332,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet ], ]; - if ($featured) { - clear_featured_recipes(); - } if ($slugOriginal && $slugOriginal !== $slug) { delete_recipe($slugOriginal); } @@ -663,6 +660,5 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet - diff --git a/helpers.php b/helpers.php index d9cb7cf..1774209 100644 --- a/helpers.php +++ b/helpers.php @@ -258,6 +258,10 @@ function save_recipe(array $recipe, ?PDO $pdo = null): bool { try { $pdo->beginTransaction(); + if (!empty($recipe['featured'])) { + clear_featured_recipes($pdo); + } + $stmt = $pdo->prepare( 'INSERT INTO recipes ( slug, hero, prep_time, cook_time, total_time, servings, @@ -329,30 +333,34 @@ function save_recipe(array $recipe, ?PDO $pdo = null): bool { $block['difficulty'] ?? '', ]); - foreach (array_values($block['tags'] ?? []) as $i => $tag) { + $tagOrder = 0; + foreach (array_values($block['tags'] ?? []) as $tag) { $tag = trim((string) $tag); if ($tag !== '') { - $tagStmt->execute([$slug, $lang, $tag, $i]); + $tagStmt->execute([$slug, $lang, $tag, $tagOrder++]); } } - foreach (array_values($block['ingredients'] ?? []) as $i => $line) { + $ingredientOrder = 0; + foreach (array_values($block['ingredients'] ?? []) as $line) { $line = trim((string) $line); if ($line !== '') { - $ingredientStmt->execute([$slug, $lang, $line, $i]); + $ingredientStmt->execute([$slug, $lang, $line, $ingredientOrder++]); } } - foreach (array_values($block['utensils'] ?? []) as $i => $line) { + $utensilOrder = 0; + foreach (array_values($block['utensils'] ?? []) as $line) { $line = trim((string) $line); if ($line !== '') { - $utensilStmt->execute([$slug, $lang, $line, $i]); + $utensilStmt->execute([$slug, $lang, $line, $utensilOrder++]); } } $steps = array_values($block['steps'] ?? []); $videos = array_values($block['step_videos'] ?? []); $timers = array_values($block['step_timers'] ?? []); + $stepOrder = 0; foreach ($steps as $i => $step) { $step = trim((string) $step); if ($step === '') { @@ -361,7 +369,7 @@ function save_recipe(array $recipe, ?PDO $pdo = null): bool { $video = trim((string) ($videos[$i] ?? '')); $timerRaw = $timers[$i] ?? ''; $timerMinutes = ($timerRaw !== '' && is_numeric($timerRaw)) ? (int) $timerRaw : null; - $stepStmt->execute([$slug, $lang, $step, $video, $timerMinutes, $i]); + $stepStmt->execute([$slug, $lang, $step, $video, $timerMinutes, $stepOrder++]); } } -- 2.54.0