feat(auth-firebase): implement Phase 2 integration - Firebase Auth, Session Sync, Favorites Grid, and Firestore Newsletter

This commit is contained in:
2026-05-21 14:14:10 +02:00
parent 548c7605bc
commit 21833fa05b
15 changed files with 1532 additions and 40 deletions
+9
View File
@@ -0,0 +1,9 @@
# FlixCooks Firebase Configuration
# Replace these placeholder values with your actual Firebase project settings.
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"
+2 -1
View File
@@ -1 +1,2 @@
.idx/*
.idx/*
.env
+18 -18
View File
@@ -12,7 +12,7 @@ Dieses Dokument enthält den aktuellen Entwicklungsstand und detaillierte Aufgab
- Fullscreen Overlay-Navigation (`LiquidOverlayMenu` & `SlideUpTextHover`)
- Geschmeidiges Scrollverhalten (`LenisSmoothScroll` & `IndexSectionIndicator`)
- Layout-Raster (`FloemaLayoutGrid`) & Premium-Buttons
- [ ] **Phase 2: Authentifizierung & Firebase Integration**
- [x] **Phase 2: Authentifizierung & Firebase Integration**
- Firebase-Projekt Setup & Anbindung
- Newsletter-System (Firebase Firestore)
- Benutzerprofile (Registrierung, Login, Favoriten)
@@ -63,23 +63,23 @@ 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).
- [ ] **Firebase Setup & Initialisierung**
- [ ] Firebase SDK Client-Side Einbindung in `partials/head.php` oder separaten Helper
- [ ] Firebase Config in umweltabhängigen Settings/Variablen auslagern
- [ ] Firestore-Datenbank initialisieren (`subscribers` und `users` Collections)
- [ ] **Premium Login- & Registrierungsseite**
- [ ] Erstellung der Seite `login.php` (und optional `signup.php`) im neuen Design-System-Stil
- [ ] Firebase Authentication (Email/Passwort Login & Registrierung) implementieren
- [ ] Responsive UI mit sanften Error- & Success-Meldungen und Eingabefeldern im Glasmorphismus-Look
- [ ] Client-seitige und Server-seitige Session-Synchronisation (z.B. Firebase Session Tokens an PHP-Session via Ajax senden)
- [ ] **Favoriten-Funktion (Rezept-Bookmarks)**
- [ ] Firestore-Collection `user_favorites` anlegen (Format: `userId` -> Liste von `recipeSlugs`)
- [ ] "Rezept speichern" Button (Herz-Icon) auf den Rezeptkarten und Rezept-Details mit dynamischem Login-Check
- [ ] Mikro-Animationen für das Herz-Icon (Scale up/down, Fülleffekte mit `var(--ease-overshoot)`)
- [ ] **Firebase-gestütztes Newsletter-Abo**
- [ ] Newsletter-Eingabefeld im Footer (`partials/footer.php`) implementieren
- [ ] AJAX-Submit-Handler: E-Mail-Adresse prüfen und in Firestore Collection `subscribers` speichern
- [ ] Elegantes Feedback-UI (Erfolgsmeldung ohne Page-Reload)
- [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] **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)
---
+30
View File
@@ -204,6 +204,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
$comingSoon = isset($_POST['coming_soon']) && $_POST['coming_soon'] === '1';
$slugOriginal = trim($_POST['slug_original'] ?? '');
$nutriCal = (int) ($_POST['nutrition_calories'] ?? 0);
$nutriProt = (int) ($_POST['nutrition_protein'] ?? 0);
$nutriCarb = (int) ($_POST['nutrition_carbs'] ?? 0);
$nutriFat = (int) ($_POST['nutrition_fat'] ?? 0);
// keep previous language data if fields left blank while editing
$prevEn = $editing['i18n']['en'] ?? [];
$prevDe = $editing['i18n']['de'] ?? [];
@@ -261,6 +266,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
'servings' => $servings,
'featured' => $featured,
'coming_soon' => $comingSoon,
'nutrition' => [
'calories' => $nutriCal,
'protein' => $nutriProt,
'carbs' => $nutriCarb,
'fat' => $nutriFat
],
'i18n' => [
'en' => [
'title' => $titleEn,
@@ -517,6 +528,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
</div>
</div>
<div class="row">
<div>
<label>Calories (kcal)</label>
<input type="number" name="nutrition_calories" min="0" value="<?php echo e($editing['nutrition']['calories'] ?? 0); ?>">
</div>
<div>
<label>Protein (g)</label>
<input type="number" name="nutrition_protein" min="0" value="<?php echo e($editing['nutrition']['protein'] ?? 0); ?>">
</div>
<div>
<label>Carbs (g)</label>
<input type="number" name="nutrition_carbs" min="0" value="<?php echo e($editing['nutrition']['carbs'] ?? 0); ?>">
</div>
<div>
<label>Fat (g)</label>
<input type="number" name="nutrition_fat" min="0" value="<?php echo e($editing['nutrition']['fat'] ?? 0); ?>">
</div>
</div>
<hr>
<h3>English</h3>
<div class="row">
+40
View File
@@ -0,0 +1,40 @@
<?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']);
}
+336
View File
@@ -1735,3 +1735,339 @@ body.nav-open { overflow: hidden; }
.feature-grid { grid-template-columns: 1fr; }
.coming-features { margin-bottom: 40px; }
}
/* ╔══════════════════════════════════════════════════════════════╗
║ PHASE 2: AUTHENTICATION, FAVORITES & NEWSLETTER STYLES ║
╚══════════════════════════════════════════════════════════════╝ */
/* ── Premium Heart Buttons ── */
.card__image {
position: relative;
display: block;
}
.btn-favorite {
position: absolute;
top: 1rem;
right: 1rem;
width: 38px;
height: 38px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.3);
color: var(--muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
transition: background 0.3s ease, border-color 0.3s ease, transform 0.3s var(--ease-overshoot), color 0.3s ease;
}
[data-theme="dark"] .btn-favorite {
background: rgba(36, 31, 33, 0.7);
border-color: rgba(255, 255, 255, 0.05);
}
.btn-favorite:hover {
transform: scale(1.1);
background: rgba(255, 255, 255, 0.95);
color: var(--ink);
}
[data-theme="dark"] .btn-favorite:hover {
background: rgba(36, 31, 33, 0.95);
}
.btn-favorite svg {
width: 18px;
height: 18px;
transition: stroke 0.3s ease, fill 0.3s ease;
fill: none;
stroke: var(--ink);
}
.btn-favorite.active {
background: var(--surface-2);
border-color: rgba(195, 166, 119, 0.4);
}
.btn-favorite.active svg {
fill: var(--color-accent-gold);
stroke: var(--color-accent-gold);
animation: heartPop 0.45s var(--ease-overshoot) both;
}
/* For larger recipe layout title heart button */
.recipe__title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
margin-bottom: 0.5rem;
width: 100%;
}
.btn-favorite--large {
position: static;
width: 52px;
height: 52px;
background: var(--surface-2);
border: 1px solid var(--stroke);
flex-shrink: 0;
}
.btn-favorite--large svg {
width: 24px;
height: 24px;
}
@keyframes heartPop {
0% { transform: scale(1); }
50% { transform: scale(1.35); }
100% { transform: scale(1); }
}
/* ── Guest Favoriting Prompt Modal ── */
.guest-modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(11, 9, 10, 0.4);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.4s var(--ease-out-expo);
}
.guest-modal-backdrop.open {
opacity: 1;
pointer-events: auto;
}
.guest-modal-card {
background: var(--surface-2);
border: 1px solid var(--stroke);
box-shadow: var(--shadow);
border-radius: var(--radius);
width: 90%;
max-width: 440px;
padding: 2.5rem;
position: relative;
text-align: center;
transform: translateY(30px);
transition: transform 0.5s var(--ease-overshoot);
display: flex;
flex-direction: column;
align-items: center;
gap: 1.2rem;
}
.guest-modal-backdrop.open .guest-modal-card {
transform: translateY(0);
}
.guest-modal-close {
position: absolute;
top: 1rem;
right: 1.2rem;
background: none;
border: none;
font-size: 1.8rem;
color: var(--muted);
cursor: pointer;
transition: color 0.3s ease;
}
.guest-modal-close:hover {
color: var(--ink);
}
.guest-modal-icon {
font-size: 2.5rem;
line-height: 1;
}
.guest-modal-card h3 {
font-family: var(--font-serif-display);
font-size: 1.8rem;
margin: 0;
color: var(--ink);
}
.guest-modal-card p {
font-size: 0.95rem;
line-height: 1.6;
color: var(--muted);
margin: 0;
}
.guest-modal-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
width: 100%;
margin-top: 0.5rem;
}
.guest-modal-actions .btn-reveal {
width: 100%;
justify-content: center;
}
.guest-modal-actions button {
background: none;
border: none;
font-family: var(--font-sans-clean);
font-size: 0.85rem;
color: var(--muted);
cursor: pointer;
}
/* ── Footer Newsletter ── */
.footer-newsletter {
padding-bottom: 2.5rem;
border-bottom: 1px solid var(--stroke);
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
align-items: center;
margin-bottom: 1rem;
width: 100%;
}
@media (min-width: 768px) {
.footer-newsletter {
grid-template-columns: 1.2fr 1fr;
gap: 3rem;
}
}
.newsletter-content h3 {
font-family: var(--font-serif-display);
font-size: 1.75rem;
margin-bottom: 0.5rem;
color: var(--ink);
}
.newsletter-content p {
color: var(--muted);
font-size: 0.95rem;
line-height: 1.5;
margin: 0;
}
.newsletter-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
position: relative;
}
.newsletter-input-group {
display: flex;
border-bottom: 2px solid var(--stroke);
padding: 0.5rem 0;
transition: border-color 0.3s ease;
position: relative;
align-items: center;
}
.newsletter-input-group:focus-within {
border-color: var(--color-accent-gold);
}
.newsletter-input-group input {
flex: 1;
background: none;
border: none;
font-family: var(--font-sans-clean);
font-size: 1rem;
color: var(--ink);
outline: none;
padding: 0.5rem 0;
}
.newsletter-input-group input::placeholder {
color: var(--color-text-muted);
opacity: 0.7;
}
.newsletter-input-group button {
background: none;
border: none;
color: var(--color-accent-gold);
font-size: 1.5rem;
cursor: pointer;
padding: 0 0.5rem;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
position: relative;
transition: transform 0.3s var(--ease-overshoot), color 0.3s ease;
}
.newsletter-input-group button:hover {
transform: translateX(4px);
}
.newsletter-input-group button .btn-success-check {
display: none;
color: #10b981;
font-size: 1.2rem;
}
.newsletter-form.success .newsletter-input-group button .btn-text {
display: none;
}
.newsletter-form.success .newsletter-input-group button .btn-success-check {
display: block;
}
.newsletter-form.success .newsletter-input-group button {
transform: none !important;
cursor: default;
}
.newsletter-form.success .newsletter-input-group {
border-color: #10b981;
}
.newsletter-feedback {
font-size: 0.85rem;
margin-top: 0.25rem;
min-height: 1.25rem;
transition: color 0.3s ease;
}
.newsletter-feedback.error {
color: #e02424;
}
.newsletter-feedback.success {
color: #10b981;
}
/* ── 22. PHASE 3: NUTRITION WIDGET ───────────────────────────── */
.nutrition-widget {
margin-top: 1.5rem;
margin-bottom: 2rem;
gap: 1.5rem;
}
.nutrition-item {
flex: 1 1 calc(25% - 1.5rem);
min-width: 120px;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.nutrition-item__header {
display: flex;
justify-content: space-between;
align-items: baseline;
font-family: var(--font-sans-clean);
}
.nutrition-label {
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
}
.nutrition-value {
font-family: var(--font-serif-display);
font-size: 1.2rem;
font-weight: 700;
color: var(--ink);
}
.nutrition-bar {
width: 100%;
height: 4px;
background-color: var(--ghost);
border-radius: 2px;
overflow: hidden;
position: relative;
}
.nutrition-fill {
height: 100%;
background-color: var(--accent);
border-radius: 2px;
transition: width 1.5s var(--ease-out-expo) 0.2s;
will-change: width;
}
+46
View File
@@ -0,0 +1,46 @@
<?php
// Environment and Configuration Loader for FlixCooks
function load_env() {
$path = __DIR__ . '/.env';
if (!file_exists($path)) {
return;
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
// Skip comments
if (strpos(trim($line), '#') === 0) {
continue;
}
// Parse Name=Value
if (strpos($line, '=') !== false) {
list($name, $value) = explode('=', $line, 2);
$name = trim($name);
$value = trim($value);
// Remove wrapping quotes if present
$value = trim($value, '"\'');
// Populate getenv(), $_ENV, $_SERVER
putenv("{$name}={$value}");
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
}
}
}
// 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') ?: '',
];
}
+42 -18
View File
@@ -8,6 +8,12 @@
"servings": 2,
"featured": false,
"coming_soon": false,
"nutrition": {
"calories": 0,
"protein": 0,
"carbs": 0,
"fat": 0
},
"i18n": {
"en": {
"title": "Tagliatelle with tomato sauce",
@@ -36,16 +42,16 @@
"Optional: Blender"
],
"steps": [
"Pile the flour on your counter, press a well in the center, crack in the eggs. Gradually pull flour into the eggs, then knead 5\u201310 minutes until smooth.",
"Roll the dough to 1\u20132 mm thickness (rolling pin or pasta machine) and cut into tagliatelle.",
"Pile the flour on your counter, press a well in the center, crack in the eggs. Gradually pull flour into the eggs, then knead 510 minutes until smooth.",
"Roll the dough to 12 mm thickness (rolling pin or pasta machine) and cut into tagliatelle.",
"Bring salted water to a boil. Meanwhile, halve tomatoes and sear in olive oil with a pinch of salt until lightly charred.",
"Toast the garlic briefly, then blend tomatoes with cream (or crush in the pan) and simmer to thicken slightly.",
"Boil tagliatelle for 2\u20133 minutes, drain, toss with the sauce, and finish with basil and Parmesan."
"Boil tagliatelle for 23 minutes, drain, toss with the sauce, and finish with basil and Parmesan."
]
},
"de": {
"title": "Tagliatelle mit Tomatensauce",
"description": "Einfach, aber richtig lecker; kurze Kochzeit und trotzdem hausgemacht. Das Basisgericht f\u00fcr einen gem\u00fctlichen Abend, stillt den Kohlenhydrat-Hunger ohne versteckte Zus\u00e4tze. Nudeln mit Tomatensauce \u2013 herzhafter geht es kaum.",
"description": "Einfach, aber richtig lecker; kurze Kochzeit und trotzdem hausgemacht. Das Basisgericht für einen gemütlichen Abend, stillt den Kohlenhydrat-Hunger ohne versteckte Zusätze. Nudeln mit Tomatensauce herzhafter geht es kaum.",
"category": "Pasta",
"difficulty": "Einfach",
"tags": [
@@ -58,7 +64,7 @@
"3 Eier",
"10 Cherry-Tomaten",
"1/2 Knoblauchzehe",
"1 EL Oliven\u00f6l",
"1 EL Olivenöl",
"80 ml Sahne",
"Frischer Basilikum",
"Parmesan",
@@ -70,11 +76,11 @@
"Optional: Mixer"
],
"steps": [
"Mehl auf der Arbeitsfl\u00e4che anh\u00e4ufen, eine Kuhle dr\u00fccken, Eier hineingeben. Mehl nach und nach einarbeiten, dann 5\u201310 Minuten kneten, bis der Teig glatt ist.",
"Teig auf 1\u20132 mm ausrollen (mit Nudelholz oder Nudelmaschine) und in Tagliatelle schneiden.",
"Gesalzenes Wasser aufsetzen; w\u00e4hrenddessen Tomaten halbieren und mit Oliven\u00f6l und einer Prise Salz in der Pfanne anr\u00f6sten, bis R\u00f6stnoten entstehen.",
"Knoblauch kurz mitr\u00f6sten, dann Tomaten mit Sahne p\u00fcrieren (Mixer) oder in der Pfanne zerdr\u00fccken und kurz einkochen lassen.",
"Tagliatelle 2\u20133 Minuten kochen, abgie\u00dfen und mit der Sauce vermengen. Mit Basilikum und Parmesan anrichten."
"Mehl auf der Arbeitsfläche anhäufen, eine Kuhle drücken, Eier hineingeben. Mehl nach und nach einarbeiten, dann 510 Minuten kneten, bis der Teig glatt ist.",
"Teig auf 12 mm ausrollen (mit Nudelholz oder Nudelmaschine) und in Tagliatelle schneiden.",
"Gesalzenes Wasser aufsetzen; währenddessen Tomaten halbieren und mit Olivenöl und einer Prise Salz in der Pfanne anrösten, bis Röstnoten entstehen.",
"Knoblauch kurz mitrösten, dann Tomaten mit Sahne pürieren (Mixer) oder in der Pfanne zerdrücken und kurz einkochen lassen.",
"Tagliatelle 23 Minuten kochen, abgießen und mit der Sauce vermengen. Mit Basilikum und Parmesan anrichten."
]
}
}
@@ -88,6 +94,12 @@
"servings": 2,
"featured": false,
"coming_soon": true,
"nutrition": {
"calories": 0,
"protein": 0,
"carbs": 0,
"fat": 0
},
"i18n": {
"en": {
"title": "Oat-Pancakes",
@@ -120,10 +132,16 @@
"servings": 2,
"featured": true,
"coming_soon": false,
"nutrition": {
"calories": 0,
"protein": 0,
"carbs": 0,
"fat": 0
},
"i18n": {
"en": {
"title": "Steak with onion jam",
"description": "A hearty meal after a long day, best enjoyed with a glass of red wine and in good company\u2014because the onion jam still isn\u2019t satisfied on its own.",
"description": "A hearty meal after a long day, best enjoyed with a glass of red wine and in good companybecause the onion jam still isnt satisfied on its own.",
"category": "",
"difficulty": "Easy",
"tags": [
@@ -157,14 +175,14 @@
"Dice the onion",
"Cook the potatoes and carrots until al dente",
"Sear the steak with butter",
"Once both sides are golden brown, bake for 6 minutes at 160\u00b0C (medium); for medium rare, let it rest after searing both sides for 3 minutes",
"Saut\u00e9 the onion in the steak butter, deglaze with red wine, and add cream once the alcohol has evaporated",
"Once both sides are golden brown, bake for 6 minutes at 160°C (medium); for medium rare, let it rest after searing both sides for 3 minutes",
"Sauté the onion in the steak butter, deglaze with red wine, and add cream once the alcohol has evaporated",
"Plate and serve!"
]
},
"de": {
"title": "Steak mit Zwiebelmarmelade",
"description": "Eine schwere Mahlzeit nach einem langen Tag, am besten zu einem Schluck Rotwein und in Gesellschaft genie\u00dfen, da die Zwiebelmarmelade noch nicht genug davon hat.",
"description": "Eine schwere Mahlzeit nach einem langen Tag, am besten zu einem Schluck Rotwein und in Gesellschaft genießen, da die Zwiebelmarmelade noch nicht genug davon hat.",
"category": "",
"difficulty": "Einfach",
"tags": [
@@ -174,7 +192,7 @@
"ingredients": [
"2 x 250g Rumpsteak",
"300gr Kartoffeln",
"1 Gro\u00dfe Rote Zwiebel",
"1 Große Rote Zwiebel",
"1/2 Knoblauch-Zehe",
"50ml Sahne",
"50ml Rotwein",
@@ -195,11 +213,11 @@
"Steak 30 Minuten vor dem anbraten raus legen",
"Kartoffeln halbieren",
"Karotten halbieren",
"Zwiebel w\u00fcrfeln",
"Zwiebel würfeln",
"Kartoffeln und Karotten al dente kochen",
"Steak anbraten mit Butter",
"sobald beide seiten goldbraun sind f\u00fcr 6 Minuten bei 160grad backen (Medium) f\u00fcr Medium Rare nur ruhen lassen nachdem es von beiden Seiten f\u00fcr 3 Minuten angebraten wurde",
"Zwiebel in Steak-Butter anschwitzen, mit Rotwein abl\u00f6schen und Sahne dazu geben nachdem er Alkohol verd\u00fcnstet ist",
"sobald beide seiten goldbraun sind für 6 Minuten bei 160grad backen (Medium) für Medium Rare nur ruhen lassen nachdem es von beiden Seiten für 3 Minuten angebraten wurde",
"Zwiebel in Steak-Butter anschwitzen, mit Rotwein ablöschen und Sahne dazu geben nachdem er Alkohol verdünstet ist",
"Anrichten!"
]
}
@@ -214,6 +232,12 @@
"servings": 2,
"featured": false,
"coming_soon": true,
"nutrition": {
"calories": 0,
"protein": 0,
"carbs": 0,
"fat": 0
},
"i18n": {
"en": {
"title": "Oat cake",
+6
View File
@@ -1,6 +1,12 @@
<?php
// Shared helper functions for the FlixCooks food blog.
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once __DIR__ . '/config.php';
function load_recipes(): array {
$path = __DIR__ . '/data/recipes.json';
if (!file_exists($path)) {
+13 -1
View File
@@ -398,7 +398,14 @@ include __DIR__ . '/partials/head.php';
<img src="<?php echo e($featured['hero']); ?>" alt="<?php echo e($featured['title']); ?>">
<div class="hero-card__body">
<p class="pill pill--gold"><?php echo e($t['featured']); ?></p>
<h3><?php echo e($featured['title']); ?></h3>
<div class="recipe__title-row">
<h3><?php echo e($featured['title']); ?></h3>
<button class="btn-favorite" data-slug="<?php echo e($featured['slug']); ?>" aria-label="Save to favorites">
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
</button>
</div>
<p><?php echo e($featured['description']); ?></p>
<div class="meta">
<span>⏱ <?php echo format_minutes((int)($featured['total_time'] ?? 0)); ?></span>
@@ -430,6 +437,11 @@ include __DIR__ . '/partials/head.php';
<?php if (!empty($recipe['category'])): ?>
<span class="pill pill--ghost"><?php echo e($recipe['category']); ?></span>
<?php endif; ?>
<button class="btn-favorite" data-slug="<?php echo e($recipe['slug']); ?>" aria-label="Save to favorites" onclick="event.preventDefault(); event.stopPropagation();">
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
</button>
</a>
<div class="card__body">
<h3><a href="<?php echo e($recipeUrl($recipe)); ?>"><?php echo e($recipe['title']); ?></a></h3>
+682
View File
@@ -0,0 +1,682 @@
<?php
require __DIR__ . '/helpers.php';
$lang = (isset($_GET['lang']) && strtolower($_GET['lang']) === 'de') ? 'de' : 'en';
$copy = [
'en' => [
'page_title' => 'My Profile & Favorites | FlixCooks',
'meta_desc' => 'Manage your profile, dietary goals, and saved seasonal recipes on FlixCooks.',
'login' => 'Login',
'signup' => 'Register',
'email' => 'Email Address',
'password' => 'Password',
'confirm_pass' => 'Confirm Password',
'goal' => 'Dietary Goal',
'goal_placeholder' => 'Select your dietary goal',
'goal_loose' => 'Weight Loss / Low-Carb',
'goal_gain' => 'Muscle Gain / High-Protein',
'goal_healthy' => 'Healthy & Balanced',
'no_account' => "Don't have an account yet?",
'have_account' => 'Already have an account?',
'welcome' => 'Welcome back,',
'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.',
],
'de' => [
'page_title' => 'Mein Profil & Favoriten | FlixCooks',
'meta_desc' => 'Verwalte dein Profil, deine Ernährungsziele und gespeicherten Rezepte auf FlixCooks.',
'login' => 'Anmelden',
'signup' => 'Registrieren',
'email' => 'E-Mail-Adresse',
'password' => 'Passwort',
'confirm_pass' => 'Passwort bestätigen',
'goal' => 'Ernährungsziel',
'goal_placeholder' => 'Wähle dein Ernährungsziel',
'goal_loose' => 'Abnehmen / Low-Carb',
'goal_gain' => 'Muskelaufbau / High-Protein',
'goal_healthy' => 'Gesund & Ausgewogen',
'no_account' => "Noch kein Konto?",
'have_account' => 'Bereits registriert?',
'welcome' => 'Willkommen zurück,',
'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.',
]
];
$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
$recipesJson = json_encode(array_values(array_map(function($r) use ($lang) {
return [
'slug' => $r['slug'],
'title' => $r['title'],
'hero' => $r['hero'],
'difficulty' => $r['difficulty'],
'total_time' => (int)($r['total_time'] ?? 0),
'url' => '/recipe.php?slug=' . urlencode($r['slug']) . '&lang=' . $lang
];
}, $allRecipesLocal)));
include __DIR__ . '/partials/head.php';
include __DIR__ . '/partials/header.php';
?>
<style>
/* ── Premium Authentication Page Styling ── */
.auth-page {
padding: clamp(6rem, 10vh, 10rem) var(--grid-margin) clamp(4rem, 8vh, 6rem);
min-height: 80vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.auth-card {
width: 100%;
max-width: 500px;
background: var(--surface-2);
border: 1px solid var(--stroke);
box-shadow: var(--shadow);
border-radius: var(--radius);
padding: clamp(2rem, 5vw, 3.5rem);
position: relative;
overflow: hidden;
transition: transform 0.4s var(--ease-out-expo), opacity 0.4s ease;
}
.auth-tabs {
display: flex;
justify-content: center;
gap: 2rem;
margin-bottom: 2.5rem;
border-bottom: 1px solid var(--stroke);
padding-bottom: 0.8rem;
}
.auth-tab {
font-family: var(--font-serif-display);
font-size: 1.5rem;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
padding: 0;
position: relative;
transition: color 0.3s ease;
}
.auth-tab.active {
color: var(--ink);
}
.auth-tab::after {
content: '';
position: absolute;
bottom: -0.9rem;
left: 0;
width: 100%;
height: 2px;
background-color: var(--color-accent-gold);
transform: scaleX(0);
transform-origin: center;
transition: transform 0.4s var(--ease-out-expo);
}
.auth-tab.active::after {
transform: scaleX(1);
}
.auth-form {
display: none;
flex-direction: column;
gap: 1.5rem;
}
.auth-form.active {
display: flex;
animation: authFadeIn 0.5s var(--ease-out-expo) both;
}
@keyframes authFadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.auth-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.auth-field label {
font-family: var(--font-sans-clean);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 700;
color: var(--muted);
}
.auth-field input, .auth-field select {
padding: 1rem 1.25rem;
border-radius: 10px;
border: 1px solid var(--stroke);
background: var(--surface);
color: var(--ink);
font-family: var(--font-sans-clean);
font-size: 0.95rem;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
outline: none;
}
.auth-field input:focus, .auth-field select:focus {
border-color: var(--color-accent-gold);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.auth-btn-row {
margin-top: 1rem;
}
.auth-btn-row button {
width: 100%;
justify-content: center;
}
.auth-switch-text {
text-align: center;
font-size: 0.9rem;
color: var(--muted);
margin-top: 1.5rem;
}
.auth-switch-text button {
background: none;
border: none;
color: var(--ink);
font-weight: 700;
cursor: pointer;
}
/* ── Profile & Dashboard Styling ── */
.profile-shell {
width: 100%;
max-width: 1100px;
display: grid;
grid-template-columns: 1fr;
gap: 3rem;
}
@media (min-width: 850px) {
.profile-shell {
grid-template-columns: 320px 1fr;
}
}
.profile-sidebar {
background: var(--surface-2);
border: 1px solid var(--stroke);
border-radius: var(--radius);
padding: 2.5rem;
display: flex;
flex-direction: column;
gap: 2rem;
height: fit-content;
box-shadow: var(--shadow);
}
.profile-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--accent-soft);
color: var(--color-accent-gold);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-serif-display);
font-size: 2.5rem;
border: 1px solid rgba(195,166,119,0.3);
}
.profile-meta h2 {
font-size: 1.75rem;
margin-bottom: 0.25rem;
font-family: var(--font-serif-display);
}
.profile-meta p {
font-size: 0.9rem;
color: var(--muted);
}
.profile-details {
border-top: 1px solid var(--stroke);
padding-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.profile-stat-box {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.profile-stat-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 700;
color: var(--muted);
}
.profile-stat-val {
font-size: 1rem;
font-weight: 600;
color: var(--ink);
}
.profile-main {
display: flex;
flex-direction: column;
gap: 2rem;
}
.profile-favorites-title {
font-family: var(--font-serif-display);
font-size: 2rem;
border-bottom: 1px solid var(--stroke);
padding-bottom: 0.8rem;
margin-bottom: 1rem;
}
.favorites-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--grid-gap);
}
.favorites-empty {
text-align: center;
padding: 4rem 2rem;
background: var(--surface-2);
border: 1px dashed var(--stroke);
border-radius: var(--radius);
color: var(--muted);
}
/* Alert styling */
.auth-alert {
background: #fdf2f2;
border: 1px solid #fbd5d5;
color: #9b1c1c;
padding: 1rem;
border-radius: 8px;
font-size: 0.9rem;
margin-bottom: 1.5rem;
display: none;
animation: authFadeIn 0.3s ease;
}
[data-theme="dark"] .auth-alert {
background: #2b1515;
border-color: #5a1818;
color: #ff9b9b;
}
/* Loading state spinner */
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 0.8s linear infinite;
display: none;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.btn-reveal:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.btn-reveal:disabled .spinner {
display: inline-block;
}
.btn-reveal:disabled .btn-reveal-text {
display: none;
}
</style>
<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'; ?>;">
<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>
<div class="profile-details">
<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">
<h3 class="profile-favorites-title"><?php echo $t['saved_recipes']; ?></h3>
<div id="favoritesGrid" class="favorites-grid">
<!-- JS inserts favorited recipe cards here -->
<div class="spinner" style="display: block; margin: 4rem auto; border-top-color: var(--color-accent-gold);"></div>
</div>
<div id="favoritesEmpty" class="favorites-empty" style="display: none;">
<p><?php echo $t['no_favorites']; ?></p>
<a class="btn-reveal" href="/index.php?lang=<?php echo $lang; ?>#recipes-start" style="margin-top: 1.5rem;">
<span class="btn-reveal-text"><?php echo $lang === 'de' ? 'Entdecken' : 'Explore Recipes'; ?></span>
<span class="btn-reveal-arrow">→</span>
</a>
</div>
</section>
</div>
</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 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) {
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);
});
}
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');
alertEl.style.display = 'none';
if (pass !== confirm) {
showAlert(langText.err_pass_match);
return;
}
btn.disabled = true;
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';
}
});
});
</script>
<?php include __DIR__ . '/partials/footer.php'; ?>
+194
View File
@@ -5,6 +5,23 @@
$contactEmail = $settings['imprint'][$langCode]['email'] ?? 'contact@flixcooks.at';
?>
<footer class="site-footer" role="contentinfo">
<section class="footer-newsletter">
<div class="newsletter-content">
<h3><?php echo $langCode === 'de' ? 'Bleib inspiriert' : 'Stay Inspired'; ?></h3>
<p><?php echo $langCode === 'de' ? 'Melde dich für unseren Newsletter an und erhalte saisonale Rezepte direkt in dein Postfach.' : 'Sign up for our newsletter and get seasonal recipes delivered straight to your inbox.'; ?></p>
</div>
<form class="newsletter-form" id="newsletterForm" onsubmit="handleSubscribe(event)">
<div class="newsletter-input-group">
<input type="email" id="newsletterEmail" placeholder="<?php echo $langCode === 'de' ? 'Deine E-Mail-Adresse...' : 'Your email address...'; ?>" required>
<button type="submit" aria-label="Subscribe">
<span class="btn-text">→</span>
<span class="btn-success-check">✓</span>
</button>
</div>
<div class="newsletter-feedback" id="newsletterFeedback"></div>
</form>
</section>
<div class="footer-meta">
<p><?php echo $langCode === 'de' ? 'Designed &amp; entwickelt von Felix Brockers' : 'Designed &amp; built by Felix Brockers'; ?></p>
<a class="contact-link underline-link" href="mailto:<?php echo e($contactEmail); ?>">
@@ -44,6 +61,183 @@
})();
// ── Newsletter subscription logic ──────────────────────────
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 = '';
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;
});
}
// ── Guest Modal Logic ──────────────────────────────────────
function openGuestModal() {
var modal = document.getElementById('guestFavModal');
if (modal) {
modal.classList.add('open');
modal.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
}
}
function closeGuestModal() {
var modal = document.getElementById('guestFavModal');
if (modal) {
modal.classList.remove('open');
modal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
}
}
// Close guest modal if clicking backdrop
document.addEventListener('click', function(e) {
var modal = document.getElementById('guestFavModal');
if (modal && e.target === modal) {
closeGuestModal();
}
});
// ── 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();
}
function updateHeartStates() {
document.querySelectorAll('.btn-favorite').forEach(function(btn) {
var slug = btn.getAttribute('data-slug');
if (userFavSlugs.has(slug)) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
function toggleFavorite(slug, btn) {
if (!currentUser) {
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');
});
}
}
// Hook up event listeners to all favorite buttons dynamically
document.addEventListener('click', function(e) {
var btn = e.target.closest('.btn-favorite');
if (btn) {
var slug = btn.getAttribute('data-slug');
if (slug) {
toggleFavorite(slug, btn);
}
}
});
// 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();
}
});
}
});
})();
</script>
<!-- Guest Favoriting Prompt Modal -->
<div class="guest-modal-backdrop" id="guestFavModal" aria-hidden="true" role="dialog">
<div class="guest-modal-card">
<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>
<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-arrow">→</span>
</a>
<button class="underline-link" onclick="closeGuestModal()"><?php echo $langCode === 'de' ? 'Später' : 'Maybe later'; ?></button>
</div>
</div>
</div>
</body>
</html>
+43
View File
@@ -20,6 +20,49 @@ $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>
<!-- GSAP + ScrollTrigger (CDN) -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
+8
View File
@@ -55,6 +55,14 @@ $langParamAmp = isset($lang) && $lang === 'de' ? '&lang=de' : '';
</span>
</a>
</li>
<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>
</a>
</li>
</ul>
</nav>
+63 -2
View File
@@ -7,6 +7,11 @@ $copy = [
'not_found' => 'Recipe not found',
'not_found_sub' => 'Try searching for something else.',
'back_home' => '← Back to all recipes',
'nutrition' => 'Nutrition',
'calories' => 'Calories',
'protein' => 'Protein',
'carbs' => 'Carbs',
'fat' => 'Fat',
'ingredients' => 'Ingredients',
'utensils' => 'Utensils',
'steps' => 'Steps',
@@ -22,6 +27,11 @@ $copy = [
'not_found' => 'Rezept nicht gefunden',
'not_found_sub' => 'Versuche eine andere Suche.',
'back_home' => '← Zurück zu allen Rezepten',
'nutrition' => 'Nährwerte',
'calories' => 'Kalorien',
'protein' => 'Protein',
'carbs' => 'Kohlenhydrate',
'fat' => 'Fett',
'ingredients' => 'Zutaten',
'utensils' => 'Küchenwerkzeug',
'steps' => 'Schritte',
@@ -78,7 +88,14 @@ include __DIR__ . '/partials/header.php';
<?php if (!empty($recipe['category'])): ?>
<p class="pill pill--gold"><?php echo e($recipe['category']); ?></p>
<?php endif; ?>
<h1><?php echo e($recipe['title']); ?></h1>
<div class="recipe__title-row">
<h1><?php echo e($recipe['title']); ?></h1>
<button class="btn-favorite btn-favorite--large" data-slug="<?php echo e($recipe['slug']); ?>" aria-label="Save to favorites">
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
</button>
</div>
<p class="lede"><?php echo e($recipe['description']); ?></p>
<div class="meta meta--row">
<span><?php echo e($t['prep']); ?>: <?php echo format_minutes((int)$recipe['prep_time']); ?></span>
@@ -95,6 +112,35 @@ include __DIR__ . '/partials/header.php';
</div>
<div class="recipe__body">
<?php if (isset($recipe['nutrition']) && ($recipe['nutrition']['calories'] > 0 || $recipe['nutrition']['protein'] > 0 || $recipe['nutrition']['carbs'] > 0 || $recipe['nutrition']['fat'] > 0)): ?>
<section class="panel reveal-target" id="nutrition-panel">
<h2><?php echo e($t['nutrition']); ?></h2>
<div class="g-row nutrition-widget">
<?php
$nutri = $recipe['nutrition'];
$macros = [
['label' => $t['calories'], 'value' => $nutri['calories'], 'unit' => 'kcal', 'pct' => min(100, round(($nutri['calories']/2000)*100))],
['label' => $t['protein'], 'value' => $nutri['protein'], 'unit' => 'g', 'pct' => min(100, round(($nutri['protein']/50)*100))],
['label' => $t['carbs'], 'value' => $nutri['carbs'], 'unit' => 'g', 'pct' => min(100, round(($nutri['carbs']/260)*100))],
['label' => $t['fat'], 'value' => $nutri['fat'], 'unit' => 'g', 'pct' => min(100, round(($nutri['fat']/70)*100))]
];
?>
<?php foreach ($macros as $m): ?>
<div class="g-col nutrition-item">
<div class="nutrition-item__header">
<span class="nutrition-label"><?php echo e($m['label']); ?></span>
<span class="nutrition-value"><?php echo e($m['value']); ?><?php echo $m['unit']; ?></span>
</div>
<div class="nutrition-bar">
<div class="nutrition-fill" data-width="<?php echo $m['pct']; ?>%" style="width: 0;"></div>
</div>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<section class="panel">
<h2><?php echo e($t['ingredients']); ?></h2>
<ul class="ingredients">
@@ -136,6 +182,11 @@ include __DIR__ . '/partials/header.php';
<article class="card card--bare reveal-target">
<a href="/recipe.php?<?php echo http_build_query(['slug' => $other['slug'], 'lang' => $lang]); ?>" class="card__image">
<img src="<?php echo e($other['hero']); ?>" alt="<?php echo e($other['title']); ?>" loading="lazy">
<button class="btn-favorite" data-slug="<?php echo e($other['slug']); ?>" aria-label="Save to favorites" onclick="event.preventDefault(); event.stopPropagation();">
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
</button>
</a>
<div class="card__body">
<h4><a href="/recipe.php?<?php echo http_build_query(['slug' => $other['slug'], 'lang' => $lang]); ?>"><?php echo e($other['title']); ?></a></h4>
@@ -158,7 +209,17 @@ include __DIR__ . '/partials/header.php';
if ('IntersectionObserver' in window) {
var obs = new IntersectionObserver(function(entries) {
entries.forEach(function(e) {
if (e.isIntersecting) { e.target.classList.add('revealed'); obs.unobserve(e.target); }
if (e.isIntersecting) {
e.target.classList.add('revealed');
obs.unobserve(e.target);
// Trigger nutrition bar animations if this is the nutrition panel
if (e.target.id === 'nutrition-panel') {
e.target.querySelectorAll('.nutrition-fill').forEach(function(fill) {
fill.style.width = fill.getAttribute('data-width');
});
}
}
});
}, { threshold: 0.08 });
document.querySelectorAll('.reveal-target').forEach(function(el) { obs.observe(el); });