diff --git a/.agents/TODO.md b/.agents/TODO.md index 953b718..2e7c01c 100644 --- a/.agents/TODO.md +++ b/.agents/TODO.md @@ -12,10 +12,10 @@ Dieses Dokument enthält den aktuellen Entwicklungsstand und detaillierte Aufgab - Fullscreen Overlay-Navigation (`LiquidOverlayMenu` & `SlideUpTextHover`) - Geschmeidiges Scrollverhalten (`LenisSmoothScroll` & `IndexSectionIndicator`) - Layout-Raster (`FloemaLayoutGrid`) & Premium-Buttons -- [x] **Phase 2: Authentifizierung & Firebase Integration** - - Firebase-Projekt Setup & Anbindung - - Newsletter-System (Firebase Firestore) - - Benutzerprofile (Registrierung, Login, Favoriten) +- [x] **Phase 2: Profil & Personalisierung (lokal)** + - Ernährungsziel & Favoriten im Browser (`fc-local.js`) + - Newsletter per Mailto + - Profilseite `login.php` - [x] **Phase 3: Nährwerte & Rezepterweiterung** - Datenmodell & Admin.php Erweiterung - Visuelles Nährwert-Widget (Floema-Präzision) @@ -28,6 +28,10 @@ Dieses Dokument enthält den aktuellen Entwicklungsstand und detaillierte Aufgab - Touch-Swipe-Karussell direkt auf index.php (unter Landing-Page) - Filteralgorithmus nach Benutzer-Ernährungszielen - Mikro-Animationen & Quick-Save Funktion +- [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 -Das Fundament für Personalisierung und Newsletter-Abos über eine sichere Firebase-Anbindung (Auth & Firestore). +### 🔒 Phase 2: Profil & Personalisierung (lokal) +Favoriten, Ernährungsziel und Newsletter ohne Cloud-Backend. -- [x] **Firebase Setup & Initialisierung** - - [x] Firebase SDK Client-Side Einbindung in `partials/head.php` or separaten Helper - - [x] Firebase Config in umweltabhängigen Settings/Variablen auslagern - - [x] Firestore-Datenbank initialisieren (`subscribers` und `users` Collections) -- [x] **Premium Login- & Registrierungsseite** - - [x] Erstellung der Seite `login.php` (und optional `signup.php`) im neuen Design-System-Stil - - [x] Firebase Authentication (Email/Passwort Login & Registrierung) implementieren - - [x] Responsive UI mit sanften Error- & Success-Meldungen und Eingabefeldern im Glasmorphismus-Look - - [x] Client-seitige und Server-seitige Session-Synchronisation (z.B. Firebase Session Tokens an PHP-Session via Ajax senden) +- [x] **Profilseite `login.php`** + - [x] Ernährungsziel wählen und in `localStorage` speichern (`fc-local.js`) + - [x] Gespeicherte Favoriten anzeigen - [x] **Favoriten-Funktion (Rezept-Bookmarks)** - - [x] Firestore-Collection `user_favorites` anlegen (Format: `userId` -> Liste von `recipeSlugs`) - - [x] "Rezept speichern" Button (Herz-Icon) auf den Rezeptkarten und Rezept-Details mit dynamischem Login-Check - - [x] Mikro-Animationen für das Herz-Icon (Scale up/down, Fülleffekte mit `var(--ease-overshoot)`) -- [x] **Firebase-gestütztes Newsletter-Abo** - - [x] Newsletter-Eingabefeld im Footer (`partials/footer.php`) implementieren - - [x] AJAX-Submit-Handler: E-Mail-Adresse prüfen und in Firestore Collection `subscribers` speichern - - [x] Elegantes Feedback-UI (Erfolgsmeldung ohne Page-Reload) + - [x] Herz-Icon auf Karten, Speicherung in `localStorage` + - [x] Mikro-Animationen für das Herz-Icon +- [x] **Newsletter** + - [x] Footer-Formular mit `mailto:`-Weiterleitung --- @@ -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. - [x] **Video-Verknüpfung im Admin-Panel** - - [x] Rezeptstruktur in `data/recipes.json` erweitern, damit jeder Arbeitsschritt (`steps`) eine optionale `video_url` (MP4 aus Firebase Storage oder extern) besitzen kann + - [x] Rezeptstruktur in `data/recipes.json` erweitern, damit jeder Arbeitsschritt (`steps`) eine optionale `video_url` (externe MP4-URL) besitzen kann - [x] `admin.php` erweitern, um Video-URLs pro Einzelschritt einzugeben - [x] **Fullscreen Cooking-Mode Overlay** - [x] Trigger-Button "Kochmodus starten" (`CapitoliumRevealButton` Stil) auf `recipe.php` einbauen @@ -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] Flüssiges Einblenden per Scroll-Reveal (`reveal-target`) mit Anbindung an `LenisSmoothScroll` - [x] **Personalisiertes Goal-Matching** - - [x] Falls der Benutzer eingeloggt ist, sein Ernährungsziel aus den Firebase-Profildaten abrufen + - [x] Ernährungsziel aus `localStorage` (`fc-local.js`) für Karussell-Filter - [x] Rezepte dynamisch filtern (z.B. High-Protein Rezepte bei Ziel "Muskelaufbau", Low-Carb Rezepte bei Ziel "Abnehmen") - [x] Personalisierte "Für dich empfohlen"-Badge auf den Karten anzeigen - [x] **Mikro-Interaktionen & Quick-Save** - [x] Touch-Gesten-Unterstützung (Wischen zum Blättern mit sanftem Trägheits-Feedback) - - [x] Quick-Save Button direkt auf den Karussell-Karten (Herzeffekt mit direktem Firebase Firestore Sync) + - [x] Quick-Save Button direkt auf den Karussell-Karten (Herzeffekt, `localStorage`) - [x] Hover-Reveal-Effekte für Rezeptdetails (Zutaten-Vorschau oder Kochzeit-Highlight schiebt sich weich hoch) --- +### 🔥 Phase 6: 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 - `[ ]` Noch nicht begonnen - `[/]` In Bearbeitung diff --git a/.agents/brain.md b/.agents/brain.md index 5ae699f..9011b1e 100644 --- a/.agents/brain.md +++ b/.agents/brain.md @@ -1,37 +1,131 @@ # FlixCooks Project Brain -This document summarizes the architectural knowledge, conventions, and learnings accumulated during our session. +This document summarizes architectural knowledge, conventions, and learnings for humans and AI agents working on this repo. ## 1. Project Architecture & Stack -- **Backend:** Vanilla PHP. The project intentionally avoids heavy frameworks. -- **Database:** Flat-file JSON database (`data/recipes.json`). Data is loaded and saved via utility functions in `helpers.php`. -- **Admin Panel (`admin.php`):** Acts as a lightweight CMS. It uses simple textareas where each line maps to an array element (e.g., for `ingredients`, `steps`, `step_videos`, `step_timers`). This keeps the JSON structure clean and parsing straightforward. -- **Frontend:** Server-rendered PHP templates (`index.php`, `recipe.php`) with Vanilla JavaScript and Vanilla CSS. No Tailwind or heavy component libraries. +- **Backend:** Vanilla PHP. No heavy frameworks. +- **Database:** PostgreSQL required (`DATABASE_URL` in `.env`). Normalized tables in `scripts/schema.sql`; `load_recipes()` / `save_recipe()` in `helpers.php`. No runtime JSON recipe file. One-time import: `php scripts/db-seed.php` from `data/recipes.json`. +- **Site settings (legal pages):** Flat-file `data/site.json` via `load_site_settings()` / `save_site_settings()` — not in Postgres. +- **Admin Panel (`admin.php`):** Lightweight CMS. Textareas use one line per array element (`ingredients`, `steps`, `step_videos`, `step_timers`). +- **Frontend:** Server-rendered PHP (`index.php`, `recipe.php`, …), Vanilla JS/CSS. Profile/favorites via `assets/fc-local.js`. +- **Config:** `config.php` loads `.env`, `get_db_connection()`. Never commit `.env` (see `.gitignore`). ## 2. Design & Aesthetics -- **CSS:** Highly customized CSS with modern design tokens (e.g., `var(--ease-out-expo)`, `var(--surface-1)`). -- **Animations:** Employs sophisticated micro-animations, glassmorphism (`backdrop-filter: blur`), and dynamic layouts (e.g., `clip-path` for overlays). -- **Smooth Scrolling:** Uses **Lenis** (`LenisSmoothScroll`). - - *Crucial Rule:* Whenever a fullscreen overlay (like the Cooking Mode) is opened, `lenis.stop()` must be called to prevent background scrolling. When closed, call `lenis.start()`. -- **Preloader:** A custom `CapitoliumPreloader` runs on the homepage. It is cached in `sessionStorage('flixcooks_preloader_seen')` so it only fires once per browsing session. +- **CSS:** Custom properties (`var(--ease-out-expo)`, `var(--surface-1)`), glassmorphism, `FloemaLayoutGrid`. +- **Lenis:** Call `lenis.stop()` when opening fullscreen overlays (e.g. Cooking Mode); `lenis.start()` on close. +- **Preloader:** `CapitoliumPreloader` on homepage; once per session via `sessionStorage('flixcooks_preloader_seen')`. ## 3. Antigravity Agent Configuration -- **Workspace Rules:** Best placed in `.agents/rules/` (e.g., `AGENT.md`). To ensure they are always active, the frontmatter must include: - ```yaml - always_on: true - glob: "*" - ``` -- **Custom Skills:** Can be defined as JSON files in `.agents/skills/`. We successfully created `close_feature.json` to automate the Git workflow of checking out `main`, merging a feature branch, verifying functionality, and deleting the branch. +- **Workspace Rules:** `.agents/rules/` with `always_on: true` and `glob: "*"` in frontmatter. +- **Custom Skills:** `.agents/skills/` (e.g. `close_feature.json` for Git merge workflow). ## 4. GitHub Actions & CI/CD -- **Gemini Code Review Automation:** We integrated `petarzarkov/gemini-code-review-action` to automatically review PRs. - - **Secrets:** Must be passed using `env:` instead of `with:` (e.g., `GEMINI_API_KEY`, `GITHUB_TOKEN`), otherwise the action fails with unexpected input errors. NEVER hardcode API keys in workflow files. - - **Model Naming:** Google's `v1beta` API is very strict. `gemini-1.5-flash` often fails. You must use the fully-qualified name like `gemini-1.5-flash-latest` or `gemini-2.0-flash-lite`. - - **Pinning Versions:** Always pin GitHub Actions to a specific version tag (e.g., `@v1.0.4`) rather than `@latest` to prevent unexpected breaking changes. +- **Gemini PR review:** `petarzarkov/gemini-code-review-action`; secrets via `env:` not `with:`; pin action versions; use full model names (e.g. `gemini-2.0-flash-lite`). -## 5. Completed Milestones -- **Phase 3 (Nutrition):** Implemented. Recipes now store `calories`, `protein`, `carbs`, and `fat`. Admin panel handles inputs, and the UI displays them beautifully. -- **Phase 4 (Interactive Cooking Mode):** Implemented. Recipes now support step-by-step looping background videos and interactive timers (`step_videos`, `step_timers`). The UI utilizes a fullscreen overlay slider with Vanilla JS logic. +--- -## 6. Next Steps -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. +## 5. PostgreSQL — Schema & Data Flow + +### Relational schema (`scripts/schema.sql`) +Created by `ensure_recipe_schema()` on first DB use. Legacy JSONB `recipes.data` is migrated once automatically. + +| 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()` | diff --git a/.cursor/plans/coolify_react_cms_stack_ea69bc7d.plan.md b/.cursor/plans/coolify_react_cms_stack_ea69bc7d.plan.md new file mode 100644 index 0000000..e0d7bc5 --- /dev/null +++ b/.cursor/plans/coolify_react_cms_stack_ea69bc7d.plan.md @@ -0,0 +1,324 @@ +--- +name: Coolify React CMS Stack +overview: "Empfohlener Standard-Stack: Next.js (React) + Payload CMS (self-hosted) + PostgreSQL, alles als Docker-Images über GitHub → Coolify. Cursor/MCP für die Entwicklung, GitHub Actions für Qualitätssicherung vor dem Deploy." +todos: + - id: scaffold-monorepo + content: "Greenfield-Monorepo anlegen: apps/web (Next standalone), apps/cms (Payload), packages/shared-types" + status: pending + - id: docker-coolify + content: "Dockerfiles, docker-compose.yml, docs/COOLIFY.md (3 Services: Postgres, CMS, Web) nach FlixCooks-Muster" + status: pending + - id: cms-content-model + content: Payload Collections + Webhooks für On-Demand Revalidation; Env-Beispiele in .env.example + status: pending + - id: github-ci + content: "GitHub Actions: lint, typecheck, build, docker build smoke; branch protection auf main" + status: pending + - id: mcp-agents + content: .cursor/mcp.json + .agents/brain.md mit Stack-, Env- und Deploy-Regeln für KI + status: pending + - id: animation-baseline + content: "GSAP/Lenis/Framer-Baseline-Komponenten und Regel: Overlays stoppen Lenis" + status: pending +isProject: false +--- + +# Tech-Stack & Workflow: React-Sites, CMS, Docker, Coolify + +## Empfehlung (Default) + +Du bist unsicher bei CMS und Frontend – hier ist ein **bewährtes Default**, das zu deinen Vorgaben passt (React, Animationen, Docker, Coolify, KI/MCP, CI): + +| Schicht | Technologie | Warum | +|---------|-------------|--------| +| **Frontend** | [Next.js 15](https://nextjs.org) (App Router, TypeScript) | SEO/SSR/ISR, React-Ökosystem, `output: 'standalone'` für ein schlankes Production-Docker-Image | +| **Animationen** | GSAP (+ ScrollTrigger), Framer Motion, Lenis | GSAP für Scroll/Timeline-Premium-Feel; Framer für UI-Micro-Interactions; Lenis kennst du bereits aus FlixCooks | +| **CMS** | [Payload CMS 3](https://payloadcms.com) (eigener Container) | TypeScript, Postgres-native, Admin-UI out of the box, Docker-freundlich, passt zu Coolify wie dein aktuelles Postgres-Setup | +| **Datenbank** | PostgreSQL 16 (Coolify Database Service) | Eine Instanz, getrennte DBs/User für CMS vs. App optional | +| **Runtime / Deploy** | Docker + [Coolify](https://coolify.io) | Git-Webhook → Build → Traefik/HTTPS; du hast das Muster schon in [docs/COOLIFY.md](docs/COOLIFY.md) | +| **Lokale Dev** | `docker compose` (Web + CMS + Postgres) | Parität zu Production, wie [docker-compose.yml](docker-compose.yml) bei FlixCooks | +| **KI-Entwicklung** | Cursor + MCP-Server | Repo-Kontext, GitHub, Docs, optional DB | +| **CI** | GitHub Actions | Lint, Types, Build, Docker-Smoke, optional E2E; PR-Review wie [.github/workflows/gemini-pr-review.yml](.github/workflows/gemini-pr-review.yml) | + +**Alternative Frontend:** Vite + React SPA + nginx-Image – maximal frei für reine Animation-Landingpages, aber schlechteres SEO und kein ISR ohne Extra-Aufwand. **Alternative CMS (weniger Ops):** Sanity/Contentful (Cloud) – nur Frontend-Container auf Coolify. **Alternative CMS (kein Backend):** Tina/Decap + Markdown im Repo – gut für Blogs, schwächer für Redakteur:innen ohne Git. + +--- + +## Zielarchitektur auf Coolify + +```mermaid +flowchart TB + subgraph dev [Entwicklung] + Cursor[Cursor + MCP] + LocalCompose[docker compose] + Cursor --> LocalCompose + end + + subgraph github [GitHub] + Repo[Monorepo] + GHA[GitHub Actions CI] + Repo --> GHA + end + + subgraph coolify [Coolify Server] + PG[(PostgreSQL)] + CMS[Payload CMS Container] + WEB[Next.js Container] + Traefik[Traefik HTTPS] + PG --> CMS + CMS -->|REST/GraphQL| WEB + Traefik --> WEB + Traefik --> CMS + end + + dev -->|push main| Repo + GHA -->|grüner Build| Repo + Repo -->|Webhook Deploy| coolify +``` + +**Drei Coolify-Ressourcen** (analog zu deinem FlixCooks-Setup: Postgres + App): + +1. **PostgreSQL** – internal URL, nicht öffentlich +2. **CMS-App** – Dockerfile aus `apps/cms`, Port z. B. 3001, Env: `DATABASE_URL`, `PAYLOAD_SECRET` +3. **Web-App** – Dockerfile aus `apps/web`, Port 3000, Env: `CMS_URL` (internal), `REVALIDATE_SECRET` für On-Demand-ISR + +Persistenz: Postgres-Volume (Inhalte), optional Volume für CMS-Uploads (`/app/media`). + +--- + +## Repository-Struktur (Greenfield-Vorlage) + +Ein Repo pro „Site-Familie“ oder Monorepo für mehrere Marken: + +``` +my-site/ +├── apps/ +│ ├── web/ # Next.js +│ │ ├── Dockerfile +│ │ ├── src/ +│ │ └── next.config.ts # output: 'standalone' +│ └── cms/ # Payload +│ ├── Dockerfile +│ └── payload.config.ts +├── packages/ +│ └── shared-types/ # optional: gemeinsame TS-Typen CMS ↔ Web +├── docker-compose.yml # lokaler Prod-Parität-Stack +├── docker-compose.dev.yml # nur Postgres (wie bei FlixCooks) +├── .github/workflows/ +│ ├── ci.yml +│ └── gemini-pr-review.yml # optional, aus FlixCooks übernehmen +├── .cursor/ +│ └── mcp.json # MCP-Server für das Team +├── .agents/ +│ ├── brain.md # Architektur für KI (Pattern aus FlixCooks) +│ └── rules/AGENT.md +└── docs/ + └── COOLIFY.md +``` + +--- + +## Frontend-Stack (React + Animationen) + +**Kern:** + +- **Next.js App Router** – Seiten in `app/`, Server Components für CMS-Daten, Client Components nur für Animation/Interaktion +- **TypeScript** – strikt; Typen aus Payload generieren (`payload generate:types`) +- **Styling** – CSS Modules oder Tailwind (nur wenn du es willst; FlixCooks bleibt bei Vanilla CSS – für neue React-Sites ist Tailwind optional, nicht Pflicht) + +**Animation-Toolkit:** + +| Tool | Einsatz | +|------|---------| +| **GSAP + ScrollTrigger** | Hero-Sequences, pinned Sections, komplexe Timelines | +| **Framer Motion** | Hover, Page-Transitions, modale UI | +| **Lenis** | Smooth Scroll (wie FlixCooks: bei Overlays `lenis.stop()`) | +| **(optional) @react-three/fiber** | 3D-Hero nur wenn nötig | + +**CMS-Anbindung im Web:** + +- **Build-Zeit (SSG):** `generateStaticParams` + Fetch von Payload REST für Marketing-Seiten +- **On-Demand Revalidation:** Payload-Webhook → `POST /api/revalidate?secret=...` in Next.js (Inhalt ändert sich ohne Full-Redeploy) +- **Preview:** Draft-Modus mit Payload Preview-URL + Next `draftMode()` + +Env im Web-Container (Coolify): + +- `CMS_URL=http://payload-service:3001` (internal hostname) +- `REVALIDATE_SECRET`, `NEXT_PUBLIC_SITE_URL` + +--- + +## CMS-Stack (Payload auf Coolify) + +**Warum Payload als Default:** Self-hosted, eine Postgres-URL, Admin unter `/admin`, Collections/Blocks für Seitenmodule, Media-Uploads, Webhooks – alles containerisierbar. + +**Coolify Env (CMS):** + +- `DATABASE_URL` – internal Postgres URL (gleiches Muster wie [docs/COOLIFY.md](docs/COOLIFY.md) Zeilen 33–43) +- `PAYLOAD_SECRET` – langer Zufallswert (nur Secrets, nie ins Repo) +- `NEXT_PUBLIC_SERVER_URL` – öffentliche CMS-URL (für Admin-Assets) + +**Erstes Deployment:** analog FlixCooks `RUN_DB_SEED` – einmalig Migration/Seed, danach Flag entfernen. + +**Sicherheit:** CMS-Admin nur über HTTPS; CORS auf Web-Domain beschränken; API-Keys für Preview/Revalidate nur als Secrets. + +--- + +## Docker-Images + +### Web (`apps/web/Dockerfile`) – Next standalone + +Mehrstufig: `node:22-alpine` → `npm ci` → `npm run build` → Runtime nur `.next/standalone` + `static` + `public`. + +- `EXPOSE 3000` +- `HEALTHCHECK` auf `/api/health` (kleine Route: `{ "status": "ok" }`) +- Coolify: Port **3000**, Health Path `/api/health` + +### CMS (`apps/cms/Dockerfile`) + +Payload-Official-Pattern oder Node-Image mit `npm run build && npm run start`. + +- `HEALTHCHECK` auf CMS-Health-Endpoint +- Volume für `/app/media` (Uploads überleben Redeploy) + +### Lokales Parität-Compose + +Orientierung an deinem bestehenden [docker-compose.yml](docker-compose.yml): + +- `postgres` mit `healthcheck` +- `cms` `depends_on: postgres: service_healthy` +- `web` `depends_on: cms` + `DATABASE_URL` nur wenn Web eigene DB braucht (meist nicht – nur CMS nutzt DB) + +Entrypoint-Pattern von [docker/entrypoint.sh](docker/entrypoint.sh) übernehmen: **DB warten → Migration → dann Prozess starten**. + +--- + +## Coolify-Workflow (End-to-End) + +```mermaid +sequenceDiagram + participant Dev as Developer + participant GH as GitHub + participant GHA as GitHub Actions + participant CF as Coolify + participant Web as Next Container + participant CMS as Payload Container + + Dev->>GH: push feature branch + Dev->>GH: open PR + GHA->>GHA: lint typecheck build docker + GHA-->>Dev: PR checks green + Dev->>GH: merge to main + GH->>CF: webhook deploy + CF->>CF: build CMS image + CF->>CF: build Web image + CF->>Web: rolling update + CMS->>Web: optional revalidate webhook +``` + +**Coolify-Konfiguration pro App:** + +| Setting | Web | CMS | +|---------|-----|-----| +| Build Pack | Dockerfile | Dockerfile | +| Branch | `main` | `main` | +| Port | 3000 | 3001 | +| Health | `/api/health` | `/api/health` oder Payload-Default | +| Secrets | `REVALIDATE_SECRET`, `CMS_URL` | `DATABASE_URL`, `PAYLOAD_SECRET` | + +**Checkliste Erstdeploy** (aus [docs/COOLIFY.md](docs/COOLIFY.md) übertragbar): + +1. Postgres healthy, internal URL notieren +2. CMS deployen, Admin anlegen, Collections seeden +3. Web deployen mit internal `CMS_URL` +4. Domain + HTTPS (Traefik) +5. Webhook Payload → Next Revalidate testen +6. `RUN_DB_SEED` / Migration-Flags wieder aus + +--- + +## GitHub Actions (CI vor Coolify) + +**Workflow `ci.yml`** (bei jedem PR + push auf `main`): + +1. **checkout** +2. **Node 22** + Cache (`apps/web`, `apps/cms`) +3. **Parallel jobs oder Matrix:** + - `npm run lint` (ESLint) + - `npm run typecheck` (`tsc --noEmit`) + - `npm run build` (Web + CMS) +4. **Docker build test** (ohne Push): + - `docker build -f apps/web/Dockerfile apps/web` + - `docker build -f apps/cms/Dockerfile apps/cms` +5. **(optional) Playwright** gegen `docker compose up` – Smoke: Startseite, eine CMS-Seite, Health endpoints +6. **(optional) PR Review** – bestehendes Gemini-Workflow aus FlixCooks wiederverwenden + +**Branch-Schutz:** `main` nur mit grünen Required Checks mergebar. + +Coolify deployt **nach** Merge – CI blockiert kaputte Images, Coolify baut das echte Production-Image (oder du pushst zu GHCR – für den Start reicht Coolify-eigener Build). + +--- + +## KI-gestützte Entwicklung mit MCP + +**Cursor `mcp.json` (Team-Standard):** + +| MCP | Zweck | +|-----|--------| +| **GitHub** | Issues, PRs, Actions-Logs aus dem Chat | +| **Context7** (oder Fetch) | Aktuelle Next.js / Payload / GSAP-Docs | +| **Postgres** (optional, nur Dev) | Content/Debugging – nie Production-Credentials im Repo | +| **Filesystem** | Standard in Cursor | + +**Projekt-Wissen für Agenten** (aus FlixCooks übernehmen): + +- [.agents/brain.md](.agents/brain.md) – Architektur, Env-Regeln, Coolify-Hosts +- [.agents/rules/AGENT.md](.agents/rules/AGENT.md) – Commit/PR/TODO-Konventionen +- `docs/COOLIFY.md` – Deploy-Runbook pro Projekt + +**Typischer KI-Workflow:** + +1. Ticket/Issue in GitHub (MCP) +2. Feature-Branch; Agent ändert `apps/web` + Payload-Collection +3. `docker compose up` lokal; Agent nutzt Health-URLs +4. PR → CI grün → Gemini-Review optional +5. Merge → Coolify + +--- + +## Entwickler-Alltag (Kurzablauf) + +1. `cp .env.example .env` – lokale URLs +2. `docker compose -f docker-compose.dev.yml up -d` (nur Postgres) **oder** volles `docker compose up` +3. `npm run dev` in `apps/web` und `apps/cms` (schneller Hot Reload) **oder** alles in Containern +4. In Payload Inhalte pflegen → Webhook triggert Revalidate +5. `git push` → PR → CI → merge → Coolify rebuild + +--- + +## Bezug zu FlixCooks (dieses Repo) + +FlixCooks ist heute **Vanilla PHP + Postgres + eingebautes `admin.php`-CMS** – kein React. Das ist ein **paralleler Stack**, kein Widerspruch: + +| Aspekt | FlixCooks (aktuell) | Neuer React-Stack | +|--------|---------------------|-------------------| +| Frontend | PHP-Templates | Next.js + React | +| CMS | `admin.php` | Payload (Container) | +| DB | Postgres | Postgres | +| Deploy | [Dockerfile](Dockerfile) + [COOLIFY.md](docs/COOLIFY.md) | gleiches Muster, zwei App-Services | +| Animationen | Lenis, Vanilla CSS | Lenis + GSAP + Framer | + +Du kannst FlixCooks auf Coolify weiterbetreiben und **neue Projekte** im Monorepo-Template starten. Eine spätere Migration FlixCooks → Next wäre ein separates Projekt (Content-Export aus Postgres/JSON → Payload-Collections). + +--- + +## Nächste konkrete Schritte (nach Plan-Freigabe) + +1. **Greenfield-Repo** aus der Struktur oben scaffolden (oder `create-payload-app` + Next in Monorepo). +2. **Minimale Collections** in Payload: `pages`, `recipes` (oder `projects`), `siteSettings`, Media. +3. **Dockerfiles + compose** + `docs/COOLIFY.md` vom FlixCooks-Muster kopieren/adaptieren. +4. **GitHub Actions `ci.yml`** anlegen. +5. **Coolify:** Postgres → CMS → Web → Webhook Revalidate testen. +6. **`.cursor/mcp.json` + `.agents/brain.md`** für das neue Repo. + +Wenn du willst, kann im nächsten Schritt ein **konkretes Starter-Repo** (Dateien + minimale Hero-Animation + eine Payload-Collection) direkt in einem neuen Ordner oder Branch angelegt werden. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..88018b8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.agents +.env +.env.* +!.env.example +docker-compose.dev.yml +docker-compose.yml +terminals +*.md +!README.md +.cursor diff --git a/.env.example b/.env.example index 810ca3e..8ec31d8 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,11 @@ -# FlixCooks Firebase Configuration -# Replace these placeholder values with your actual Firebase project settings. +# FlixCooks – lokale Entwicklung (.env wird nicht committed) +# Kopieren: cp .env.example .env -FIREBASE_API_KEY="AIzaSyYourApiKeyHere" -FIREBASE_AUTH_DOMAIN="flixcooks-your-project-id.firebaseapp.com" -FIREBASE_PROJECT_ID="flixcooks-your-project-id" -FIREBASE_STORAGE_BUCKET="flixcooks-your-project-id.appspot.com" -FIREBASE_MESSAGING_SENDER_ID="123456789012" -FIREBASE_APP_ID="1:123456789012:web:abcdef123456" +# --- Postgres (Pflicht für die Website) --- +# Lokal: docker compose -f docker-compose.dev.yml up -d +DATABASE_URL="postgresql://flixcooks:flixcooks_dev@127.0.0.1:5432/flixcooks_dev" + +# --- Production / Coolify (im Dashboard setzen, nicht committen) --- +# DATABASE_URL="postgresql://user:pass@postgresql-service:5432/flixcooks" +# FLIXCOOKS_ADMIN_KEY="langes-zufaelliges-passwort" +# RUN_DB_SEED="true" # nur beim allerersten Deploy, danach entfernen diff --git a/.gitattributes b/.gitattributes index dfe0770..f7e2def 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,2 @@ -# Auto detect text files and perform LF normalization -* text=auto +* text=auto eol=lf +*.sh text eol=lf diff --git a/.htaccess b/.htaccess deleted file mode 100644 index f96c3a6..0000000 --- a/.htaccess +++ /dev/null @@ -1,4 +0,0 @@ -#KONSOLEH AREA START - PLEASE DO NOT EDIT MANUALLY BETWEEN THESE LINES -DirectoryIndex index.php -#KONSOLEH AREA END - PLEASE ADD MANUAL CHANGES BELOW -SetEnv FLIXCOOKS_ADMIN_KEY "vFDH.N_tVLEKNdR3fhLs" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..32682a1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# FlixCooks – Production image (Coolify / any Docker host) +FROM php:8.3-apache-bookworm + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev \ + && docker-php-ext-install pdo_pgsql \ + && a2enmod rewrite headers \ + && rm -rf /var/lib/apt/lists/* + +# Apache: document root, no per-directory overrides in production +ENV APACHE_DOCUMENT_ROOT=/var/www/html +RUN sed -ri 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \ + && sed -ri 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf \ + && printf '%s\n' \ + '' \ + ' AllowOverride None' \ + ' Require all granted' \ + '' \ + > /etc/apache2/conf-available/flixcooks.conf \ + && a2enconf flixcooks + +WORKDIR /var/www/html + +COPY --chown=www-data:www-data . /var/www/html + +RUN sed -i 's/\r$//' /var/www/html/docker/entrypoint.sh \ + && chmod +x /var/www/html/docker/entrypoint.sh \ + && mkdir -p /var/www/html/data \ + && chown -R www-data:www-data /var/www/html/data + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD php /var/www/html/health.php >/dev/null || exit 1 + +ENTRYPOINT ["/var/www/html/docker/entrypoint.sh"] +CMD ["apache2-foreground"] diff --git a/README.md b/README.md index 9c2bb3b..3cb670b 100644 --- a/README.md +++ b/README.md @@ -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**. - `recipe.php`: The immersive recipe detail page, featuring floating macro-nutrition widgets, interactive ingredients lists, and the fullscreen **Step-by-Step Cooking Mode**. - `admin.php`: A custom, lightweight CMS/admin dashboard allowing full CRUD capabilities over the recipe database, dynamic ingredient line parsing, cooking timers, and video URL associations. - - `login.php` & `register.php`: Fully responsive, glassmorphic auth portals powered by Firebase. + - `login.php`: Local profile page for dietary goals and saved favorites (browser storage). - **Support & Layouts**: - `partials/`: Contains modular templates (`head.php`, `header.php`, `footer.php`) to maintain a clean DRY structure. - - `helpers.php`: Core PHP utilities containing data formatting helpers and the data access layer for the flat-file database. + - `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`. - - `api/`: Lightweight, stateless backend endpoints supporting AJAX operations (e.g., newsletter subscriptions, bookmarks, recommendation queries). - - `data/`: Houses `recipes.json`, our flat-file recipe database. + - `assets/fc-local.js`: Browser-side storage for favorites and dietary goals. + - `data/recipes.json`: Optional seed file only (`php scripts/db-seed.php`), not used at runtime. + - `scripts/schema.sql`: Relational table definitions for recipes. --- @@ -36,13 +37,9 @@ FlixCooks uses a modern, carefully curated vanilla tech-stack focused on lightni ### ⚙️ Backend & Data - **Engine**: Vanilla PHP. -- **Database**: Flat-file JSON database (`data/recipes.json`), allowing lightning-quick load times and simple structural schemas without heavy overhead. +- **Database**: PostgreSQL only. `DATABASE_URL` is required; without a working DB connection the site returns HTTP 503. - **Environment**: Custom `.env` variable parser integrated into PHP bootstrap. -### 🔒 Integrations & Cloud Services -- **Firebase Authentication**: Client and server-side synchronized user sessions for profile management. -- **Firebase Firestore**: Real-time database managing newsletter subscribers and user bookmarks/favorites lists. - --- ## 🚀 Local Development Setup @@ -51,7 +48,8 @@ Follow these simple steps to spin up the local development environment. ### Prerequisites Make sure you have the following installed on your local machine: -- **PHP** (v7.4 or higher recommended) +- **PHP** (8.x recommended) with the **pgsql** extension (`php-pgsql` on Linux/WSL) +- **Docker** (for local Postgres via `docker-compose.dev.yml`) - A modern web browser ### 1. Set Up Environment Variables @@ -59,14 +57,63 @@ Make sure you have the following installed on your local machine: ```bash cp .env.example .env ``` -2. Open `.env` and fill in your actual **Firebase project settings** (API keys, project identifier, authentication domain, etc.): - ```env - FIREBASE_API_KEY="AIzaSyYourApiKeyHere" - FIREBASE_AUTH_DOMAIN="flixcooks-your-project-id.firebaseapp.com" - ... - ``` +2. Set **`DATABASE_URL`** in `.env` (required). See [Local Postgres (Docker)](#local-postgres-docker) below. +3. Seed recipes once: `php scripts/db-seed.php` (imports `data/recipes.json` into SQL tables). -### 2. 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) 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 ``` -#### Option B: Local Apache Environments (XAMPP / MAMP / WAMP) +#### Option B: Local Apache (XAMPP / MAMP / WAMP) If you prefer running a full local stack: 1. Move or link the project directory inside your local server's document root (e.g., `htdocs` or `www`). -2. Ensure URL rewriting is enabled (the included `.htaccess` file handles caching and custom redirections). +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`). --- +## 🗄️ 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 All current development tasks, features, and roadmaps are actively tracked and updated in the project’s [.agents/TODO.md](file:///.agents/TODO.md) file. Architectural learnings, conventions, and configuration updates are maintained in the central knowledge base: [.agents/brain.md](file:///.agents/brain.md). diff --git a/admin.php b/admin.php index 1902a51..b07411a 100644 --- a/admin.php +++ b/admin.php @@ -2,9 +2,36 @@ session_start(); require __DIR__ . '/helpers.php'; -$ADMIN_KEY = getenv('FLIXCOOKS_ADMIN_KEY') ?: 'vFDH.N_tVLEKNdR3fhLs'; +$ADMIN_KEY = getenv('FLIXCOOKS_ADMIN_KEY') ?: ''; $authed = isset($_SESSION['fc_admin']) && $_SESSION['fc_admin'] === true; +if ($ADMIN_KEY === '') { + http_response_code(503); + ?> + + + + + + FlixCooks Admin Unavailable + + + + +
+

Admin unavailable

+

FLIXCOOKS_ADMIN_KEY is not configured.

+
+ + + ($r['slug'] ?? '') !== $slugDel)); - if ($before !== count($allRecipes) && save_recipes($allRecipes)) { + if ($slugDel !== '' && delete_recipe($slugDel)) { $message = 'Recipe deleted.'; + $allRecipes = load_recipes(); } else { - $message = 'Could not delete. Check permissions.'; + $message = 'Could not delete recipe.'; } $editing = null; $editIndex = null; @@ -259,13 +289,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet } } - if ($featured) { - foreach ($allRecipes as &$r) { - $r['featured'] = false; - } - unset($r); - } - $record = [ 'slug' => $slug, 'hero' => $hero, @@ -309,16 +332,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet ], ]; - if ($slugOriginal && $editIndex !== null) { - $allRecipes[$editIndex] = $record; - } else { - $allRecipes[] = $record; + if ($slugOriginal && $slugOriginal !== $slug) { + delete_recipe($slugOriginal); } - if (save_recipes($allRecipes)) { + if (save_recipe($record)) { $message = $slugOriginal ? 'Saved changes.' : 'Saved! New recipe added.'; + $allRecipes = load_recipes(); } else { - $message = 'Could not save the file. Check permissions on data/recipes.json.'; + $message = 'Could not save to database.'; } } } diff --git a/api/session.php b/api/session.php deleted file mode 100644 index 9aecee2..0000000 --- a/api/session.php +++ /dev/null @@ -1,40 +0,0 @@ - $uid, - 'email' => $email, - 'token' => $token - ]; - echo json_encode(['status' => 'success', 'message' => 'Logged in']); - } else { - http_response_code(400); - echo json_encode(['status' => 'error', 'message' => 'Missing UID']); - } -} elseif ($action === 'logout') { - $_SESSION = []; - if (ini_get("session.use_cookies")) { - $params = session_get_cookie_params(); - setcookie(session_name(), '', time() - 42000, - $params["path"], $params["domain"], - $params["secure"], $params["httponly"] - ); - } - session_destroy(); - echo json_encode(['status' => 'success', 'message' => 'Logged out']); -} else { - http_response_code(400); - echo json_encode(['status' => 'error', 'message' => 'Invalid action']); -} diff --git a/assets/fc-local.js b/assets/fc-local.js new file mode 100644 index 0000000..7df4708 --- /dev/null +++ b/assets/fc-local.js @@ -0,0 +1,62 @@ +// Local-only favorites and dietary goal (no cloud backend). +(function (global) { + var FAV_KEY = 'fc_fav_slugs'; + var GOAL_KEY = 'fc_diet_goal'; + + function readJson(key, fallback) { + try { + var raw = global.localStorage.getItem(key); + if (!raw) return fallback; + return JSON.parse(raw); + } catch (e) { + return fallback; + } + } + + function writeJson(key, value) { + try { + global.localStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + /* ignore quota errors */ + } + } + + global.fcLocal = { + getFavorites: function () { + var list = readJson(FAV_KEY, []); + return Array.isArray(list) ? list : []; + }, + hasFavorite: function (slug) { + return this.getFavorites().indexOf(slug) !== -1; + }, + toggleFavorite: function (slug) { + var list = this.getFavorites(); + var idx = list.indexOf(slug); + if (idx === -1) { + list.push(slug); + } else { + list.splice(idx, 1); + } + writeJson(FAV_KEY, list); + return list; + }, + getGoal: function () { + try { + return global.localStorage.getItem(GOAL_KEY) || ''; + } catch (e) { + return ''; + } + }, + setGoal: function (goal) { + try { + if (goal) { + global.localStorage.setItem(GOAL_KEY, goal); + } else { + global.localStorage.removeItem(GOAL_KEY); + } + } catch (e) { + /* ignore */ + } + } + }; +})(window); diff --git a/config.php b/config.php index 869259c..2422301 100644 --- a/config.php +++ b/config.php @@ -34,13 +34,51 @@ function load_env() { // Automatically load on include load_env(); -function get_firebase_config(): array { - return [ - 'apiKey' => getenv('FIREBASE_API_KEY') ?: '', - 'authDomain' => getenv('FIREBASE_AUTH_DOMAIN') ?: '', - 'projectId' => getenv('FIREBASE_PROJECT_ID') ?: '', - 'storageBucket' => getenv('FIREBASE_STORAGE_BUCKET') ?: '', - 'messagingSenderId' => getenv('FIREBASE_MESSAGING_SENDER_ID') ?: '', - 'appId' => getenv('FIREBASE_APP_ID') ?: '', - ]; +class DatabaseUnavailableException extends RuntimeException +{ +} + +/** + * Get a PDO connection to the database. + */ +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; + } } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..4f1a2de --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,26 @@ +# Lokale Postgres-Instanz für Entwicklung (dev branch). +# Start: docker compose -f docker-compose.dev.yml up -d +# Stop: docker compose -f docker-compose.dev.yml down +# Reset: docker compose -f docker-compose.dev.yml down -v (löscht alle Daten!) + +services: + postgres: + image: postgres:16-alpine + container_name: flixcooks-postgres-dev + restart: unless-stopped + ports: + - "5432:5432" + environment: + POSTGRES_USER: flixcooks + POSTGRES_PASSWORD: flixcooks_dev + POSTGRES_DB: flixcooks_dev + volumes: + - flixcooks_pg_dev:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U flixcooks -d flixcooks_dev"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + flixcooks_pg_dev: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5ec26a7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +# Lokaler Produktions-Test: App-Image + Postgres (ähnlich Coolify mit zwei Services) +# +# docker compose build +# docker compose up -d +# curl http://127.0.0.1:8080/health.php +# +# Erstes Deployment mit Seed: +# RUN_DB_SEED=true docker compose up -d + +services: + web: + build: . + ports: + - "8080:80" + environment: + DATABASE_URL: postgresql://flixcooks:flixcooks_prod@postgres:5432/flixcooks + FLIXCOOKS_ADMIN_KEY: ${FLIXCOOKS_ADMIN_KEY:-change-me-in-production} + RUN_DB_SEED: ${RUN_DB_SEED:-false} + depends_on: + postgres: + condition: service_healthy + volumes: + # Impressum/Datenschutz-Einstellungen persistent halten + - site_data:/var/www/html/data + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: flixcooks + POSTGRES_PASSWORD: flixcooks_prod + POSTGRES_DB: flixcooks + volumes: + - pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U flixcooks -d flixcooks"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + pg_data: + site_data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..c5db388 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -euo pipefail + +cd /var/www/html + +echo "[flixcooks] Waiting for database..." +TRIES=0 +MAX_TRIES="${DB_WAIT_MAX_TRIES:-30}" + +until php scripts/db-check.php >/dev/null 2>&1; do + TRIES=$((TRIES + 1)) + if [ "$TRIES" -ge "$MAX_TRIES" ]; then + echo "[flixcooks] Database not reachable after ${MAX_TRIES} attempts." >&2 + exit 1 + fi + sleep 2 +done + +echo "[flixcooks] Applying schema..." +php -r "require 'helpers.php'; require_database(); echo \"schema ok\n\";" + +if [ "${RUN_DB_SEED:-false}" = "true" ]; then + echo "[flixcooks] Seeding recipes from data/recipes.json..." + php scripts/db-seed.php +fi + +echo "[flixcooks] Starting Apache..." +exec "$@" diff --git a/docs/COOLIFY.md b/docs/COOLIFY.md new file mode 100644 index 0000000..f226bea --- /dev/null +++ b/docs/COOLIFY.md @@ -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 | + + diff --git a/health.php b/health.php new file mode 100644 index 0000000..f3d269b --- /dev/null +++ b/health.php @@ -0,0 +1,25 @@ + 'ok']; + echo json_encode($response, JSON_UNESCAPED_UNICODE); +} catch (Throwable $e) { + http_response_code(503); + $response = [ + 'status' => 'error', + 'message' => $e->getMessage(), + ]; + echo json_encode($response, JSON_UNESCAPED_UNICODE); + exit(1); +} diff --git a/helpers.php b/helpers.php index d6b4099..1774209 100644 --- a/helpers.php +++ b/helpers.php @@ -7,31 +7,381 @@ if (session_status() === PHP_SESSION_NONE) { require_once __DIR__ . '/config.php'; -function load_recipes(): array { - $path = __DIR__ . '/data/recipes.json'; +function handle_database_unavailable(DatabaseUnavailableException $e): void { + http_response_code(503); + $dbError = $e->getMessage(); + include __DIR__ . '/maintenance/db-unavailable.php'; + exit; +} + +function db_table_has_column(PDO $pdo, string $table, string $column): bool { + $stmt = $pdo->prepare( + 'SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?' + ); + $stmt->execute([$table, $column]); + return (bool) $stmt->fetchColumn(); +} + +function apply_recipe_schema(PDO $pdo): void { + $path = __DIR__ . '/scripts/schema.sql'; if (!file_exists($path)) { - return []; + throw new RuntimeException('Missing scripts/schema.sql'); } - $json = file_get_contents($path); - $data = json_decode($json, true); - if (!is_array($data)) { + $pdo->exec(file_get_contents($path)); +} + +function recipe_empty_i18n(): array { + return [ + 'title' => '', + 'description' => '', + 'category' => '', + 'difficulty' => '', + 'tags' => [], + 'ingredients' => [], + 'utensils' => [], + 'steps' => [], + 'step_videos' => [], + 'step_timers' => [], + ]; +} + +function recipe_base_from_row(array $row): array { + return [ + 'slug' => $row['slug'], + 'hero' => $row['hero'] ?? '', + 'prep_time' => (int) ($row['prep_time'] ?? 0), + 'cook_time' => (int) ($row['cook_time'] ?? 0), + 'total_time' => (int) ($row['total_time'] ?? 0), + 'servings' => (int) ($row['servings'] ?? 2), + 'featured' => (bool) ($row['featured'] ?? false), + 'coming_soon' => (bool) ($row['coming_soon'] ?? false), + 'nutrition' => [ + 'calories' => (int) ($row['calories'] ?? 0), + 'protein' => (int) ($row['protein'] ?? 0), + 'carbs' => (int) ($row['carbs'] ?? 0), + 'fat' => (int) ($row['fat'] ?? 0), + ], + 'i18n' => [ + 'en' => recipe_empty_i18n(), + 'de' => recipe_empty_i18n(), + ], + ]; +} + +function decode_legacy_jsonb_value($value): ?array { + if (is_array($value)) { + return $value; + } + if (is_string($value)) { + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : null; + } + if ($value !== null) { + $decoded = json_decode(json_encode($value), true); + return is_array($decoded) ? $decoded : null; + } + return null; +} + +function migrate_legacy_jsonb_storage(PDO $pdo): void { + if (!db_table_has_column($pdo, 'recipes', 'data')) { + return; + } + + $legacy = []; + $stmt = $pdo->query('SELECT slug, data FROM recipes'); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $recipe = decode_legacy_jsonb_value($row['data']); + if (is_array($recipe) && !empty($recipe['slug'])) { + $legacy[] = $recipe; + } + } + + $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 []; } - 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'])) { $recipe['hero'] = normalize_asset_path($recipe['hero']); } } unset($recipe); - return $data; + return $list; } -function save_recipes(array $recipes): bool { - $path = __DIR__ . '/data/recipes.json'; - $json = json_encode($recipes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - return (bool) file_put_contents($path, $json); +function load_recipes(): array { + $pdo = require_database(); + return hydrate_recipes_from_db($pdo); +} + +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; + } } /** diff --git a/index.php b/index.php index ed2c95a..894e4de 100644 --- a/index.php +++ b/index.php @@ -1,6 +1,12 @@ empty($r['coming_soon']))); @@ -920,43 +925,38 @@ $recipesJson = json_encode(array_values(array_map(function($r) use ($lang) { }); } - // Hook into auth state - window.addEventListener('DOMContentLoaded', function() { - if (window.auth && window.db) { - window.auth.onAuthStateChanged(function(user) { - if (user) { - window.db.collection('users').doc(user.uid).get() - .then(function(doc) { - if (doc.exists) { - var goal = doc.data().goal; - var filtered = indexRecipeBank; - - if (goal === 'weight_loss') { - filtered = indexRecipeBank.filter(r => - r.tags.some(t => t.toLowerCase().includes('low-carb') || t.toLowerCase().includes('diet')) || - (r.category && r.category.toLowerCase().includes('salad')) - ); - } else if (goal === 'muscle_gain') { - filtered = indexRecipeBank.filter(r => - r.tags.some(t => t.toLowerCase().includes('high-protein') || t.toLowerCase().includes('meat') || t.toLowerCase().includes('fleisch')) - ); - } else if (goal === 'healthy') { - filtered = indexRecipeBank.filter(r => - r.tags.some(t => t.toLowerCase().includes('vegetarian') || t.toLowerCase().includes('healthy')) - ); - } - - // fallback if empty - if (filtered.length === 0) filtered = indexRecipeBank; - - renderSwipeCarousel(filtered, i18nGoal[goal] || null); - } - }); - } else { - // Default unauthenticated view - renderSwipeCarousel(indexRecipeBank, null); - } + function filterRecipesByGoal(goal) { + var filtered = indexRecipeBank; + if (goal === 'weight_loss') { + filtered = indexRecipeBank.filter(function(r) { + return r.tags.some(function(t) { + var lower = t.toLowerCase(); + return lower.includes('low-carb') || lower.includes('diet'); + }) || (r.category && r.category.toLowerCase().includes('salad')); }); + } else if (goal === 'muscle_gain') { + filtered = indexRecipeBank.filter(function(r) { + return r.tags.some(function(t) { + var lower = t.toLowerCase(); + return lower.includes('high-protein') || lower.includes('meat') || lower.includes('fleisch'); + }); + }); + } else if (goal === 'healthy') { + filtered = indexRecipeBank.filter(function(r) { + return r.tags.some(function(t) { + var lower = t.toLowerCase(); + return lower.includes('vegetarian') || lower.includes('healthy'); + }); + }); + } + if (filtered.length === 0) filtered = indexRecipeBank; + return filtered; + } + + window.addEventListener('DOMContentLoaded', function() { + var goal = window.fcLocal ? window.fcLocal.getGoal() : ''; + if (goal) { + renderSwipeCarousel(filterRecipesByGoal(goal), i18nGoal[goal] || null); } else { renderSwipeCarousel(indexRecipeBank, null); } diff --git a/login.php b/login.php index 5fddef4..cb1f87a 100644 --- a/login.php +++ b/login.php @@ -1,6 +1,12 @@ 'Your dietary goal', 'saved_recipes' => 'Your Saved Favorites', 'no_favorites' => "You haven't saved any recipes yet. Explore our collection and tap the heart icon!", - 'logout' => 'Log Out', - 'err_pass_match' => 'Passwords do not match.', - 'err_fill_fields'=> 'Please fill in all fields.', + 'save_goal' => 'Save goal', + 'goal_saved' => 'Dietary goal saved.', + 'local_note' => 'Favorites and goals are stored in this browser only.', ], 'de' => [ 'page_title' => 'Mein Profil & Favoriten | FlixCooks', @@ -46,9 +52,9 @@ $copy = [ 'your_goal' => 'Dein Ernährungsziel', 'saved_recipes' => 'Deine gespeicherten Favoriten', 'no_favorites' => "Du hast noch keine Rezepte gespeichert. Entdecke unsere Küche und klicke auf das Herz-Symbol!", - 'logout' => 'Abmelden', - 'err_pass_match' => 'Die Passwörter stimmen nicht überein.', - 'err_fill_fields'=> 'Bitte fülle alle Felder aus.', + 'save_goal' => 'Ziel speichern', + 'goal_saved' => 'Ernährungsziel gespeichert.', + 'local_note' => 'Favoriten und Ziele werden nur in diesem Browser gespeichert.', ] ]; @@ -56,10 +62,6 @@ $t = $copy[$lang]; $pageTitle = $t['page_title']; $description = $t['meta_desc']; -// Check PHP session state -$user = $_SESSION['fc_user'] ?? null; - -$allRecipes = load_recipes(); $allRecipesLocal = localize_recipes($allRecipes, $lang); // Formatter helper for JS usage @@ -376,95 +378,37 @@ include __DIR__ . '/partials/header.php';
- -
-
- - -
- - -
-
- - -
-
- - -
-
- -
-

- -

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

- -

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

Datenbank nicht verfügbar

+

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

+ + + +

Lokal prüfen:

+
    +
  1. DATABASE_URL in .env setzen
  2. +
  3. docker compose -f docker-compose.dev.yml up -d
  4. +
  5. php scripts/db-check.php
  6. +
  7. Leere DB: php scripts/db-seed.php
  8. +
+
+ + diff --git a/partials/footer.php b/partials/footer.php index 792a0c9..34eda16 100644 --- a/partials/footer.php +++ b/partials/footer.php @@ -61,37 +61,23 @@ })(); -// ── Newsletter subscription logic ────────────────────────── +// ── Newsletter (mailto) ──────────────────────────────────── function handleSubscribe(e) { e.preventDefault(); var emailInput = document.getElementById('newsletterEmail'); var feedback = document.getElementById('newsletterFeedback'); var form = document.getElementById('newsletterForm'); - var btn = form.querySelector('button'); var email = emailInput.value.trim(); if (!email) return; - btn.disabled = true; - feedback.className = 'newsletter-feedback'; - feedback.textContent = ''; + var subject = encodeURIComponent('FlixCooks Newsletter'); + var body = encodeURIComponent('Please add me to the newsletter: ' + email); + window.location.href = 'mailto:?subject=' + subject + '&body=' + body; - window.db.collection('subscribers').doc(email.toLowerCase()).set({ - email: email.toLowerCase(), - subscribedAt: firebase.firestore.FieldValue.serverTimestamp(), - status: 'active' - }) - .then(function() { - form.classList.add('success'); - emailInput.disabled = true; - feedback.className = 'newsletter-feedback success'; - feedback.textContent = ''; - }) - .catch(function(error) { - btn.disabled = false; - feedback.className = 'newsletter-feedback error'; - feedback.textContent = '' + error.message; - }); + form.classList.add('success'); + feedback.className = 'newsletter-feedback success'; + feedback.textContent = ''; } // ── Guest Modal Logic ────────────────────────────────────── @@ -121,33 +107,12 @@ document.addEventListener('click', function(e) { } }); -// ── Favorites Toggle & Management ─────────────────────────── -var currentUser = null; -var userFavSlugs = new Set(); - -function syncFavorites(uid) { - window.db.collection('user_favorites').where('userId', '==', uid).get() - .then(function(snapshot) { - userFavSlugs.clear(); - snapshot.forEach(function(doc) { - userFavSlugs.add(doc.data().recipeSlug); - }); - updateHeartStates(); - }) - .catch(function(err) { - console.error("Error syncing favorites: ", err); - }); -} - -function clearFavorites() { - userFavSlugs.clear(); - updateHeartStates(); -} - +// ── Favorites (localStorage) ─────────────────────────────── function updateHeartStates() { + if (!window.fcLocal) return; document.querySelectorAll('.btn-favorite').forEach(function(btn) { var slug = btn.getAttribute('data-slug'); - if (userFavSlugs.has(slug)) { + if (slug && window.fcLocal.hasFavorite(slug)) { btn.classList.add('active'); } else { btn.classList.remove('active'); @@ -156,43 +121,12 @@ function updateHeartStates() { } function toggleFavorite(slug, btn) { - if (!currentUser) { + if (!window.fcLocal) { openGuestModal(); return; } - - var docId = currentUser.uid + '_' + slug; - var docRef = window.db.collection('user_favorites').doc(docId); - - if (userFavSlugs.has(slug)) { - // Optimistic UI update - userFavSlugs.delete(slug); - btn.classList.remove('active'); - - docRef.delete() - .catch(function(err) { - console.error("Error removing favorite: ", err); - // Rollback - userFavSlugs.add(slug); - btn.classList.add('active'); - }); - } else { - // Optimistic UI update - userFavSlugs.add(slug); - btn.classList.add('active'); - - docRef.set({ - userId: currentUser.uid, - recipeSlug: slug, - createdAt: firebase.firestore.FieldValue.serverTimestamp() - }) - .catch(function(err) { - console.error("Error adding favorite: ", err); - // Rollback - userFavSlugs.delete(slug); - btn.classList.remove('active'); - }); - } + window.fcLocal.toggleFavorite(slug); + updateHeartStates(); } // Hook up event listeners to all favorite buttons dynamically @@ -206,18 +140,8 @@ document.addEventListener('click', function(e) { } }); -// Setup Auth state observer for favorites window.addEventListener('DOMContentLoaded', function() { - if (window.auth) { - window.auth.onAuthStateChanged(function(user) { - currentUser = user; - if (user) { - syncFavorites(user.uid); - } else { - clearFavorites(); - } - }); - } + updateHeartStates(); }); })(); @@ -228,10 +152,10 @@ window.addEventListener('DOMContentLoaded', function() {
❤️

-

+

- + diff --git a/partials/head.php b/partials/head.php index 68f9746..0636bb0 100644 --- a/partials/head.php +++ b/partials/head.php @@ -19,49 +19,7 @@ $description = $description ?? 'Seasonal recipes, tested tips, and approachable - - - - - - + diff --git a/partials/header.php b/partials/header.php index 3ce6e5b..e554769 100644 --- a/partials/header.php +++ b/partials/header.php @@ -58,8 +58,8 @@ $langParamAmp = isset($lang) && $lang === 'de' ? '&lang=de' : '';
  • > - - + +
  • diff --git a/recipe.php b/recipe.php index 2aeaca2..2b434b0 100644 --- a/recipe.php +++ b/recipe.php @@ -1,6 +1,12 @@ [ @@ -46,9 +52,8 @@ $copy = [ ]; $t = $copy[$lang]; -$allRecipes = load_recipes(); $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; $allRecipesLocal = localize_recipes($allRecipes, $lang); diff --git a/scripts/db-check.php b/scripts/db-check.php new file mode 100644 index 0000000..83869d3 --- /dev/null +++ b/scripts/db-check.php @@ -0,0 +1,45 @@ +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"; + } +} diff --git a/scripts/db-seed.php b/scripts/db-seed.php new file mode 100644 index 0000000..3d9184f --- /dev/null +++ b/scripts/db-seed.php @@ -0,0 +1,43 @@ +getMessage() . "\n"); + exit(1); +} + +$count = 0; +foreach ($recipes as $recipe) { + if (!is_array($recipe) || empty($recipe['slug'])) { + continue; + } + if (save_recipe($recipe, $pdo)) { + $count++; + echo " + {$recipe['slug']}\n"; + } else { + fwrite(STDERR, " ! Fehler bei {$recipe['slug']}\n"); + } +} + +echo "\nImport abgeschlossen: {$count} Rezepte.\n"; diff --git a/scripts/schema.sql b/scripts/schema.sql new file mode 100644 index 0000000..7aa5983 --- /dev/null +++ b/scripts/schema.sql @@ -0,0 +1,70 @@ +-- FlixCooks relational recipe schema (PostgreSQL) + +CREATE TABLE IF NOT EXISTS recipes ( + slug VARCHAR(255) PRIMARY KEY, + hero TEXT NOT NULL DEFAULT '', + prep_time INTEGER NOT NULL DEFAULT 0, + cook_time INTEGER NOT NULL DEFAULT 0, + total_time INTEGER NOT NULL DEFAULT 0, + servings INTEGER NOT NULL DEFAULT 2, + featured BOOLEAN NOT NULL DEFAULT FALSE, + coming_soon BOOLEAN NOT NULL DEFAULT FALSE, + calories INTEGER NOT NULL DEFAULT 0, + protein INTEGER NOT NULL DEFAULT 0, + carbs INTEGER NOT NULL DEFAULT 0, + fat INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS recipe_translations ( + recipe_slug VARCHAR(255) NOT NULL REFERENCES recipes(slug) ON DELETE CASCADE, + lang CHAR(2) NOT NULL CHECK (lang IN ('en', 'de')), + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL DEFAULT '', + difficulty TEXT NOT NULL DEFAULT '', + PRIMARY KEY (recipe_slug, lang) +); + +CREATE TABLE IF NOT EXISTS recipe_tags ( + id SERIAL PRIMARY KEY, + recipe_slug VARCHAR(255) NOT NULL REFERENCES recipes(slug) ON DELETE CASCADE, + lang CHAR(2) NOT NULL CHECK (lang IN ('en', 'de')), + tag TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_recipe_tags_slug_lang ON recipe_tags (recipe_slug, lang); + +CREATE TABLE IF NOT EXISTS recipe_ingredients ( + id SERIAL PRIMARY KEY, + recipe_slug VARCHAR(255) NOT NULL REFERENCES recipes(slug) ON DELETE CASCADE, + lang CHAR(2) NOT NULL CHECK (lang IN ('en', 'de')), + content TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_slug_lang ON recipe_ingredients (recipe_slug, lang); + +CREATE TABLE IF NOT EXISTS recipe_utensils ( + id SERIAL PRIMARY KEY, + recipe_slug VARCHAR(255) NOT NULL REFERENCES recipes(slug) ON DELETE CASCADE, + lang CHAR(2) NOT NULL CHECK (lang IN ('en', 'de')), + content TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_recipe_utensils_slug_lang ON recipe_utensils (recipe_slug, lang); + +CREATE TABLE IF NOT EXISTS recipe_steps ( + id SERIAL PRIMARY KEY, + recipe_slug VARCHAR(255) NOT NULL REFERENCES recipes(slug) ON DELETE CASCADE, + lang CHAR(2) NOT NULL CHECK (lang IN ('en', 'de')), + content TEXT NOT NULL DEFAULT '', + video_url TEXT NOT NULL DEFAULT '', + timer_minutes INTEGER, + sort_order INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_recipe_steps_slug_lang ON recipe_steps (recipe_slug, lang);