Merge pull request #9 from LordSchmackes/dev
Dev
This commit was merged in pull request #9.
This commit is contained in:
+32
-25
@@ -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
|
||||
|
||||
+119
-25
@@ -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()` |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
* text=auto eol=lf
|
||||
*.sh text eol=lf
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
?>
|
||||
<!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 (hash_equals($ADMIN_KEY, $_POST['password'])) {
|
||||
$_SESSION['fc_admin'] = true;
|
||||
@@ -49,7 +76,11 @@ if (!$authed) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$allRecipes = load_recipes();
|
||||
try {
|
||||
$allRecipes = load_recipes();
|
||||
} catch (DatabaseUnavailableException $e) {
|
||||
handle_database_unavailable($e);
|
||||
}
|
||||
$siteSettings = load_site_settings();
|
||||
$message = null;
|
||||
$errors = [];
|
||||
@@ -165,12 +196,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete') {
|
||||
$slugDel = trim($_POST['slug'] ?? '');
|
||||
$before = count($allRecipes);
|
||||
$allRecipes = array_values(array_filter($allRecipes, fn($r) => ($r['slug'] ?? '') !== $slugDel));
|
||||
if ($before !== count($allRecipes) && save_recipes($allRecipes)) {
|
||||
if ($slugDel !== '' && delete_recipe($slugDel)) {
|
||||
$message = 'Recipe deleted.';
|
||||
$allRecipes = load_recipes();
|
||||
} else {
|
||||
$message = 'Could not delete. Check permissions.';
|
||||
$message = 'Could not delete recipe.';
|
||||
}
|
||||
$editing = null;
|
||||
$editIndex = null;
|
||||
@@ -259,13 +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.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<?php
|
||||
require __DIR__ . '/helpers.php';
|
||||
|
||||
try {
|
||||
$allRecipes = load_recipes();
|
||||
} catch (DatabaseUnavailableException $e) {
|
||||
handle_database_unavailable($e);
|
||||
}
|
||||
|
||||
// Language & inputs
|
||||
$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) ?? ''));
|
||||
@@ -84,7 +90,6 @@ $copy = [
|
||||
$t = $copy[$lang];
|
||||
|
||||
// Data
|
||||
$allRecipes = load_recipes();
|
||||
$localizedAll = localize_recipes($allRecipes, $lang);
|
||||
$recipes = filter_recipes($allRecipes, $q ?: null, $tag ?: null, $lang);
|
||||
$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
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<?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';
|
||||
|
||||
$copy = [
|
||||
@@ -23,9 +29,9 @@ $copy = [
|
||||
'your_goal' => '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';
|
||||
<main class="auth-page">
|
||||
<div class="auth-alert" id="authAlert"></div>
|
||||
|
||||
<!-- ── 1. UNAUTHENTICATED STATE (Login/Register Card) ── -->
|
||||
<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'; ?>;">
|
||||
<div class="profile-shell" id="profileShell" style="display: grid;">
|
||||
<aside class="profile-sidebar">
|
||||
<div style="display: flex; align-items: center; gap: 1.5rem;">
|
||||
<div class="profile-avatar" id="avatarChar">
|
||||
<?php echo $user ? strtoupper(substr($user['email'], 0, 1)) : ''; ?>
|
||||
</div>
|
||||
<div class="profile-meta">
|
||||
<p><?php echo $t['welcome']; ?></p>
|
||||
<h2 id="profileEmailVal"><?php echo $user ? e($user['email']) : ''; ?></h2>
|
||||
</div>
|
||||
<div class="profile-meta">
|
||||
<p><?php echo $t['welcome']; ?></p>
|
||||
<p style="font-size: 0.9rem; color: var(--muted); margin-top: 0.5rem;"><?php echo e($t['local_note']); ?></p>
|
||||
</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">
|
||||
<span class="profile-stat-label"><?php echo $t['your_goal']; ?></span>
|
||||
<span class="profile-stat-val" id="profileGoalVal">-</span>
|
||||
</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>
|
||||
|
||||
<section class="profile-main">
|
||||
@@ -487,195 +431,81 @@ include __DIR__ . '/partials/header.php';
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Expose recipe bank to JavaScript
|
||||
const recipeBank = <?php echo $recipesJson; ?>;
|
||||
const langText = <?php echo json_encode($t); ?>;
|
||||
|
||||
function switchTab(tab) {
|
||||
const tabs = document.querySelectorAll('.auth-tab');
|
||||
const forms = document.querySelectorAll('.auth-form');
|
||||
const alertEl = document.getElementById('authAlert');
|
||||
alertEl.style.display = 'none';
|
||||
|
||||
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 goalLabel(goal) {
|
||||
if (goal === 'weight_loss') return langText.goal_loose;
|
||||
if (goal === 'muscle_gain') return langText.goal_gain;
|
||||
if (goal === 'healthy') return langText.goal_healthy;
|
||||
return '-';
|
||||
}
|
||||
|
||||
function showAlert(message) {
|
||||
const alertEl = document.getElementById('authAlert');
|
||||
alertEl.textContent = message;
|
||||
alertEl.style.display = 'block';
|
||||
// Scroll to alert smoothly
|
||||
window.scrollTo({ top: alertEl.offsetTop - 120, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function handleLogin(e) {
|
||||
function handleSaveGoal(e) {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById('loginEmail').value;
|
||||
const pass = document.getElementById('loginPassword').value;
|
||||
const btn = e.target.querySelector('button[type="submit"]');
|
||||
const alertEl = document.getElementById('authAlert');
|
||||
|
||||
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);
|
||||
});
|
||||
if (!window.fcLocal) return;
|
||||
const goal = document.getElementById('profileGoal').value;
|
||||
window.fcLocal.setGoal(goal);
|
||||
document.getElementById('profileGoalVal').textContent = goalLabel(goal);
|
||||
showAlert(langText.goal_saved);
|
||||
}
|
||||
|
||||
function handleSignup(e) {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById('signupEmail').value;
|
||||
const pass = document.getElementById('signupPassword').value;
|
||||
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');
|
||||
function renderFavorites() {
|
||||
const grid = document.getElementById('favoritesGrid');
|
||||
const emptyState = document.getElementById('favoritesEmpty');
|
||||
if (!window.fcLocal) return;
|
||||
|
||||
alertEl.style.display = 'none';
|
||||
const slugs = window.fcLocal.getFavorites();
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (pass !== confirm) {
|
||||
showAlert(langText.err_pass_match);
|
||||
if (slugs.length === 0) {
|
||||
grid.style.display = 'none';
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
emptyState.style.display = 'none';
|
||||
grid.style.display = 'grid';
|
||||
|
||||
window.auth.createUserWithEmailAndPassword(email, pass)
|
||||
.then(userCredential => {
|
||||
const user = userCredential.user;
|
||||
// Save profile details to Firestore
|
||||
return window.db.collection('users').doc(user.uid).set({
|
||||
email: email,
|
||||
goal: goal,
|
||||
createdAt: firebase.firestore.FieldValue.serverTimestamp()
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
// Sync is handled by observer in head.php!
|
||||
})
|
||||
.catch(error => {
|
||||
btn.disabled = false;
|
||||
showAlert(error.message);
|
||||
});
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
slugs.forEach(function(slug) {
|
||||
const recipe = recipeBank.find(function(r) { return r.slug === slug; });
|
||||
if (!recipe) return;
|
||||
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 (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>
|
||||
|
||||
|
||||
@@ -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
@@ -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:<?php echo e($contactEmail); ?>?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 = '<?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;
|
||||
});
|
||||
form.classList.add('success');
|
||||
feedback.className = 'newsletter-feedback success';
|
||||
feedback.textContent = '<?php echo $langCode === "de" ? "E-Mail-Programm geöffnet – bitte absenden." : "Email client opened — please send."; ?>';
|
||||
}
|
||||
|
||||
// ── 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();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -228,10 +152,10 @@ window.addEventListener('DOMContentLoaded', function() {
|
||||
<button class="guest-modal-close" onclick="closeGuestModal()" aria-label="Close modal">×</button>
|
||||
<div class="guest-modal-icon">❤️</div>
|
||||
<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">
|
||||
<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>
|
||||
</a>
|
||||
<button class="underline-link" onclick="closeGuestModal()"><?php echo $langCode === 'de' ? 'Später' : 'Maybe later'; ?></button>
|
||||
|
||||
+1
-43
@@ -19,49 +19,7 @@ $description = $description ?? 'Seasonal recipes, tested tips, and approachable
|
||||
|
||||
<!-- Main stylesheet -->
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
|
||||
<!-- 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>
|
||||
<script src="/assets/fc-local.js" defer></script>
|
||||
|
||||
<!-- GSAP + ScrollTrigger (CDN) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||
|
||||
+2
-2
@@ -58,8 +58,8 @@ $langParamAmp = isset($lang) && $lang === 'de' ? '&lang=de' : '';
|
||||
<li>
|
||||
<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-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-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-main"><?php echo isset($lang) && $lang === 'de' ? 'Profil' : 'Profile'; ?></span>
|
||||
<span class="slide-text-clone" aria-hidden="true"><?php echo isset($lang) && $lang === 'de' ? 'Profil' : 'Profile'; ?></span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
+7
-2
@@ -1,6 +1,12 @@
|
||||
<?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';
|
||||
$copy = [
|
||||
'en' => [
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user