Merge pull request #13 from LordSchmackes/dev

Remove seed step: migrate all content to Postgres-only backend
This commit was merged in pull request #13.
This commit is contained in:
2026-06-22 10:38:39 +02:00
committed by GitHub
20 changed files with 154 additions and 512 deletions
+7 -7
View File
@@ -30,8 +30,8 @@ Dieses Dokument enthält den aktuellen Entwicklungsstand und detaillierte Aufgab
- Mikro-Animationen & Quick-Save Funktion
- [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`
- `init_db()` / `save_recipe()` / `save_site_settings()` in `helpers.php`
- Kein JSON-Fallback; Rezepte und Site-Daten liegen in Postgres
---
@@ -82,7 +82,7 @@ Favoriten, Ernährungsziel und Newsletter ohne Cloud-Backend.
Jedes Rezept erhält präzise Makronährstoffe (Kalorien, Proteine, Kohlenhydrate, Fette), die vom Admin gepflegt und dem Nutzer edel präsentiert werden.
- [x] **Datenmodell & Admin-Panel Erweiterung**
- [x] `data/recipes.json` Struktur anpassen: Hinzufügen von `nutrition` (`calories`, `protein`, `carbs`, `fat`)
- [x] Rezeptstruktur um `nutrition` (`calories`, `protein`, `carbs`, `fat`) erweitern
- [x] `admin.php` Formular erweitern:
- [x] Eingabefeld für Kalorien (kcal)
- [x] Eingabefeld für Eiweiß / Protein (g)
@@ -101,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` (externe MP4-URL) besitzen kann
- [x] Rezeptstruktur 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,13 +134,13 @@ Ein hochgradig interaktives, touch-freundliches Karussell direkt auf der Startse
---
### 🔥 Phase 6: PostgreSQL Datenbank
Rezepte in Postgres (JSONB), optional lokal per Docker.
Rezepte und Site-Daten in normalisierten Postgres-Tabellen, optional lokal per Docker.
- [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] `init_db()` legt Tabellen an; `scripts/db-seed.php` seedet aus `scripts/seed-data.php`
- [x] **PHP-Datenzugriff**
- [x] `load_recipes()` / `save_recipes()` mit JSON-Fallback
- [x] `load_recipes()` / `save_recipe()` ohne JSON-Fallback
- [x] `load_recipe_by_slug()` in `recipe.php`
---
+7 -7
View File
@@ -4,8 +4,8 @@ This document summarizes architectural knowledge, conventions, and learnings for
## 1. Project Architecture & Stack
- **Backend:** Vanilla PHP. No heavy frameworks.
- **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.
- **Database:** PostgreSQL required (`DATABASE_URL` in `.env`). Normalized tables in `scripts/schema.sql`; `load_recipes()` / `save_recipe()` and `load_site_settings()` / `save_site_settings()` in `helpers.php`. No runtime JSON files. One-time import: `php scripts/db-seed.php` from `scripts/seed-data.php`.
- **Site settings (legal pages):** Stored in Postgres table `site_settings`; no flat-file fallback.
- **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. Profile/favorites via `assets/fc-local.js`.
- **Config:** `config.php` loads `.env`, `get_db_connection()`. Never commit `.env` (see `.gitignore`).
@@ -27,13 +27,14 @@ This document summarizes architectural knowledge, conventions, and learnings for
## 5. PostgreSQL — Schema & Data Flow
### Relational schema (`scripts/schema.sql`)
Created by `ensure_recipe_schema()` on first DB use. Legacy JSONB `recipes.data` is migrated once automatically.
Created by `ensure_recipe_schema()` on first DB use. Legacy JSONB/file fallbacks are not supported.
| 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 |
| `site_settings` | imprint/privacy fields per language |
PHP still exposes the same nested arrays (`i18n`, `nutrition`, …) via `hydrate_recipes_from_db()`.
@@ -41,7 +42,7 @@ PHP still exposes the same nested arrays (`i18n`, `nutrition`, …) via `hydrate
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).
4. One-time import: `php scripts/db-seed.php` from `scripts/seed-data.php`.
### `config.php` functions
- `load_env()` — parses `.env`.
@@ -123,9 +124,8 @@ See `.agents/TODO.md` (e.g. PWA & offline support). README `Local Postgres (Dock
| 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 |
| `helpers.php` | `init_db`, `load_recipes`, `save_recipe`, site settings |
| `scripts/seed-data.php` | Seed arrays for recipes and site settings |
| `docker-compose.dev.yml` | Local Postgres |
| `scripts/db-check.php` | Connection + seed smoke test |
| `partials/head.php` | Firebase init via `get_firebase_config()` |
+17
View File
@@ -0,0 +1,17 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "Dev DB (Postgres only)",
"runtimeExecutable": "docker",
"runtimeArgs": ["compose", "-f", "docker-compose.dev.yml", "up"],
"port": 5432
},
{
"name": "Full stack (PHP app + Postgres)",
"runtimeExecutable": "docker",
"runtimeArgs": ["compose", "up", "--build"],
"port": 8080
}
]
}
+1 -1
View File
@@ -8,4 +8,4 @@ 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
# RUN_DB_SEED="true" # nur beim allerersten Deploy: Rezepte + Site-Daten in Postgres seeden
+1 -3
View File
@@ -24,9 +24,7 @@ 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
&& chmod +x /var/www/html/docker/entrypoint.sh
EXPOSE 80
+15 -12
View File
@@ -16,11 +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 and the PostgreSQL data access layer for recipes.
- `helpers.php`: Core PHP utilities and the PostgreSQL data access layer for recipes and site settings.
- `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/recipes.json`: Optional seed file only (`php scripts/db-seed.php`), not used at runtime.
- `scripts/schema.sql`: Relational table definitions for recipes.
- `scripts/seed-data.php`: One-time seed data for recipes, imprint, and privacy settings.
- `scripts/schema.sql`: Relational table definitions for recipes and site settings.
---
@@ -37,7 +37,7 @@ FlixCooks uses a modern, carefully curated vanilla tech-stack focused on lightni
### ⚙️ Backend & Data
- **Engine**: Vanilla PHP.
- **Database**: PostgreSQL only. `DATABASE_URL` is required; without a working DB connection the site returns HTTP 503.
- **Database**: PostgreSQL only. `DATABASE_URL` is required; without a working external DB connection the site returns HTTP 503.
- **Environment**: Custom `.env` variable parser integrated into PHP bootstrap.
---
@@ -58,7 +58,7 @@ Make sure you have the following installed on your local machine:
cp .env.example .env
```
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).
3. Seed the external database once: `php scripts/db-seed.php` (imports recipes and legal/site settings into SQL tables).
### 2. Local Postgres (Docker)
@@ -97,7 +97,8 @@ Nützliche SQL-Befehle in `psql`:
\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
SELECT recipe_slug, title FROM recipe_translations WHERE lang = 'en';
SELECT section, lang, setting_key FROM site_settings ORDER BY section, lang, setting_key;
\q -- beenden
```
@@ -135,7 +136,7 @@ If you prefer running a full local stack:
## 🗄️ Postgres in diesem Projekt (Kurzüberblick)
Rezepte liegen in **normalisierten SQL-Tabellen** (kein JSONB-Blob, kein Laufzeit-Fallback auf Dateien):
Rezepte und Site-Daten liegen in **normalisierten SQL-Tabellen**. Es gibt keinen JSONB-Blob, keinen Datei-Fallback und kein Laden von `data/*.json` zur Laufzeit.
| Tabelle | Inhalt |
|---------|--------|
@@ -145,19 +146,21 @@ Rezepte liegen in **normalisierten SQL-Tabellen** (kein JSONB-Blob, kein Laufzei
| `recipe_ingredients` | Zutatenzeilen |
| `recipe_utensils` | Werkzeugzeilen |
| `recipe_steps` | Schritte inkl. Video-URL und Timer |
| `site_settings` | Impressum- und Datenschutzfelder pro Sprache |
Schema: `scripts/schema.sql`. PHP baut daraus dieselben Arrays wie früher (`i18n.en`, `nutrition`, …), damit Templates unverändert bleiben.
**Ablauf:**
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.
2. Beim ersten Request: Tabellen anlegen (`ensure_recipe_schema()`).
3. `php scripts/db-seed.php` einmalig ausführen → Rezepte und Site-Daten werden in Postgres geschrieben.
4. `load_recipes()` und `load_site_settings()` lesen per SQL; ohne DB → HTTP 503 (`maintenance/db-unavailable.php`).
5. Admin: `save_recipe()`, `delete_recipe()` und `save_site_settings()` schreiben direkt in die Tabellen.
**Einmalig Daten laden:** `php scripts/db-seed.php` (aus `data/recipes.json`).
**Einmalig Daten laden:** `php scripts/db-seed.php` (aus `scripts/seed-data.php`, ohne JSON-Dateien).
Für **Staging/Production** nur `DATABASE_URL` in der Hosting-Umgebung setzen nie Production-Daten in der lokalen Dev-DB mischen.
Für **Staging/Production** muss eine externe Postgres-Datenbank vorhanden sein. Setze nur `DATABASE_URL` in der Hosting-Umgebung und mische nie Production-Daten in die lokale Dev-DB.
### Docker / Coolify
+3 -2
View File
@@ -78,10 +78,10 @@ if (!$authed) {
try {
$allRecipes = load_recipes();
$siteSettings = load_site_settings();
} catch (DatabaseUnavailableException $e) {
handle_database_unavailable($e);
}
$siteSettings = load_site_settings();
$message = null;
$errors = [];
$editing = null;
@@ -186,9 +186,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_
$siteSettings['imprint'] = $imprint;
$siteSettings['privacy'] = $privacy;
if (save_site_settings($siteSettings)) {
$siteSettings = load_site_settings(true);
$message = 'Settings saved.';
} else {
$errors[] = 'Could not save settings. Check permissions on data/site.json.';
$errors[] = 'Could not save settings. Check the database connection.';
}
}
}
-264
View File
@@ -1,264 +0,0 @@
[
{
"slug": "frische-tagliatelle-mit-cremiger-tomatensauce",
"hero": "/assets/pasta-tomato.jpg",
"prep_time": 50,
"cook_time": 10,
"total_time": 60,
"servings": 2,
"featured": false,
"coming_soon": false,
"nutrition": {
"calories": 0,
"protein": 0,
"carbs": 0,
"fat": 0
},
"i18n": {
"en": {
"title": "Tagliatelle with tomato sauce",
"description": "Simple but tasty, short cooking time but self made. The root dish to start in a cozy evening and satisfy the carbs cravings without any hidden additives. Noodles with Tomato Sauce, more hearty isnt possible.",
"category": "Pasta",
"difficulty": "Easy",
"tags": [
"Pasta",
"Fast",
"Vegetarian"
],
"ingredients": [
"300 g flour",
"3 eggs",
"10 cherry tomatoes",
"1/2 clove garlic",
"1 tbsp olive oil",
"80 ml cream",
"Fresh basil",
"Parmesan",
"Salt and pepper"
],
"utensils": [
"Rolling pin",
"Optional: Pasta machine",
"Optional: Blender"
],
"steps": [
"Pile the flour on your counter, press a well in the center, crack in the eggs. Gradually pull flour into the eggs, then knead 510 minutes until smooth.",
"Roll the dough to 12 mm thickness (rolling pin or pasta machine) and cut into tagliatelle.",
"Bring salted water to a boil. Meanwhile, halve tomatoes and sear in olive oil with a pinch of salt until lightly charred.",
"Toast the garlic briefly, then blend tomatoes with cream (or crush in the pan) and simmer to thicken slightly.",
"Boil tagliatelle for 23 minutes, drain, toss with the sauce, and finish with basil and Parmesan."
]
},
"de": {
"title": "Tagliatelle mit Tomatensauce",
"description": "Einfach, aber richtig lecker; kurze Kochzeit und trotzdem hausgemacht. Das Basisgericht für einen gemütlichen Abend, stillt den Kohlenhydrat-Hunger ohne versteckte Zusätze. Nudeln mit Tomatensauce herzhafter geht es kaum.",
"category": "Pasta",
"difficulty": "Einfach",
"tags": [
"Pasta",
"Schnell",
"Vegetarisch"
],
"ingredients": [
"300 g Mehl",
"3 Eier",
"10 Cherry-Tomaten",
"1/2 Knoblauchzehe",
"1 EL Olivenöl",
"80 ml Sahne",
"Frischer Basilikum",
"Parmesan",
"Salz und Pfeffer"
],
"utensils": [
"Nudelholz",
"Optional: Nudelmaschine",
"Optional: Mixer"
],
"steps": [
"Mehl auf der Arbeitsfläche anhäufen, eine Kuhle drücken, Eier hineingeben. Mehl nach und nach einarbeiten, dann 510 Minuten kneten, bis der Teig glatt ist.",
"Teig auf 12 mm ausrollen (mit Nudelholz oder Nudelmaschine) und in Tagliatelle schneiden.",
"Gesalzenes Wasser aufsetzen; währenddessen Tomaten halbieren und mit Olivenöl und einer Prise Salz in der Pfanne anrösten, bis Röstnoten entstehen.",
"Knoblauch kurz mitrösten, dann Tomaten mit Sahne pürieren (Mixer) oder in der Pfanne zerdrücken und kurz einkochen lassen.",
"Tagliatelle 23 Minuten kochen, abgießen und mit der Sauce vermengen. Mit Basilikum und Parmesan anrichten."
]
}
}
},
{
"slug": "oat-pancakes",
"hero": "/assets/pancakes.jpg",
"prep_time": 0,
"cook_time": 0,
"total_time": 0,
"servings": 2,
"featured": false,
"coming_soon": true,
"nutrition": {
"calories": 0,
"protein": 0,
"carbs": 0,
"fat": 0
},
"i18n": {
"en": {
"title": "Oat-Pancakes",
"description": "",
"category": "",
"difficulty": "Easy",
"tags": [],
"ingredients": [],
"utensils": [],
"steps": []
},
"de": {
"title": "Hafer-Pfannkuchen",
"description": "",
"category": "",
"difficulty": "Einfach",
"tags": [],
"ingredients": [],
"utensils": [],
"steps": []
}
}
},
{
"slug": "steak-with-onion-jam",
"hero": "/assets/steak.JPEG",
"prep_time": 15,
"cook_time": 15,
"total_time": 30,
"servings": 2,
"featured": true,
"coming_soon": false,
"nutrition": {
"calories": 0,
"protein": 0,
"carbs": 0,
"fat": 0
},
"i18n": {
"en": {
"title": "Steak with onion jam",
"description": "A hearty meal after a long day, best enjoyed with a glass of red wine and in good company—because the onion jam still isnt satisfied on its own.",
"category": "",
"difficulty": "Easy",
"tags": [
"meat",
"dinner"
],
"ingredients": [
"2 x 250g rump steak",
"300g potatoes",
"1 large red onion",
"1/2 clove garlic",
"50ml cream",
"50ml red wine",
"4 carrots (yellow and purple)",
"parmesan",
"Sicilian orange salt",
"thyme",
"1 tbsp butter"
],
"utensils": [
"knife",
"cutting board",
"pan",
"pot",
"oven"
],
"steps": [
"Take the steak out 30 minutes before cooking",
"Halve the potatoes",
"Halve the carrots",
"Dice the onion",
"Cook the potatoes and carrots until al dente",
"Sear the steak with butter",
"Once both sides are golden brown, bake for 6 minutes at 160°C (medium); for medium rare, let it rest after searing both sides for 3 minutes",
"Sauté the onion in the steak butter, deglaze with red wine, and add cream once the alcohol has evaporated",
"Plate and serve!"
]
},
"de": {
"title": "Steak mit Zwiebelmarmelade",
"description": "Eine schwere Mahlzeit nach einem langen Tag, am besten zu einem Schluck Rotwein und in Gesellschaft genießen, da die Zwiebelmarmelade noch nicht genug davon hat.",
"category": "",
"difficulty": "Einfach",
"tags": [
"Fleisch",
"Abendessen"
],
"ingredients": [
"2 x 250g Rumpsteak",
"300gr Kartoffeln",
"1 Große Rote Zwiebel",
"1/2 Knoblauch-Zehe",
"50ml Sahne",
"50ml Rotwein",
"4 Karotten (Geld und Lila)",
"Parmesan",
"Sizilianisches Orangensalz",
"Thymian",
"1 EL Butter"
],
"utensils": [
"Messer",
"Brett",
"Pfanne",
"Topf",
"Ofen"
],
"steps": [
"Steak 30 Minuten vor dem anbraten raus legen",
"Kartoffeln halbieren",
"Karotten halbieren",
"Zwiebel würfeln",
"Kartoffeln und Karotten al dente kochen",
"Steak anbraten mit Butter",
"sobald beide seiten goldbraun sind für 6 Minuten bei 160grad backen (Medium) für Medium Rare nur ruhen lassen nachdem es von beiden Seiten für 3 Minuten angebraten wurde",
"Zwiebel in Steak-Butter anschwitzen, mit Rotwein ablöschen und Sahne dazu geben nachdem er Alkohol verdünstet ist",
"Anrichten!"
]
}
}
},
{
"slug": "oat-cake",
"hero": "/assets/haferkuchen.JPEG",
"prep_time": 0,
"cook_time": 0,
"total_time": 0,
"servings": 2,
"featured": false,
"coming_soon": true,
"nutrition": {
"calories": 0,
"protein": 0,
"carbs": 0,
"fat": 0
},
"i18n": {
"en": {
"title": "Oat cake",
"description": "",
"category": "",
"difficulty": "Easy",
"tags": [],
"ingredients": [],
"utensils": [],
"steps": []
},
"de": {
"title": "Haferkuchen",
"description": "",
"category": "",
"difficulty": "Einfach",
"tags": [],
"ingredients": [],
"utensils": [],
"steps": []
}
}
}
]
-54
View File
@@ -1,54 +0,0 @@
{
"imprint": {
"de": {
"owner_name": "Felix Brockers",
"address": "N.A",
"email": "contact@flixcooks.at",
"phone": "N.A",
"legal_form": "Kleines Impressum; kein Firmenbucheintrag/UID hinterlegt.",
"business_purpose": "Foodblog & Rezeptmarketing.",
"wko_membership": "",
"authority": "",
"uid": "",
"odr": "https://consumer-redress.ec.europa.eu/",
"last_updated": "2026-03-23"
},
"en": {
"owner_name": "Felix Brockers",
"address": "N.A",
"email": "contact@flixcooks.at",
"phone": "N.A",
"legal_form": "Small website notice; no commercial register/UID provided.",
"business_purpose": "Food blog & recipe marketing.",
"wko_membership": "",
"authority": "",
"uid": "",
"odr": "https://consumer-redress.ec.europa.eu/",
"last_updated": "2026-03-23"
}
},
"privacy": {
"de": {
"controller": "Felix Brockers",
"contact_email": "contact@flixcooks.at",
"contact_phone": "N.A",
"address": "N.A",
"hosting_provider": "Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen, Deutschland",
"log_retention_days": 30,
"cookie_statement": "Keine Tracking- oder Marketing-Cookies; nur technisch notwendige.",
"purposes": "Betrieb und Bereitstellung der Website, Beantwortung von Kontaktanfragen.",
"last_updated": "2026-03-23"
},
"en": {
"controller": "Felix Brockers",
"contact_email": "contact@flixcooks.at",
"contact_phone": "N.A",
"address": "N.A",
"hosting_provider": "Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen, Germany",
"log_retention_days": 30,
"cookie_statement": "No tracking or marketing cookies; only technically necessary cookies.",
"purposes": "Operating and providing the website, responding to contact requests.",
"last_updated": "2026-03-23"
}
}
}
+5 -1
View File
@@ -2,7 +2,11 @@
require __DIR__ . '/helpers.php';
$lang = strtolower((string)(filter_input(INPUT_GET, 'lang', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?: 'en')) === 'de' ? 'de' : 'en';
$settings = load_site_settings();
try {
$settings = load_site_settings();
} catch (DatabaseUnavailableException $e) {
handle_database_unavailable($e);
}
$privacy = $settings['privacy'][$lang] ?? ($settings['privacy']['en'] ?? []);
$langSwitchLabel = $lang === 'de' ? 'DE' : 'EN';
+2 -8
View File
@@ -3,28 +3,23 @@
# 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: .
restart: unless-stopped
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
restart: unless-stopped
environment:
POSTGRES_USER: flixcooks
POSTGRES_PASSWORD: flixcooks_prod
@@ -39,4 +34,3 @@ services:
volumes:
pg_data:
site_data:
-5
View File
@@ -19,10 +19,5 @@ 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 "$@"
+17 -39
View File
@@ -12,13 +12,12 @@ Zwei getrennte Ressourcen: **PostgreSQL** und **PHP-Web-App** (dieses Repo als D
│ │ (Service B) │ :5432 │ Apache + PHP 8.3 │ │
│ └──────────────┘ │ Port 80 → Traefik/HTTPS │ │
│ ▲ └─────────────────────────┘ │
│ │
│ Volume (Daten) Volume optional:
│ data/ (site.json) │
│ │
│ Volume (Daten)
└─────────────────────────────────────────────────────────┘
```
Die App startet **nicht**, wenn `DATABASE_URL` fehlt oder Postgres nicht erreichbar ist.
Die App startet **nicht**, wenn `DATABASE_URL` fehlt oder Postgres nicht erreichbar ist. Rezepte, Impressum und Datenschutz liegen komplett in Postgres.
---
@@ -65,25 +64,13 @@ postgresql://flixcooks:geheim@postgresql-flixcooks:5432/flixcooks
### 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 |
| ------------------- | ------- | ---------------------------------------------------------------------------- |
| `DB_WAIT_MAX_TRIES` | `30` | Warteversuche bis Postgres da ist (à 2 s) |
### Persistent Storage
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.
Nur PostgreSQL benötigt persistenten Speicher. Für die Web-App selbst ist kein `/var/www/html/data`-Volume mehr nötig, weil Rezepte und Site-Settings in Postgres liegen.
---
@@ -91,28 +78,21 @@ Rezepte liegen in Postgres **kein** Volume für Rezepte nötig.
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"}`.
3. `https://deine-domain/health.php``{"status":"ok"}`.
4. `https://deine-domain/admin.php` → Rezepte anlegen, Impressum/Datenschutz unter „Site settings" befüllen.
### 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.
Tabellen legt der Container beim Start automatisch an (`scripts/schema.sql` via `require_database()`). Eine leere DB ist kein Fehler — die Site startet mit Platzhalter-Impressum/Datenschutz und zeigt eine leere Rezeptliste. Inhalte werden ausschließlich über `/admin.php` gepflegt.
---
## 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
```
@@ -122,7 +102,6 @@ curl http://127.0.0.1:8080/health.php
- 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.
---
@@ -138,12 +117,11 @@ 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 | Rezepte über `/admin.php` anlegen, Site-Settings befüllen |
| Admin geht nicht | `FLIXCOOKS_ADMIN_KEY` gesetzt? |
+60 -65
View File
@@ -69,61 +69,12 @@ function recipe_base_from_row(array $row): array {
];
}
function decode_legacy_jsonb_value($value): ?array {
if (is_array($value)) {
return $value;
}
if (is_string($value)) {
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : null;
}
if ($value !== null) {
$decoded = json_decode(json_encode($value), true);
return is_array($decoded) ? $decoded : null;
}
return null;
}
function migrate_legacy_jsonb_storage(PDO $pdo): void {
if (!db_table_has_column($pdo, 'recipes', 'data')) {
return;
}
$legacy = [];
$stmt = $pdo->query('SELECT slug, data FROM recipes');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$recipe = decode_legacy_jsonb_value($row['data']);
if (is_array($recipe) && !empty($recipe['slug'])) {
$legacy[] = $recipe;
}
}
$pdo->exec('DROP TABLE IF EXISTS recipe_steps CASCADE');
$pdo->exec('DROP TABLE IF EXISTS recipe_utensils CASCADE');
$pdo->exec('DROP TABLE IF EXISTS recipe_ingredients CASCADE');
$pdo->exec('DROP TABLE IF EXISTS recipe_tags CASCADE');
$pdo->exec('DROP TABLE IF EXISTS recipe_translations CASCADE');
$pdo->exec('DROP TABLE IF EXISTS recipes CASCADE');
apply_recipe_schema($pdo);
foreach ($legacy as $recipe) {
save_recipe($recipe, $pdo);
}
}
function ensure_recipe_schema(PDO $pdo): void {
static $ready = false;
if ($ready) {
return;
}
if (db_table_has_column($pdo, 'recipes', 'data') && !db_table_has_column($pdo, 'recipes', 'calories')) {
migrate_legacy_jsonb_storage($pdo);
$ready = true;
return;
}
apply_recipe_schema($pdo);
$ready = true;
}
@@ -446,29 +397,73 @@ function default_site_settings(): array {
}
/**
* Load site-wide settings for legal pages, merging with defaults.
* Load site-wide settings for legal pages from Postgres.
*/
function load_site_settings(): array {
$defaults = default_site_settings();
$path = __DIR__ . '/data/site.json';
if (!file_exists($path)) {
return $defaults;
function load_site_settings(bool $refresh = false): array {
static $cache = null;
if ($cache !== null && !$refresh) {
return $cache;
}
$raw = file_get_contents($path);
$data = json_decode($raw, true);
if (!is_array($data)) {
return $defaults;
$settings = default_site_settings();
$pdo = require_database();
$stmt = $pdo->query(
'SELECT section, lang, setting_key, setting_value FROM site_settings ORDER BY section, lang, setting_key'
);
$hasRows = false;
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$section = $row['section'];
$lang = $row['lang'];
$key = $row['setting_key'];
if (!isset($settings[$section][$lang])) {
continue;
}
$settings[$section][$lang][$key] = $key === 'log_retention_days'
? (int) $row['setting_value']
: $row['setting_value'];
$hasRows = true;
}
return array_replace_recursive($defaults, $data);
$cache = $settings;
return $cache;
}
/**
* Persist site-wide settings for legal pages.
* Persist site-wide settings for legal pages in Postgres.
*/
function save_site_settings(array $settings): bool {
$path = __DIR__ . '/data/site.json';
$json = json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return (bool) file_put_contents($path, $json);
function save_site_settings(array $settings, ?PDO $pdo = null): bool {
$pdo = $pdo ?? require_database();
try {
$pdo->beginTransaction();
$pdo->exec("DELETE FROM site_settings WHERE section IN ('imprint', 'privacy')");
$stmt = $pdo->prepare(
'INSERT INTO site_settings (section, lang, setting_key, setting_value, updated_at)
VALUES (?, ?, ?, ?, NOW())
ON CONFLICT (section, lang, setting_key) DO UPDATE SET
setting_value = EXCLUDED.setting_value,
updated_at = NOW()'
);
foreach (['imprint', 'privacy'] as $section) {
foreach (['de', 'en'] as $lang) {
foreach (($settings[$section][$lang] ?? []) as $key => $value) {
$stmt->execute([$section, $lang, $key, (string) $value]);
}
}
}
$pdo->commit();
return true;
} catch (\Exception $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log('save_site_settings failed: ' . $e->getMessage());
return false;
}
}
function slugify(string $text): string {
+5 -1
View File
@@ -2,7 +2,11 @@
require __DIR__ . '/helpers.php';
$lang = strtolower((string)(filter_input(INPUT_GET, 'lang', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?: 'en')) === 'de' ? 'de' : 'en';
$settings = load_site_settings();
try {
$settings = load_site_settings();
} catch (DatabaseUnavailableException $e) {
handle_database_unavailable($e);
}
$imprint = $settings['imprint'][$lang] ?? ($settings['imprint']['en'] ?? []);
$langSwitchLabel = $lang === 'de' ? 'DE' : 'EN';
+1
View File
@@ -3,6 +3,7 @@ require __DIR__ . '/helpers.php';
try {
$allRecipes = load_recipes();
load_site_settings();
} catch (DatabaseUnavailableException $e) {
handle_database_unavailable($e);
}
+1
View File
@@ -3,6 +3,7 @@ require __DIR__ . '/helpers.php';
try {
$allRecipes = load_recipes();
load_site_settings();
} catch (DatabaseUnavailableException $e) {
handle_database_unavailable($e);
}
+1
View File
@@ -3,6 +3,7 @@ require __DIR__ . '/helpers.php';
try {
$allRecipes = load_recipes();
load_site_settings();
} catch (DatabaseUnavailableException $e) {
handle_database_unavailable($e);
}
-43
View File
@@ -1,43 +0,0 @@
<?php
/**
* Einmaliger Import aus data/recipes.json in die relationale DB.
* Die Website liest zur Laufzeit nur noch aus Postgres.
*
* php scripts/db-seed.php
*/
require_once dirname(__DIR__) . '/helpers.php';
$seedPath = dirname(__DIR__) . '/data/recipes.json';
if (!file_exists($seedPath)) {
fwrite(STDERR, "Seed-Datei fehlt: data/recipes.json\n");
exit(1);
}
$raw = file_get_contents($seedPath);
$recipes = json_decode($raw, true);
if (!is_array($recipes)) {
fwrite(STDERR, "Ungültiges JSON in data/recipes.json\n");
exit(1);
}
try {
$pdo = require_database();
} catch (DatabaseUnavailableException $e) {
fwrite(STDERR, $e->getMessage() . "\n");
exit(1);
}
$count = 0;
foreach ($recipes as $recipe) {
if (!is_array($recipe) || empty($recipe['slug'])) {
continue;
}
if (save_recipe($recipe, $pdo)) {
$count++;
echo " + {$recipe['slug']}\n";
} else {
fwrite(STDERR, " ! Fehler bei {$recipe['slug']}\n");
}
}
echo "\nImport abgeschlossen: {$count} Rezepte.\n";
+11
View File
@@ -68,3 +68,14 @@ CREATE TABLE IF NOT EXISTS recipe_steps (
);
CREATE INDEX IF NOT EXISTS idx_recipe_steps_slug_lang ON recipe_steps (recipe_slug, lang);
CREATE TABLE IF NOT EXISTS site_settings (
section VARCHAR(32) NOT NULL CHECK (section IN ('imprint', 'privacy')),
lang CHAR(2) NOT NULL CHECK (lang IN ('en', 'de')),
setting_key VARCHAR(64) NOT NULL,
setting_value TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (section, lang, setting_key)
);
CREATE INDEX IF NOT EXISTS idx_site_settings_section_lang ON site_settings (section, lang);