Merge pull request #9 from LordSchmackes/dev

Dev
This commit was merged in pull request #9.
This commit is contained in:
2026-05-23 23:30:54 +02:00
committed by GitHub
29 changed files with 1782 additions and 598 deletions
+32 -25
View File
@@ -12,10 +12,10 @@ Dieses Dokument enthält den aktuellen Entwicklungsstand und detaillierte Aufgab
- Fullscreen Overlay-Navigation (`LiquidOverlayMenu` & `SlideUpTextHover`) - Fullscreen Overlay-Navigation (`LiquidOverlayMenu` & `SlideUpTextHover`)
- Geschmeidiges Scrollverhalten (`LenisSmoothScroll` & `IndexSectionIndicator`) - Geschmeidiges Scrollverhalten (`LenisSmoothScroll` & `IndexSectionIndicator`)
- Layout-Raster (`FloemaLayoutGrid`) & Premium-Buttons - Layout-Raster (`FloemaLayoutGrid`) & Premium-Buttons
- [x] **Phase 2: Authentifizierung & Firebase Integration** - [x] **Phase 2: Profil & Personalisierung (lokal)**
- Firebase-Projekt Setup & Anbindung - Ernährungsziel & Favoriten im Browser (`fc-local.js`)
- Newsletter-System (Firebase Firestore) - Newsletter per Mailto
- Benutzerprofile (Registrierung, Login, Favoriten) - Profilseite `login.php`
- [x] **Phase 3: Nährwerte & Rezepterweiterung** - [x] **Phase 3: Nährwerte & Rezepterweiterung**
- Datenmodell & Admin.php Erweiterung - Datenmodell & Admin.php Erweiterung
- Visuelles Nährwert-Widget (Floema-Präzision) - Visuelles Nährwert-Widget (Floema-Präzision)
@@ -28,6 +28,10 @@ Dieses Dokument enthält den aktuellen Entwicklungsstand und detaillierte Aufgab
- Touch-Swipe-Karussell direkt auf index.php (unter Landing-Page) - Touch-Swipe-Karussell direkt auf index.php (unter Landing-Page)
- Filteralgorithmus nach Benutzer-Ernährungszielen - Filteralgorithmus nach Benutzer-Ernährungszielen
- Mikro-Animationen & Quick-Save Funktion - Mikro-Animationen & Quick-Save Funktion
- [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`
--- ---
@@ -60,26 +64,17 @@ Das visuelle Fundament der Website. Die Etablierung des Premium-Designs stellt s
--- ---
### 🔒 Phase 2: Authentifizierung & Firebase Integration ### 🔒 Phase 2: Profil & Personalisierung (lokal)
Das Fundament für Personalisierung und Newsletter-Abos über eine sichere Firebase-Anbindung (Auth & Firestore). Favoriten, Ernährungsziel und Newsletter ohne Cloud-Backend.
- [x] **Firebase Setup & Initialisierung** - [x] **Profilseite `login.php`**
- [x] Firebase SDK Client-Side Einbindung in `partials/head.php` or separaten Helper - [x] Ernährungsziel wählen und in `localStorage` speichern (`fc-local.js`)
- [x] Firebase Config in umweltabhängigen Settings/Variablen auslagern - [x] Gespeicherte Favoriten anzeigen
- [x] Firestore-Datenbank initialisieren (`subscribers` und `users` Collections)
- [x] **Premium Login- & Registrierungsseite**
- [x] Erstellung der Seite `login.php` (und optional `signup.php`) im neuen Design-System-Stil
- [x] Firebase Authentication (Email/Passwort Login & Registrierung) implementieren
- [x] Responsive UI mit sanften Error- & Success-Meldungen und Eingabefeldern im Glasmorphismus-Look
- [x] Client-seitige und Server-seitige Session-Synchronisation (z.B. Firebase Session Tokens an PHP-Session via Ajax senden)
- [x] **Favoriten-Funktion (Rezept-Bookmarks)** - [x] **Favoriten-Funktion (Rezept-Bookmarks)**
- [x] Firestore-Collection `user_favorites` anlegen (Format: `userId` -> Liste von `recipeSlugs`) - [x] Herz-Icon auf Karten, Speicherung in `localStorage`
- [x] "Rezept speichern" Button (Herz-Icon) auf den Rezeptkarten und Rezept-Details mit dynamischem Login-Check - [x] Mikro-Animationen für das Herz-Icon
- [x] Mikro-Animationen für das Herz-Icon (Scale up/down, Fülleffekte mit `var(--ease-overshoot)`) - [x] **Newsletter**
- [x] **Firebase-gestütztes Newsletter-Abo** - [x] Footer-Formular mit `mailto:`-Weiterleitung
- [x] Newsletter-Eingabefeld im Footer (`partials/footer.php`) implementieren
- [x] AJAX-Submit-Handler: E-Mail-Adresse prüfen und in Firestore Collection `subscribers` speichern
- [x] Elegantes Feedback-UI (Erfolgsmeldung ohne Page-Reload)
--- ---
@@ -106,7 +101,7 @@ Jedes Rezept erhält präzise Makronährstoffe (Kalorien, Proteine, Kohlenhydrat
Ein immersiver Kochmodus, der Anwendern Schritt-für-Schritt durch die Zubereitung führt inklusive Videoanleitungen und integrierten Timern. Ein immersiver Kochmodus, der Anwendern Schritt-für-Schritt durch die Zubereitung führt inklusive Videoanleitungen und integrierten Timern.
- [x] **Video-Verknüpfung im Admin-Panel** - [x] **Video-Verknüpfung im Admin-Panel**
- [x] Rezeptstruktur in `data/recipes.json` erweitern, damit jeder Arbeitsschritt (`steps`) eine optionale `video_url` (MP4 aus Firebase Storage oder extern) besitzen kann - [x] Rezeptstruktur in `data/recipes.json` erweitern, damit jeder Arbeitsschritt (`steps`) eine optionale `video_url` (externe MP4-URL) besitzen kann
- [x] `admin.php` erweitern, um Video-URLs pro Einzelschritt einzugeben - [x] `admin.php` erweitern, um Video-URLs pro Einzelschritt einzugeben
- [x] **Fullscreen Cooking-Mode Overlay** - [x] **Fullscreen Cooking-Mode Overlay**
- [x] Trigger-Button "Kochmodus starten" (`CapitoliumRevealButton` Stil) auf `recipe.php` einbauen - [x] Trigger-Button "Kochmodus starten" (`CapitoliumRevealButton` Stil) auf `recipe.php` einbauen
@@ -128,16 +123,28 @@ Ein hochgradig interaktives, touch-freundliches Karussell direkt auf der Startse
- [x] CSS-Klammern (`clamp()`) für responsive Skalierung der Karten - [x] CSS-Klammern (`clamp()`) für responsive Skalierung der Karten
- [x] Flüssiges Einblenden per Scroll-Reveal (`reveal-target`) mit Anbindung an `LenisSmoothScroll` - [x] Flüssiges Einblenden per Scroll-Reveal (`reveal-target`) mit Anbindung an `LenisSmoothScroll`
- [x] **Personalisiertes Goal-Matching** - [x] **Personalisiertes Goal-Matching**
- [x] Falls der Benutzer eingeloggt ist, sein Ernährungsziel aus den Firebase-Profildaten abrufen - [x] Ernährungsziel aus `localStorage` (`fc-local.js`) für Karussell-Filter
- [x] Rezepte dynamisch filtern (z.B. High-Protein Rezepte bei Ziel "Muskelaufbau", Low-Carb Rezepte bei Ziel "Abnehmen") - [x] Rezepte dynamisch filtern (z.B. High-Protein Rezepte bei Ziel "Muskelaufbau", Low-Carb Rezepte bei Ziel "Abnehmen")
- [x] Personalisierte "Für dich empfohlen"-Badge auf den Karten anzeigen - [x] Personalisierte "Für dich empfohlen"-Badge auf den Karten anzeigen
- [x] **Mikro-Interaktionen & Quick-Save** - [x] **Mikro-Interaktionen & Quick-Save**
- [x] Touch-Gesten-Unterstützung (Wischen zum Blättern mit sanftem Trägheits-Feedback) - [x] Touch-Gesten-Unterstützung (Wischen zum Blättern mit sanftem Trägheits-Feedback)
- [x] Quick-Save Button direkt auf den Karussell-Karten (Herzeffekt mit direktem Firebase Firestore Sync) - [x] Quick-Save Button direkt auf den Karussell-Karten (Herzeffekt, `localStorage`)
- [x] Hover-Reveal-Effekte für Rezeptdetails (Zutaten-Vorschau oder Kochzeit-Highlight schiebt sich weich hoch) - [x] Hover-Reveal-Effekte für Rezeptdetails (Zutaten-Vorschau oder Kochzeit-Highlight schiebt sich weich hoch)
--- ---
### 🔥 Phase 6: PostgreSQL Datenbank
Rezepte in Postgres (JSONB), 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] **PHP-Datenzugriff**
- [x] `load_recipes()` / `save_recipes()` mit JSON-Fallback
- [x] `load_recipe_by_slug()` in `recipe.php`
---
## 📈 Status-Legende ## 📈 Status-Legende
- `[ ]` Noch nicht begonnen - `[ ]` Noch nicht begonnen
- `[/]` In Bearbeitung - `[/]` In Bearbeitung
+119 -25
View File
@@ -1,37 +1,131 @@
# FlixCooks Project Brain # 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 ## 1. Project Architecture & Stack
- **Backend:** Vanilla PHP. The project intentionally avoids heavy frameworks. - **Backend:** Vanilla PHP. No heavy frameworks.
- **Database:** Flat-file JSON database (`data/recipes.json`). Data is loaded and saved via utility functions in `helpers.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`.
- **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. - **Site settings (legal pages):** Flat-file `data/site.json` via `load_site_settings()` / `save_site_settings()` — not in Postgres.
- **Frontend:** Server-rendered PHP templates (`index.php`, `recipe.php`) with Vanilla JavaScript and Vanilla CSS. No Tailwind or heavy component libraries. - **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`).
## 2. Design & Aesthetics ## 2. Design & Aesthetics
- **CSS:** Highly customized CSS with modern design tokens (e.g., `var(--ease-out-expo)`, `var(--surface-1)`). - **CSS:** Custom properties (`var(--ease-out-expo)`, `var(--surface-1)`), glassmorphism, `FloemaLayoutGrid`.
- **Animations:** Employs sophisticated micro-animations, glassmorphism (`backdrop-filter: blur`), and dynamic layouts (e.g., `clip-path` for overlays). - **Lenis:** Call `lenis.stop()` when opening fullscreen overlays (e.g. Cooking Mode); `lenis.start()` on close.
- **Smooth Scrolling:** Uses **Lenis** (`LenisSmoothScroll`). - **Preloader:** `CapitoliumPreloader` on homepage; once per session via `sessionStorage('flixcooks_preloader_seen')`.
- *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.
## 3. Antigravity Agent Configuration ## 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: - **Workspace Rules:** `.agents/rules/` with `always_on: true` and `glob: "*"` in frontmatter.
```yaml - **Custom Skills:** `.agents/skills/` (e.g. `close_feature.json` for Git merge workflow).
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.
## 4. GitHub Actions & CI/CD ## 4. GitHub Actions & CI/CD
- **Gemini Code Review Automation:** We integrated `petarzarkov/gemini-code-review-action` to automatically review PRs. - **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`).
- **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.
## 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.
## 6. Next Steps ## 5. PostgreSQL — Schema & Data Flow
According to `TODO.md`, the next major feature block is **Phase 5 (PWA & Offline Support)**, which involves service workers, manifest files, and enabling the app to be installable on mobile devices.
### Relational schema (`scripts/schema.sql`)
Created by `ensure_recipe_schema()` on first DB use. Legacy JSONB `recipes.data` is migrated once automatically.
| 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 |
PHP still exposes the same nested arrays (`i18n`, `nutrition`, …) via `hydrate_recipes_from_db()`.
### Runtime flow
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`.
- `get_db_connection()` — PDO or `null`.
- `DatabaseUnavailableException` — thrown when DB is required but missing.
---
## 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 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`.
### 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.
- **`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 hosting secrets, not in repo.
---
## 7. Troubleshooting (known issues)
| Symptom | Cause | Fix |
|--------|--------|-----|
| 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` |
| 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):** Normalized SQL tables; `db-seed.php`; no runtime JSON recipes.
- **Local dev Postgres:** `docker-compose.dev.yml`, README section, `scripts/db-check.php`, `.env.example` with `DATABASE_URL`.
- **Local 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.
## 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()` |
@@ -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 3343)
- `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.
+12
View File
@@ -0,0 +1,12 @@
.git
.github
.agents
.env
.env.*
!.env.example
docker-compose.dev.yml
docker-compose.yml
terminals
*.md
!README.md
.cursor
+10 -8
View File
@@ -1,9 +1,11 @@
# FlixCooks Firebase Configuration # FlixCooks lokale Entwicklung (.env wird nicht committed)
# Replace these placeholder values with your actual Firebase project settings. # Kopieren: cp .env.example .env
FIREBASE_API_KEY="AIzaSyYourApiKeyHere" # --- Postgres (Pflicht für die Website) ---
FIREBASE_AUTH_DOMAIN="flixcooks-your-project-id.firebaseapp.com" # Lokal: docker compose -f docker-compose.dev.yml up -d
FIREBASE_PROJECT_ID="flixcooks-your-project-id" DATABASE_URL="postgresql://flixcooks:flixcooks_dev@127.0.0.1:5432/flixcooks_dev"
FIREBASE_STORAGE_BUCKET="flixcooks-your-project-id.appspot.com"
FIREBASE_MESSAGING_SENDER_ID="123456789012" # --- Production / Coolify (im Dashboard setzen, nicht committen) ---
FIREBASE_APP_ID="1:123456789012:web:abcdef123456" # 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
+2 -2
View File
@@ -1,2 +1,2 @@
# Auto detect text files and perform LF normalization * text=auto eol=lf
* text=auto *.sh text eol=lf
-4
View File
@@ -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"
+37
View File
@@ -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, 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' \
'<Directory /var/www/html>' \
' AllowOverride None' \
' Require all granted' \
'</Directory>' \
> /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"]
+102 -19
View File
@@ -13,13 +13,14 @@ 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**. - `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**. - `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. - `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**: - **Support & Layouts**:
- `partials/`: Contains modular templates (`head.php`, `header.php`, `footer.php`) to maintain a clean DRY structure. - `partials/`: Contains modular templates (`head.php`, `header.php`, `footer.php`) to maintain a clean DRY structure.
- `helpers.php`: Core PHP utilities containing data formatting helpers and the data access layer for the flat-file database. - `helpers.php`: Core PHP utilities and the PostgreSQL data access layer for recipes.
- `config.php`: Environment-independent configuration loader which reads runtime secrets from `.env`. - `config.php`: Environment-independent configuration loader which reads runtime secrets from `.env`.
- `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. - `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,13 +37,9 @@ FlixCooks uses a modern, carefully curated vanilla tech-stack focused on lightni
### ⚙️ Backend & Data ### ⚙️ Backend & Data
- **Engine**: Vanilla PHP. - **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 only. `DATABASE_URL` is required; without a working DB connection the site returns HTTP 503.
- **Environment**: Custom `.env` variable parser integrated into PHP bootstrap. - **Environment**: Custom `.env` variable parser integrated into PHP bootstrap.
### 🔒 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 ## 🚀 Local Development Setup
@@ -51,7 +48,8 @@ Follow these simple steps to spin up the local development environment.
### Prerequisites ### Prerequisites
Make sure you have the following installed on your local machine: 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 - A modern web browser
### 1. Set Up Environment Variables ### 1. Set Up Environment Variables
@@ -59,14 +57,63 @@ Make sure you have the following installed on your local machine:
```bash ```bash
cp .env.example .env cp .env.example .env
``` ```
2. Open `.env` and fill in your actual **Firebase project settings** (API keys, project identifier, authentication domain, etc.): 2. Set **`DATABASE_URL`** in `.env` (required). See [Local Postgres (Docker)](#local-postgres-docker) below.
```env 3. Seed recipes once: `php scripts/db-seed.php` (imports `data/recipes.json` into SQL tables).
FIREBASE_API_KEY="AIzaSyYourApiKeyHere"
FIREBASE_AUTH_DOMAIN="flixcooks-your-project-id.firebaseapp.com"
...
```
### 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:**
```bash
docker compose -f docker-compose.dev.yml down -v
docker compose -f docker-compose.dev.yml up -d
php scripts/db-seed.php
```
**Verbindung prüfen:** `php scripts/db-check.php`
Details zum Schema: Abschnitt unten und `scripts/schema.sql`.
### 3. Start the Development Server
#### Option A: PHP Built-in Web Server (Recommended & Easiest) #### 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: 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,14 +125,50 @@ Then, open your browser and navigate to:
http://localhost:8000 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: 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`). 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 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`). 3. Access the site via your custom local virtual host (e.g., `http://localhost/flixcooks-website`).
--- ---
## 🗄️ Postgres in diesem Projekt (Kurzüberblick)
Rezepte liegen in **normalisierten SQL-Tabellen** (kein JSONB-Blob, kein Laufzeit-Fallback auf Dateien):
| 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 |
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.
**Einmalig Daten laden:** `php scripts/db-seed.php` (aus `data/recipes.json`).
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 ## 📈 Development Tracking & Progress
All current development tasks, features, and roadmaps are actively tracked and updated in the projects [.agents/TODO.md](file:///.agents/TODO.md) file. All current development tasks, features, and roadmaps are actively tracked and updated in the projects [.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). Architectural learnings, conventions, and configuration updates are maintained in the central knowledge base: [.agents/brain.md](file:///.agents/brain.md).
+41 -19
View File
@@ -2,9 +2,36 @@
session_start(); session_start();
require __DIR__ . '/helpers.php'; 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; $authed = isset($_SESSION['fc_admin']) && $_SESSION['fc_admin'] === true;
if ($ADMIN_KEY === '') {
http_response_code(503);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlixCooks Admin Unavailable</title>
<link rel="stylesheet" href="/assets/style.css">
<style>
body { display: grid; place-items: center; min-height: 100vh; }
.login-card { background: #fff; padding: 24px; border-radius: 16px; box-shadow: var(--shadow); width: min(420px, 90vw); }
.error { color: #b00020; margin: 0; }
</style>
</head>
<body>
<div class="login-card">
<h1>Admin unavailable</h1>
<p class="error">FLIXCOOKS_ADMIN_KEY is not configured.</p>
</div>
</body>
</html>
<?php
exit;
}
if (!$authed && isset($_POST['password'])) { if (!$authed && isset($_POST['password'])) {
if (hash_equals($ADMIN_KEY, $_POST['password'])) { if (hash_equals($ADMIN_KEY, $_POST['password'])) {
$_SESSION['fc_admin'] = true; $_SESSION['fc_admin'] = true;
@@ -49,7 +76,11 @@ if (!$authed) {
exit; exit;
} }
$allRecipes = load_recipes(); try {
$allRecipes = load_recipes();
} catch (DatabaseUnavailableException $e) {
handle_database_unavailable($e);
}
$siteSettings = load_site_settings(); $siteSettings = load_site_settings();
$message = null; $message = null;
$errors = []; $errors = [];
@@ -165,12 +196,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete') { if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete') {
$slugDel = trim($_POST['slug'] ?? ''); $slugDel = trim($_POST['slug'] ?? '');
$before = count($allRecipes); if ($slugDel !== '' && delete_recipe($slugDel)) {
$allRecipes = array_values(array_filter($allRecipes, fn($r) => ($r['slug'] ?? '') !== $slugDel));
if ($before !== count($allRecipes) && save_recipes($allRecipes)) {
$message = 'Recipe deleted.'; $message = 'Recipe deleted.';
$allRecipes = load_recipes();
} else { } else {
$message = 'Could not delete. Check permissions.'; $message = 'Could not delete recipe.';
} }
$editing = null; $editing = null;
$editIndex = null; $editIndex = null;
@@ -259,13 +289,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
} }
} }
if ($featured) {
foreach ($allRecipes as &$r) {
$r['featured'] = false;
}
unset($r);
}
$record = [ $record = [
'slug' => $slug, 'slug' => $slug,
'hero' => $hero, 'hero' => $hero,
@@ -309,16 +332,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
], ],
]; ];
if ($slugOriginal && $editIndex !== null) { if ($slugOriginal && $slugOriginal !== $slug) {
$allRecipes[$editIndex] = $record; delete_recipe($slugOriginal);
} else {
$allRecipes[] = $record;
} }
if (save_recipes($allRecipes)) { if (save_recipe($record)) {
$message = $slugOriginal ? 'Saved changes.' : 'Saved! New recipe added.'; $message = $slugOriginal ? 'Saved changes.' : 'Saved! New recipe added.';
$allRecipes = load_recipes();
} else { } else {
$message = 'Could not save the file. Check permissions on data/recipes.json.'; $message = 'Could not save to database.';
} }
} }
} }
-40
View File
@@ -1,40 +0,0 @@
<?php
// PHP session synchronizer endpoint for Firebase Auth
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$action = $_POST['action'] ?? '';
if ($action === 'login') {
$uid = $_POST['uid'] ?? '';
$email = $_POST['email'] ?? '';
$token = $_POST['token'] ?? '';
if ($uid !== '') {
$_SESSION['fc_user'] = [
'uid' => $uid,
'email' => $email,
'token' => $token
];
echo json_encode(['status' => 'success', 'message' => 'Logged in']);
} else {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Missing UID']);
}
} elseif ($action === 'logout') {
$_SESSION = [];
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$params["path"], $params["domain"],
$params["secure"], $params["httponly"]
);
}
session_destroy();
echo json_encode(['status' => 'success', 'message' => 'Logged out']);
} else {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid action']);
}
+62
View File
@@ -0,0 +1,62 @@
// Local-only favorites and dietary goal (no cloud backend).
(function (global) {
var FAV_KEY = 'fc_fav_slugs';
var GOAL_KEY = 'fc_diet_goal';
function readJson(key, fallback) {
try {
var raw = global.localStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw);
} catch (e) {
return fallback;
}
}
function writeJson(key, value) {
try {
global.localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
/* ignore quota errors */
}
}
global.fcLocal = {
getFavorites: function () {
var list = readJson(FAV_KEY, []);
return Array.isArray(list) ? list : [];
},
hasFavorite: function (slug) {
return this.getFavorites().indexOf(slug) !== -1;
},
toggleFavorite: function (slug) {
var list = this.getFavorites();
var idx = list.indexOf(slug);
if (idx === -1) {
list.push(slug);
} else {
list.splice(idx, 1);
}
writeJson(FAV_KEY, list);
return list;
},
getGoal: function () {
try {
return global.localStorage.getItem(GOAL_KEY) || '';
} catch (e) {
return '';
}
},
setGoal: function (goal) {
try {
if (goal) {
global.localStorage.setItem(GOAL_KEY, goal);
} else {
global.localStorage.removeItem(GOAL_KEY);
}
} catch (e) {
/* ignore */
}
}
};
})(window);
+47 -9
View File
@@ -34,13 +34,51 @@ function load_env() {
// Automatically load on include // Automatically load on include
load_env(); load_env();
function get_firebase_config(): array { class DatabaseUnavailableException extends RuntimeException
return [ {
'apiKey' => getenv('FIREBASE_API_KEY') ?: '', }
'authDomain' => getenv('FIREBASE_AUTH_DOMAIN') ?: '',
'projectId' => getenv('FIREBASE_PROJECT_ID') ?: '', /**
'storageBucket' => getenv('FIREBASE_STORAGE_BUCKET') ?: '', * Get a PDO connection to the database.
'messagingSenderId' => getenv('FIREBASE_MESSAGING_SENDER_ID') ?: '', */
'appId' => getenv('FIREBASE_APP_ID') ?: '', 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;
}
$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,
PDO::ATTR_TIMEOUT => 3,
]);
return $pdo;
} catch (\Exception $e) {
error_log("Database connection failed: " . $e->getMessage());
return null;
}
} }
+26
View File
@@ -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:
+42
View File
@@ -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:
+28
View File
@@ -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 "$@"
+149
View File
@@ -0,0 +1,149 @@
# 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 setzen. Ohne diese Variable ist `/admin.php` deaktiviert.
- 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 |
+25
View File
@@ -0,0 +1,25 @@
<?php
/**
* Container health check (Coolify / Docker HEALTHCHECK).
*/
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/config.php';
try {
if (!extension_loaded('pdo_pgsql')) {
throw new RuntimeException('pdo_pgsql extension missing');
}
require_once __DIR__ . '/helpers.php';
require_database();
$response = ['status' => '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);
}
+362 -12
View File
@@ -7,31 +7,381 @@ if (session_status() === PHP_SESSION_NONE) {
require_once __DIR__ . '/config.php'; require_once __DIR__ . '/config.php';
function load_recipes(): array { function handle_database_unavailable(DatabaseUnavailableException $e): void {
$path = __DIR__ . '/data/recipes.json'; 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)) { if (!file_exists($path)) {
return []; throw new RuntimeException('Missing scripts/schema.sql');
} }
$json = file_get_contents($path); $pdo->exec(file_get_contents($path));
$data = json_decode($json, true); }
if (!is_array($data)) {
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;
}
}
$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;
}
function require_database(): PDO {
$pdo = get_db_connection();
if (!$pdo) {
$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 []; return [];
} }
foreach ($data as &$recipe) { $stmt = $pdo->query('SELECT * FROM recipe_translations');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$slug = $row['recipe_slug'];
$lang = $row['lang'];
if (!isset($recipes[$slug])) {
continue;
}
$recipes[$slug]['i18n'][$lang] = array_merge(recipe_empty_i18n(), [
'title' => $row['title'],
'description' => $row['description'],
'category' => $row['category'],
'difficulty' => $row['difficulty'],
]);
}
$stmt = $pdo->query('SELECT recipe_slug, lang, tag FROM recipe_tags ORDER BY sort_order, id');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
$recipes[$row['recipe_slug']]['i18n'][$row['lang']]['tags'][] = $row['tag'];
}
}
$stmt = $pdo->query('SELECT recipe_slug, lang, content FROM recipe_ingredients ORDER BY sort_order, id');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
$recipes[$row['recipe_slug']]['i18n'][$row['lang']]['ingredients'][] = $row['content'];
}
}
$stmt = $pdo->query('SELECT recipe_slug, lang, content FROM recipe_utensils ORDER BY sort_order, id');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
$recipes[$row['recipe_slug']]['i18n'][$row['lang']]['utensils'][] = $row['content'];
}
}
$stmt = $pdo->query(
'SELECT recipe_slug, lang, content, video_url, timer_minutes
FROM recipe_steps ORDER BY sort_order, id'
);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (!isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
continue;
}
$lang = &$recipes[$row['recipe_slug']]['i18n'][$row['lang']];
$lang['steps'][] = $row['content'];
$lang['step_videos'][] = $row['video_url'] ?? '';
$lang['step_timers'][] = $row['timer_minutes'] !== null ? (string) $row['timer_minutes'] : '';
unset($lang);
}
$list = array_values($recipes);
foreach ($list as &$recipe) {
if (!empty($recipe['hero']) && is_string($recipe['hero'])) { if (!empty($recipe['hero']) && is_string($recipe['hero'])) {
$recipe['hero'] = normalize_asset_path($recipe['hero']); $recipe['hero'] = normalize_asset_path($recipe['hero']);
} }
} }
unset($recipe); unset($recipe);
return $data; return $list;
} }
function save_recipes(array $recipes): bool { function load_recipes(): array {
$path = __DIR__ . '/data/recipes.json'; $pdo = require_database();
$json = json_encode($recipes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); return hydrate_recipes_from_db($pdo);
return (bool) file_put_contents($path, $json); }
function load_recipe_by_slug(string $slug): ?array {
foreach (load_recipes() as $recipe) {
if (($recipe['slug'] ?? '') === $slug) {
return $recipe;
}
}
return null;
}
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();
if (!empty($recipe['featured'])) {
clear_featured_recipes($pdo);
}
$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'] ?? '',
]);
$tagOrder = 0;
foreach (array_values($block['tags'] ?? []) as $tag) {
$tag = trim((string) $tag);
if ($tag !== '') {
$tagStmt->execute([$slug, $lang, $tag, $tagOrder++]);
}
}
$ingredientOrder = 0;
foreach (array_values($block['ingredients'] ?? []) as $line) {
$line = trim((string) $line);
if ($line !== '') {
$ingredientStmt->execute([$slug, $lang, $line, $ingredientOrder++]);
}
}
$utensilOrder = 0;
foreach (array_values($block['utensils'] ?? []) as $line) {
$line = trim((string) $line);
if ($line !== '') {
$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 === '') {
continue;
}
$video = trim((string) ($videos[$i] ?? ''));
$timerRaw = $timers[$i] ?? '';
$timerMinutes = ($timerRaw !== '' && is_numeric($timerRaw)) ? (int) $timerRaw : null;
$stepStmt->execute([$slug, $lang, $step, $video, $timerMinutes, $stepOrder++]);
}
}
$pdo->commit();
return true;
} catch (\Exception $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log('save_recipe failed: ' . $e->getMessage());
return false;
}
} }
/** /**
+37 -37
View File
@@ -1,6 +1,12 @@
<?php <?php
require __DIR__ . '/helpers.php'; require __DIR__ . '/helpers.php';
try {
$allRecipes = load_recipes();
} catch (DatabaseUnavailableException $e) {
handle_database_unavailable($e);
}
// Language & inputs // Language & inputs
$lang = strtolower((string) (filter_input(INPUT_GET, 'lang', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?: 'en')) === 'de' ? 'de' : 'en'; $lang = strtolower((string) (filter_input(INPUT_GET, 'lang', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?: 'en')) === 'de' ? 'de' : 'en';
$q = trim((string) (filter_input(INPUT_GET, 'q', FILTER_UNSAFE_RAW) ?? '')); $q = trim((string) (filter_input(INPUT_GET, 'q', FILTER_UNSAFE_RAW) ?? ''));
@@ -84,7 +90,6 @@ $copy = [
$t = $copy[$lang]; $t = $copy[$lang];
// Data // Data
$allRecipes = load_recipes();
$localizedAll = localize_recipes($allRecipes, $lang); $localizedAll = localize_recipes($allRecipes, $lang);
$recipes = filter_recipes($allRecipes, $q ?: null, $tag ?: null, $lang); $recipes = filter_recipes($allRecipes, $q ?: null, $tag ?: null, $lang);
$recipes = array_values(array_filter($recipes, fn($r) => empty($r['coming_soon']))); $recipes = array_values(array_filter($recipes, fn($r) => empty($r['coming_soon'])));
@@ -920,43 +925,38 @@ $recipesJson = json_encode(array_values(array_map(function($r) use ($lang) {
}); });
} }
// Hook into auth state function filterRecipesByGoal(goal) {
window.addEventListener('DOMContentLoaded', function() { var filtered = indexRecipeBank;
if (window.auth && window.db) { if (goal === 'weight_loss') {
window.auth.onAuthStateChanged(function(user) { filtered = indexRecipeBank.filter(function(r) {
if (user) { return r.tags.some(function(t) {
window.db.collection('users').doc(user.uid).get() var lower = t.toLowerCase();
.then(function(doc) { return lower.includes('low-carb') || lower.includes('diet');
if (doc.exists) { }) || (r.category && r.category.toLowerCase().includes('salad'));
var goal = doc.data().goal;
var filtered = indexRecipeBank;
if (goal === 'weight_loss') {
filtered = indexRecipeBank.filter(r =>
r.tags.some(t => t.toLowerCase().includes('low-carb') || t.toLowerCase().includes('diet')) ||
(r.category && r.category.toLowerCase().includes('salad'))
);
} else if (goal === 'muscle_gain') {
filtered = indexRecipeBank.filter(r =>
r.tags.some(t => t.toLowerCase().includes('high-protein') || t.toLowerCase().includes('meat') || t.toLowerCase().includes('fleisch'))
);
} else if (goal === 'healthy') {
filtered = indexRecipeBank.filter(r =>
r.tags.some(t => t.toLowerCase().includes('vegetarian') || t.toLowerCase().includes('healthy'))
);
}
// fallback if empty
if (filtered.length === 0) filtered = indexRecipeBank;
renderSwipeCarousel(filtered, i18nGoal[goal] || null);
}
});
} else {
// Default unauthenticated view
renderSwipeCarousel(indexRecipeBank, null);
}
}); });
} else if (goal === 'muscle_gain') {
filtered = indexRecipeBank.filter(function(r) {
return r.tags.some(function(t) {
var lower = t.toLowerCase();
return lower.includes('high-protein') || lower.includes('meat') || lower.includes('fleisch');
});
});
} else if (goal === 'healthy') {
filtered = indexRecipeBank.filter(function(r) {
return r.tags.some(function(t) {
var lower = t.toLowerCase();
return lower.includes('vegetarian') || lower.includes('healthy');
});
});
}
if (filtered.length === 0) filtered = indexRecipeBank;
return filtered;
}
window.addEventListener('DOMContentLoaded', function() {
var goal = window.fcLocal ? window.fcLocal.getGoal() : '';
if (goal) {
renderSwipeCarousel(filterRecipesByGoal(goal), i18nGoal[goal] || null);
} else { } else {
renderSwipeCarousel(indexRecipeBank, null); renderSwipeCarousel(indexRecipeBank, null);
} }
+88 -258
View File
@@ -1,6 +1,12 @@
<?php <?php
require __DIR__ . '/helpers.php'; require __DIR__ . '/helpers.php';
try {
$allRecipes = load_recipes();
} catch (DatabaseUnavailableException $e) {
handle_database_unavailable($e);
}
$lang = (isset($_GET['lang']) && strtolower($_GET['lang']) === 'de') ? 'de' : 'en'; $lang = (isset($_GET['lang']) && strtolower($_GET['lang']) === 'de') ? 'de' : 'en';
$copy = [ $copy = [
@@ -23,9 +29,9 @@ $copy = [
'your_goal' => 'Your dietary goal', 'your_goal' => 'Your dietary goal',
'saved_recipes' => 'Your Saved Favorites', 'saved_recipes' => 'Your Saved Favorites',
'no_favorites' => "You haven't saved any recipes yet. Explore our collection and tap the heart icon!", 'no_favorites' => "You haven't saved any recipes yet. Explore our collection and tap the heart icon!",
'logout' => 'Log Out', 'save_goal' => 'Save goal',
'err_pass_match' => 'Passwords do not match.', 'goal_saved' => 'Dietary goal saved.',
'err_fill_fields'=> 'Please fill in all fields.', 'local_note' => 'Favorites and goals are stored in this browser only.',
], ],
'de' => [ 'de' => [
'page_title' => 'Mein Profil & Favoriten | FlixCooks', 'page_title' => 'Mein Profil & Favoriten | FlixCooks',
@@ -46,9 +52,9 @@ $copy = [
'your_goal' => 'Dein Ernährungsziel', 'your_goal' => 'Dein Ernährungsziel',
'saved_recipes' => 'Deine gespeicherten Favoriten', 'saved_recipes' => 'Deine gespeicherten Favoriten',
'no_favorites' => "Du hast noch keine Rezepte gespeichert. Entdecke unsere Küche und klicke auf das Herz-Symbol!", 'no_favorites' => "Du hast noch keine Rezepte gespeichert. Entdecke unsere Küche und klicke auf das Herz-Symbol!",
'logout' => 'Abmelden', 'save_goal' => 'Ziel speichern',
'err_pass_match' => 'Die Passwörter stimmen nicht überein.', 'goal_saved' => 'Ernährungsziel gespeichert.',
'err_fill_fields'=> 'Bitte fülle alle Felder aus.', 'local_note' => 'Favoriten und Ziele werden nur in diesem Browser gespeichert.',
] ]
]; ];
@@ -56,10 +62,6 @@ $t = $copy[$lang];
$pageTitle = $t['page_title']; $pageTitle = $t['page_title'];
$description = $t['meta_desc']; $description = $t['meta_desc'];
// Check PHP session state
$user = $_SESSION['fc_user'] ?? null;
$allRecipes = load_recipes();
$allRecipesLocal = localize_recipes($allRecipes, $lang); $allRecipesLocal = localize_recipes($allRecipes, $lang);
// Formatter helper for JS usage // Formatter helper for JS usage
@@ -376,95 +378,37 @@ include __DIR__ . '/partials/header.php';
<main class="auth-page"> <main class="auth-page">
<div class="auth-alert" id="authAlert"></div> <div class="auth-alert" id="authAlert"></div>
<!-- ── 1. UNAUTHENTICATED STATE (Login/Register Card) ── --> <div class="profile-shell" id="profileShell" style="display: grid;">
<div class="auth-card" id="authCard" style="display: <?php echo $user ? 'none' : 'block'; ?>;">
<div class="auth-tabs">
<button class="auth-tab active" onclick="switchTab('login')"><?php echo $t['login']; ?></button>
<button class="auth-tab" onclick="switchTab('signup')"><?php echo $t['signup']; ?></button>
</div>
<!-- Login Form -->
<form class="auth-form active" id="loginForm" onsubmit="handleLogin(event)">
<div class="auth-field">
<label for="loginEmail"><?php echo $t['email']; ?></label>
<input type="email" id="loginEmail" placeholder="you@example.com" required autocomplete="username">
</div>
<div class="auth-field">
<label for="loginPassword"><?php echo $t['password']; ?></label>
<input type="password" id="loginPassword" placeholder="••••••••" required autocomplete="current-password">
</div>
<div class="auth-btn-row">
<button class="btn-reveal" type="submit">
<span class="btn-reveal-text"><?php echo $t['login']; ?></span>
<span class="spinner"></span>
<span class="btn-reveal-arrow">→</span>
</button>
</div>
<p class="auth-switch-text">
<?php echo $t['no_account']; ?> <button type="button" onclick="switchTab('signup')"><?php echo $t['signup']; ?></button>
</p>
</form>
<!-- Sign-up Form -->
<form class="auth-form" id="signupForm" onsubmit="handleSignup(event)">
<div class="auth-field">
<label for="signupEmail"><?php echo $t['email']; ?></label>
<input type="email" id="signupEmail" placeholder="you@example.com" required autocomplete="username">
</div>
<div class="auth-field">
<label for="signupPassword"><?php echo $t['password']; ?></label>
<input type="password" id="signupPassword" placeholder="••••••••" required autocomplete="new-password">
</div>
<div class="auth-field">
<label for="signupConfirm"><?php echo $t['confirm_pass']; ?></label>
<input type="password" id="signupConfirm" placeholder="••••••••" required autocomplete="new-password">
</div>
<div class="auth-field">
<label for="signupGoal"><?php echo $t['goal']; ?></label>
<select id="signupGoal" required>
<option value="" disabled selected><?php echo $t['goal_placeholder']; ?></option>
<option value="weight_loss"><?php echo $t['goal_loose']; ?></option>
<option value="muscle_gain"><?php echo $t['goal_gain']; ?></option>
<option value="healthy"><?php echo $t['goal_healthy']; ?></option>
</select>
</div>
<div class="auth-btn-row">
<button class="btn-reveal" type="submit">
<span class="btn-reveal-text"><?php echo $t['signup']; ?></span>
<span class="spinner"></span>
<span class="btn-reveal-arrow">→</span>
</button>
</div>
<p class="auth-switch-text">
<?php echo $t['have_account']; ?> <button type="button" onclick="switchTab('login')"><?php echo $t['login']; ?></button>
</p>
</form>
</div>
<!-- ── 2. AUTHENTICATED STATE (Dashboard & Favorites) ── -->
<div class="profile-shell" id="profileShell" style="display: <?php echo $user ? 'grid' : 'none'; ?>;">
<aside class="profile-sidebar"> <aside class="profile-sidebar">
<div style="display: flex; align-items: center; gap: 1.5rem;"> <div class="profile-meta">
<div class="profile-avatar" id="avatarChar"> <p><?php echo $t['welcome']; ?></p>
<?php echo $user ? strtoupper(substr($user['email'], 0, 1)) : ''; ?> <p style="font-size: 0.9rem; color: var(--muted); margin-top: 0.5rem;"><?php echo e($t['local_note']); ?></p>
</div>
<div class="profile-meta">
<p><?php echo $t['welcome']; ?></p>
<h2 id="profileEmailVal"><?php echo $user ? e($user['email']) : ''; ?></h2>
</div>
</div> </div>
<div class="profile-details"> <form class="auth-form active" id="goalForm" onsubmit="handleSaveGoal(event)" style="margin-top: 1.5rem;">
<div class="auth-field">
<label for="profileGoal"><?php echo $t['goal']; ?></label>
<select id="profileGoal" required>
<option value="" disabled><?php echo $t['goal_placeholder']; ?></option>
<option value="weight_loss"><?php echo $t['goal_loose']; ?></option>
<option value="muscle_gain"><?php echo $t['goal_gain']; ?></option>
<option value="healthy"><?php echo $t['goal_healthy']; ?></option>
</select>
</div>
<div class="auth-btn-row">
<button class="btn-reveal" type="submit">
<span class="btn-reveal-text"><?php echo $t['save_goal']; ?></span>
<span class="btn-reveal-arrow">→</span>
</button>
</div>
</form>
<div class="profile-details" style="margin-top: 1.5rem;">
<div class="profile-stat-box"> <div class="profile-stat-box">
<span class="profile-stat-label"><?php echo $t['your_goal']; ?></span> <span class="profile-stat-label"><?php echo $t['your_goal']; ?></span>
<span class="profile-stat-val" id="profileGoalVal">-</span> <span class="profile-stat-val" id="profileGoalVal">-</span>
</div> </div>
</div> </div>
<button class="btn-reveal" onclick="handleLogout()" style="margin-top: 1rem; border-color: rgba(0,0,0,0.15);">
<span class="btn-reveal-text"><?php echo $t['logout']; ?></span>
<span class="btn-reveal-arrow">→</span>
</button>
</aside> </aside>
<section class="profile-main"> <section class="profile-main">
@@ -487,195 +431,81 @@ include __DIR__ . '/partials/header.php';
</main> </main>
<script> <script>
// Expose recipe bank to JavaScript
const recipeBank = <?php echo $recipesJson; ?>; const recipeBank = <?php echo $recipesJson; ?>;
const langText = <?php echo json_encode($t); ?>; const langText = <?php echo json_encode($t); ?>;
function switchTab(tab) { function goalLabel(goal) {
const tabs = document.querySelectorAll('.auth-tab'); if (goal === 'weight_loss') return langText.goal_loose;
const forms = document.querySelectorAll('.auth-form'); if (goal === 'muscle_gain') return langText.goal_gain;
const alertEl = document.getElementById('authAlert'); if (goal === 'healthy') return langText.goal_healthy;
alertEl.style.display = 'none'; return '-';
if (tab === 'login') {
tabs[0].classList.add('active');
tabs[1].classList.remove('active');
forms[0].classList.add('active');
forms[1].classList.remove('active');
} else {
tabs[0].classList.remove('active');
tabs[1].classList.add('active');
forms[0].classList.remove('active');
forms[1].classList.add('active');
}
} }
function showAlert(message) { function showAlert(message) {
const alertEl = document.getElementById('authAlert'); const alertEl = document.getElementById('authAlert');
alertEl.textContent = message; alertEl.textContent = message;
alertEl.style.display = 'block'; alertEl.style.display = 'block';
// Scroll to alert smoothly
window.scrollTo({ top: alertEl.offsetTop - 120, behavior: 'smooth' }); window.scrollTo({ top: alertEl.offsetTop - 120, behavior: 'smooth' });
} }
function handleLogin(e) { function handleSaveGoal(e) {
e.preventDefault(); e.preventDefault();
const email = document.getElementById('loginEmail').value; if (!window.fcLocal) return;
const pass = document.getElementById('loginPassword').value; const goal = document.getElementById('profileGoal').value;
const btn = e.target.querySelector('button[type="submit"]'); window.fcLocal.setGoal(goal);
const alertEl = document.getElementById('authAlert'); document.getElementById('profileGoalVal').textContent = goalLabel(goal);
showAlert(langText.goal_saved);
alertEl.style.display = 'none';
btn.disabled = true;
window.auth.signInWithEmailAndPassword(email, pass)
.then(userCredential => {
// Sync happens automatically via onAuthStateChanged observer in head.php!
// The observer will reload the page when it receives the new state
})
.catch(error => {
btn.disabled = false;
let errMsg = error.message;
if (error.code === 'auth/wrong-password' || error.code === 'auth/user-not-found') {
errMsg = "Invalid email or password.";
}
showAlert(errMsg);
});
} }
function handleSignup(e) { function renderFavorites() {
e.preventDefault(); const grid = document.getElementById('favoritesGrid');
const email = document.getElementById('signupEmail').value; const emptyState = document.getElementById('favoritesEmpty');
const pass = document.getElementById('signupPassword').value; if (!window.fcLocal) return;
const confirm = document.getElementById('signupConfirm').value;
const goal = document.getElementById('signupGoal').value;
const btn = e.target.querySelector('button[type="submit"]');
const alertEl = document.getElementById('authAlert');
alertEl.style.display = 'none'; const slugs = window.fcLocal.getFavorites();
grid.innerHTML = '';
if (pass !== confirm) { if (slugs.length === 0) {
showAlert(langText.err_pass_match); grid.style.display = 'none';
emptyState.style.display = 'block';
return; return;
} }
btn.disabled = true; emptyState.style.display = 'none';
grid.style.display = 'grid';
window.auth.createUserWithEmailAndPassword(email, pass) slugs.forEach(function(slug) {
.then(userCredential => { const recipe = recipeBank.find(function(r) { return r.slug === slug; });
const user = userCredential.user; if (!recipe) return;
// Save profile details to Firestore const card = document.createElement('article');
return window.db.collection('users').doc(user.uid).set({ card.className = 'card card--bare reveal-target revealed';
email: email, card.innerHTML =
goal: goal, '<a href="' + recipe.url + '" class="card__image">' +
createdAt: firebase.firestore.FieldValue.serverTimestamp() '<img src="' + recipe.hero + '" alt="' + recipe.title + '" loading="lazy">' +
}); '</a>' +
}) '<div class="card__body">' +
.then(() => { '<h4><a href="' + recipe.url + '">' + recipe.title + '</a></h4>' +
// Sync is handled by observer in head.php! '<div class="meta">' +
}) '<span>⏱ ' + recipe.total_time + ' min</span>' +
.catch(error => { '<span>🙂 ' + recipe.difficulty + '</span>' +
btn.disabled = false; '</div>' +
showAlert(error.message); '</div>';
}); grid.appendChild(card);
}
function handleLogout() {
window.auth.signOut()
.then(() => {
// Sync observer takes care of PHP session termination and reloads page
});
}
// ── Profile and Favorites dashboard renderer ──
function initDashboard(user) {
if (!user) return;
const goalVal = document.getElementById('profileGoalVal');
const grid = document.getElementById('favoritesGrid');
const emptyState = document.getElementById('favoritesEmpty');
// Fetch user profile from Firestore
window.db.collection('users').doc(user.uid).get()
.then(doc => {
if (doc.exists) {
const data = doc.data();
let goalString = '-';
if (data.goal === 'weight_loss') goalString = langText.goal_loose;
else if (data.goal === 'muscle_gain') goalString = langText.goal_gain;
else if (data.goal === 'healthy') goalString = langText.goal_healthy;
goalVal.textContent = goalString;
}
})
.catch(err => console.error("Error loading profile: ", err));
// Load Saved Favorites
window.db.collection('user_favorites').where('userId', '==', user.uid).get()
.then(snapshot => {
grid.innerHTML = '';
if (snapshot.empty) {
grid.style.display = 'none';
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
grid.style.display = 'grid';
snapshot.forEach(doc => {
const fav = doc.data();
const recipe = recipeBank.find(r => r.slug === fav.recipeSlug);
if (recipe) {
const card = document.createElement('article');
card.className = 'card card--bare reveal-target revealed';
card.innerHTML = `
<a href="${recipe.url}" class="card__image">
<img src="${recipe.hero}" alt="${recipe.title}" loading="lazy">
</a>
<div class="card__body">
<h4><a href="${recipe.url}">${recipe.title}</a></h4>
<div class="meta">
<span>⏱ ${recipe.total_time} min</span>
<span>🙂 ${recipe.difficulty}</span>
</div>
</div>
`;
grid.appendChild(card);
}
});
// If none of the favorite slugs matched recipes in current language:
if (grid.children.length === 0) {
grid.style.display = 'none';
emptyState.style.display = 'block';
}
})
.catch(err => {
console.error("Error fetching favorites: ", err);
grid.innerHTML = '<p>Error loading favorites. Please try again.</p>';
});
}
// Observe auth state for dashboard renderer
window.addEventListener('DOMContentLoaded', () => {
window.auth.onAuthStateChanged(user => {
const authCard = document.getElementById('authCard');
const profileShell = document.getElementById('profileShell');
if (user) {
document.getElementById('profileEmailVal').textContent = user.email;
document.getElementById('avatarChar').textContent = user.email.substr(0,1).toUpperCase();
authCard.style.display = 'none';
profileShell.style.display = 'grid';
initDashboard(user);
} else {
authCard.style.display = 'block';
profileShell.style.display = 'none';
}
}); });
if (grid.children.length === 0) {
grid.style.display = 'none';
emptyState.style.display = 'block';
}
}
window.addEventListener('DOMContentLoaded', function() {
if (!window.fcLocal) return;
const goal = window.fcLocal.getGoal();
const select = document.getElementById('profileGoal');
if (goal && select) select.value = goal;
document.getElementById('profileGoalVal').textContent = goalLabel(goal);
renderFavorites();
}); });
</script> </script>
+52
View File
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlixCooks Datenbank nicht verfügbar</title>
<link rel="stylesheet" href="/assets/style.css">
<style>
body {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
text-align: center;
}
.db-error-card {
max-width: 32rem;
padding: 2rem;
border-radius: var(--radius, 16px);
border: 1px solid var(--stroke, rgba(255,255,255,0.12));
background: var(--surface-2, rgba(255,255,255,0.04));
}
.db-error-card h1 { margin-top: 0; }
.db-error-card code {
display: block;
margin: 1rem 0;
padding: 0.75rem;
font-size: 0.85rem;
text-align: left;
word-break: break-all;
background: rgba(0,0,0,0.2);
border-radius: 8px;
}
</style>
</head>
<body>
<div class="db-error-card">
<h1>Datenbank nicht verfügbar</h1>
<p>FlixCooks benötigt eine laufende PostgreSQL-Verbindung. Ohne Datenbank werden keine Rezepte angezeigt.</p>
<?php if (!empty($dbError)): ?>
<code><?php echo htmlspecialchars($dbError, ENT_QUOTES, 'UTF-8'); ?></code>
<?php endif; ?>
<p><strong>Lokal prüfen:</strong></p>
<ol style="text-align: left; margin: 0 auto; max-width: 22rem;">
<li><code>DATABASE_URL</code> in <code>.env</code> setzen</li>
<li><code>docker compose -f docker-compose.dev.yml up -d</code></li>
<li><code>php scripts/db-check.php</code></li>
<li>Leere DB: <code>php scripts/db-seed.php</code></li>
</ol>
</div>
</body>
</html>
+16 -92
View File
@@ -61,37 +61,23 @@
})(); })();
// ── Newsletter subscription logic ────────────────────────── // ── Newsletter (mailto) ────────────────────────────────────
function handleSubscribe(e) { function handleSubscribe(e) {
e.preventDefault(); e.preventDefault();
var emailInput = document.getElementById('newsletterEmail'); var emailInput = document.getElementById('newsletterEmail');
var feedback = document.getElementById('newsletterFeedback'); var feedback = document.getElementById('newsletterFeedback');
var form = document.getElementById('newsletterForm'); var form = document.getElementById('newsletterForm');
var btn = form.querySelector('button');
var email = emailInput.value.trim(); var email = emailInput.value.trim();
if (!email) return; if (!email) return;
btn.disabled = true; var subject = encodeURIComponent('FlixCooks Newsletter');
feedback.className = 'newsletter-feedback'; var body = encodeURIComponent('Please add me to the newsletter: ' + email);
feedback.textContent = ''; window.location.href = 'mailto:<?php echo e($contactEmail); ?>?subject=' + subject + '&body=' + body;
window.db.collection('subscribers').doc(email.toLowerCase()).set({ form.classList.add('success');
email: email.toLowerCase(), feedback.className = 'newsletter-feedback success';
subscribedAt: firebase.firestore.FieldValue.serverTimestamp(), feedback.textContent = '<?php echo $langCode === "de" ? "E-Mail-Programm geöffnet bitte absenden." : "Email client opened — please send."; ?>';
status: 'active'
})
.then(function() {
form.classList.add('success');
emailInput.disabled = true;
feedback.className = 'newsletter-feedback success';
feedback.textContent = '<?php echo $langCode === "de" ? "Erfolgreich abonniert!" : "Successfully subscribed!"; ?>';
})
.catch(function(error) {
btn.disabled = false;
feedback.className = 'newsletter-feedback error';
feedback.textContent = '<?php echo $langCode === "de" ? "Fehler: " : "Error: "; ?>' + error.message;
});
} }
// ── Guest Modal Logic ────────────────────────────────────── // ── Guest Modal Logic ──────────────────────────────────────
@@ -121,33 +107,12 @@ document.addEventListener('click', function(e) {
} }
}); });
// ── Favorites Toggle & Management ─────────────────────────── // ── Favorites (localStorage) ───────────────────────────────
var currentUser = null;
var userFavSlugs = new Set();
function syncFavorites(uid) {
window.db.collection('user_favorites').where('userId', '==', uid).get()
.then(function(snapshot) {
userFavSlugs.clear();
snapshot.forEach(function(doc) {
userFavSlugs.add(doc.data().recipeSlug);
});
updateHeartStates();
})
.catch(function(err) {
console.error("Error syncing favorites: ", err);
});
}
function clearFavorites() {
userFavSlugs.clear();
updateHeartStates();
}
function updateHeartStates() { function updateHeartStates() {
if (!window.fcLocal) return;
document.querySelectorAll('.btn-favorite').forEach(function(btn) { document.querySelectorAll('.btn-favorite').forEach(function(btn) {
var slug = btn.getAttribute('data-slug'); var slug = btn.getAttribute('data-slug');
if (userFavSlugs.has(slug)) { if (slug && window.fcLocal.hasFavorite(slug)) {
btn.classList.add('active'); btn.classList.add('active');
} else { } else {
btn.classList.remove('active'); btn.classList.remove('active');
@@ -156,43 +121,12 @@ function updateHeartStates() {
} }
function toggleFavorite(slug, btn) { function toggleFavorite(slug, btn) {
if (!currentUser) { if (!window.fcLocal) {
openGuestModal(); openGuestModal();
return; return;
} }
window.fcLocal.toggleFavorite(slug);
var docId = currentUser.uid + '_' + slug; updateHeartStates();
var docRef = window.db.collection('user_favorites').doc(docId);
if (userFavSlugs.has(slug)) {
// Optimistic UI update
userFavSlugs.delete(slug);
btn.classList.remove('active');
docRef.delete()
.catch(function(err) {
console.error("Error removing favorite: ", err);
// Rollback
userFavSlugs.add(slug);
btn.classList.add('active');
});
} else {
// Optimistic UI update
userFavSlugs.add(slug);
btn.classList.add('active');
docRef.set({
userId: currentUser.uid,
recipeSlug: slug,
createdAt: firebase.firestore.FieldValue.serverTimestamp()
})
.catch(function(err) {
console.error("Error adding favorite: ", err);
// Rollback
userFavSlugs.delete(slug);
btn.classList.remove('active');
});
}
} }
// Hook up event listeners to all favorite buttons dynamically // Hook up event listeners to all favorite buttons dynamically
@@ -206,18 +140,8 @@ document.addEventListener('click', function(e) {
} }
}); });
// Setup Auth state observer for favorites
window.addEventListener('DOMContentLoaded', function() { window.addEventListener('DOMContentLoaded', function() {
if (window.auth) { updateHeartStates();
window.auth.onAuthStateChanged(function(user) {
currentUser = user;
if (user) {
syncFavorites(user.uid);
} else {
clearFavorites();
}
});
}
}); });
})(); })();
</script> </script>
@@ -228,10 +152,10 @@ window.addEventListener('DOMContentLoaded', function() {
<button class="guest-modal-close" onclick="closeGuestModal()" aria-label="Close modal">×</button> <button class="guest-modal-close" onclick="closeGuestModal()" aria-label="Close modal">×</button>
<div class="guest-modal-icon">❤️</div> <div class="guest-modal-icon">❤️</div>
<h3><?php echo $langCode === 'de' ? 'Lieblingsrezept speichern' : 'Save your favorite recipe'; ?></h3> <h3><?php echo $langCode === 'de' ? 'Lieblingsrezept speichern' : 'Save your favorite recipe'; ?></h3>
<p><?php echo $langCode === 'de' ? 'Erstelle ein kostenloses Konto oder melde dich an, um Rezepte in deinen Favoriten zu speichern und deine Ernährungsziele zu verwalten.' : 'Create a free account or login to save recipes to your favorites and manage your dietary goals.'; ?></p> <p><?php echo $langCode === 'de' ? 'Speichere Rezepte lokal in deinem Browser und verwalte dein Ernährungsziel im Profil.' : 'Save recipes locally in your browser and manage your dietary goal in your profile.'; ?></p>
<div class="guest-modal-actions"> <div class="guest-modal-actions">
<a class="btn-reveal" href="/login.php<?php echo $langSuffix; ?>"> <a class="btn-reveal" href="/login.php<?php echo $langSuffix; ?>">
<span class="btn-reveal-text"><?php echo $langCode === 'de' ? 'Jetzt Anmelden' : 'Login / Register'; ?></span> <span class="btn-reveal-text"><?php echo $langCode === 'de' ? 'Zum Profil' : 'Go to profile'; ?></span>
<span class="btn-reveal-arrow">→</span> <span class="btn-reveal-arrow">→</span>
</a> </a>
<button class="underline-link" onclick="closeGuestModal()"><?php echo $langCode === 'de' ? 'Später' : 'Maybe later'; ?></button> <button class="underline-link" onclick="closeGuestModal()"><?php echo $langCode === 'de' ? 'Später' : 'Maybe later'; ?></button>
+1 -43
View File
@@ -19,49 +19,7 @@ $description = $description ?? 'Seasonal recipes, tested tips, and approachable
<!-- Main stylesheet --> <!-- Main stylesheet -->
<link rel="stylesheet" href="/assets/style.css"> <link rel="stylesheet" href="/assets/style.css">
<script src="/assets/fc-local.js" defer></script>
<!-- Firebase v10 compat SDKs -->
<script src="https://www.gstatic.com/firebasejs/10.8.0/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.8.0/firebase-auth-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.8.0/firebase-firestore-compat.js"></script>
<script>
(function() {
var config = <?php echo json_encode(get_firebase_config()); ?>;
firebase.initializeApp(config);
window.auth = firebase.auth();
window.db = firebase.firestore();
// Auto-sync PHP Session with Firebase Auth State
window.auth.onAuthStateChanged(function(user) {
var phpUser = <?php echo isset($_SESSION['fc_user']) ? json_encode($_SESSION['fc_user']) : 'null'; ?>;
if (user) {
if (!phpUser || phpUser.uid !== user.uid) {
user.getIdToken().then(function(token) {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/session.php', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.send('action=login&uid=' + encodeURIComponent(user.uid) + '&email=' + encodeURIComponent(user.email) + '&token=' + encodeURIComponent(token));
xhr.onload = function() {
if (xhr.status === 200) {
if (!phpUser) { window.location.reload(); }
}
};
});
}
} else {
if (phpUser) {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/session.php', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.send('action=logout');
xhr.onload = function() {
window.location.reload();
};
}
}
});
})();
</script>
<!-- GSAP + ScrollTrigger (CDN) --> <!-- GSAP + ScrollTrigger (CDN) -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
+2 -2
View File
@@ -58,8 +58,8 @@ $langParamAmp = isset($lang) && $lang === 'de' ? '&lang=de' : '';
<li> <li>
<a href="/login.php<?php echo $langParam; ?>" class="slide-hover-link" <?php echo $currentPage === 'login.php' ? 'aria-current="page"' : ''; ?>> <a href="/login.php<?php echo $langParam; ?>" class="slide-hover-link" <?php echo $currentPage === 'login.php' ? 'aria-current="page"' : ''; ?>>
<span class="slide-hover-wrapper"> <span class="slide-hover-wrapper">
<span class="slide-text-main"><?php echo isset($_SESSION['fc_user']) ? (isset($lang) && $lang === 'de' ? 'Profil' : 'Profile') : (isset($lang) && $lang === 'de' ? 'Anmelden' : 'Login'); ?></span> <span class="slide-text-main"><?php echo isset($lang) && $lang === 'de' ? 'Profil' : 'Profile'; ?></span>
<span class="slide-text-clone" aria-hidden="true"><?php echo isset($_SESSION['fc_user']) ? (isset($lang) && $lang === 'de' ? 'Profil' : 'Profile') : (isset($lang) && $lang === 'de' ? 'Anmelden' : 'Login'); ?></span> <span class="slide-text-clone" aria-hidden="true"><?php echo isset($lang) && $lang === 'de' ? 'Profil' : 'Profile'; ?></span>
</span> </span>
</a> </a>
</li> </li>
+7 -2
View File
@@ -1,6 +1,12 @@
<?php <?php
require __DIR__ . '/helpers.php'; require __DIR__ . '/helpers.php';
try {
$allRecipes = load_recipes();
} catch (DatabaseUnavailableException $e) {
handle_database_unavailable($e);
}
$lang = (isset($_GET['lang']) && strtolower($_GET['lang']) === 'de') ? 'de' : 'en'; $lang = (isset($_GET['lang']) && strtolower($_GET['lang']) === 'de') ? 'de' : 'en';
$copy = [ $copy = [
'en' => [ 'en' => [
@@ -46,9 +52,8 @@ $copy = [
]; ];
$t = $copy[$lang]; $t = $copy[$lang];
$allRecipes = load_recipes();
$slug = isset($_GET['slug']) ? trim($_GET['slug']) : ''; $slug = isset($_GET['slug']) ? trim($_GET['slug']) : '';
$recipeRaw = $slug ? find_recipe_by_slug($allRecipes, $slug) : null; $recipeRaw = $slug ? load_recipe_by_slug($slug) : null;
$recipe = $recipeRaw ? localize_recipe($recipeRaw, $lang) : null; $recipe = $recipeRaw ? localize_recipe($recipeRaw, $lang) : null;
$allRecipesLocal = localize_recipes($allRecipes, $lang); $allRecipesLocal = localize_recipes($allRecipes, $lang);
+45
View File
@@ -0,0 +1,45 @@
<?php
/**
* Kurzer Verbindungstest zur lokalen Postgres-Instanz.
* Aufruf (im Projektroot):
* php scripts/db-check.php
*/
require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/helpers.php';
if (!extension_loaded('pdo_pgsql')) {
fwrite(STDERR, "PHP-Extension pdo_pgsql fehlt. z. B.: sudo apt install php-pgsql\n");
exit(1);
}
$url = getenv('DATABASE_URL');
if (!$url) {
fwrite(STDERR, "DATABASE_URL ist nicht gesetzt. In .env eintragen (siehe .env.example).\n");
exit(1);
}
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);
}
$count = (int) $pdo->query('SELECT COUNT(*) FROM recipes')->fetchColumn();
echo "Verbindung OK. Rezepte in DB: {$count}\n";
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";
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
/**
* Einmaliger Import aus data/recipes.json in die relationale DB.
* Die Website liest zur Laufzeit nur noch aus Postgres.
*
* php scripts/db-seed.php
*/
require_once dirname(__DIR__) . '/helpers.php';
$seedPath = dirname(__DIR__) . '/data/recipes.json';
if (!file_exists($seedPath)) {
fwrite(STDERR, "Seed-Datei fehlt: data/recipes.json\n");
exit(1);
}
$raw = file_get_contents($seedPath);
$recipes = json_decode($raw, true);
if (!is_array($recipes)) {
fwrite(STDERR, "Ungültiges JSON in data/recipes.json\n");
exit(1);
}
try {
$pdo = require_database();
} catch (DatabaseUnavailableException $e) {
fwrite(STDERR, $e->getMessage() . "\n");
exit(1);
}
$count = 0;
foreach ($recipes as $recipe) {
if (!is_array($recipe) || empty($recipe['slug'])) {
continue;
}
if (save_recipe($recipe, $pdo)) {
$count++;
echo " + {$recipe['slug']}\n";
} else {
fwrite(STDERR, " ! Fehler bei {$recipe['slug']}\n");
}
}
echo "\nImport abgeschlossen: {$count} Rezepte.\n";
+70
View File
@@ -0,0 +1,70 @@
-- FlixCooks relational recipe schema (PostgreSQL)
CREATE TABLE IF NOT EXISTS recipes (
slug VARCHAR(255) PRIMARY KEY,
hero TEXT NOT NULL DEFAULT '',
prep_time INTEGER NOT NULL DEFAULT 0,
cook_time INTEGER NOT NULL DEFAULT 0,
total_time INTEGER NOT NULL DEFAULT 0,
servings INTEGER NOT NULL DEFAULT 2,
featured BOOLEAN NOT NULL DEFAULT FALSE,
coming_soon BOOLEAN NOT NULL DEFAULT FALSE,
calories INTEGER NOT NULL DEFAULT 0,
protein INTEGER NOT NULL DEFAULT 0,
carbs INTEGER NOT NULL DEFAULT 0,
fat INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS recipe_translations (
recipe_slug VARCHAR(255) NOT NULL REFERENCES recipes(slug) ON DELETE CASCADE,
lang CHAR(2) NOT NULL CHECK (lang IN ('en', 'de')),
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
difficulty TEXT NOT NULL DEFAULT '',
PRIMARY KEY (recipe_slug, lang)
);
CREATE TABLE IF NOT EXISTS recipe_tags (
id SERIAL PRIMARY KEY,
recipe_slug VARCHAR(255) NOT NULL REFERENCES recipes(slug) ON DELETE CASCADE,
lang CHAR(2) NOT NULL CHECK (lang IN ('en', 'de')),
tag TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_recipe_tags_slug_lang ON recipe_tags (recipe_slug, lang);
CREATE TABLE IF NOT EXISTS recipe_ingredients (
id SERIAL PRIMARY KEY,
recipe_slug VARCHAR(255) NOT NULL REFERENCES recipes(slug) ON DELETE CASCADE,
lang CHAR(2) NOT NULL CHECK (lang IN ('en', 'de')),
content TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_slug_lang ON recipe_ingredients (recipe_slug, lang);
CREATE TABLE IF NOT EXISTS recipe_utensils (
id SERIAL PRIMARY KEY,
recipe_slug VARCHAR(255) NOT NULL REFERENCES recipes(slug) ON DELETE CASCADE,
lang CHAR(2) NOT NULL CHECK (lang IN ('en', 'de')),
content TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_recipe_utensils_slug_lang ON recipe_utensils (recipe_slug, lang);
CREATE TABLE IF NOT EXISTS recipe_steps (
id SERIAL PRIMARY KEY,
recipe_slug VARCHAR(255) NOT NULL REFERENCES recipes(slug) ON DELETE CASCADE,
lang CHAR(2) NOT NULL CHECK (lang IN ('en', 'de')),
content TEXT NOT NULL DEFAULT '',
video_url TEXT NOT NULL DEFAULT '',
timer_minutes INTEGER,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_recipe_steps_slug_lang ON recipe_steps (recipe_slug, lang);