Stable Website before landingpage

This commit is contained in:
2026-03-22 22:16:44 +01:00
parent 7e0818aece
commit 9049e7cc58
23 changed files with 2552 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
#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 "REWnqLYwQtZZDgXcNxnt"
+5
View File
@@ -0,0 +1,5 @@
## Workflow
- After every chat response that includes code or content changes, make a git commit describing the work.
- Keep commits small and scoped to the edits made in that conversation.
- Do not defer commits; commit as soon as the edits are complete.
+393
View File
@@ -0,0 +1,393 @@
<?php
session_start();
require __DIR__ . '/helpers.php';
$ADMIN_KEY = getenv('FLIXCOOKS_ADMIN_KEY') ?: 'REWnqLYwQtZZDgXcNxnt';
$authed = isset($_SESSION['fc_admin']) && $_SESSION['fc_admin'] === true;
if (!$authed && isset($_POST['password'])) {
if (hash_equals($ADMIN_KEY, $_POST['password'])) {
$_SESSION['fc_admin'] = true;
$authed = true;
} else {
$error = 'Wrong password';
}
}
if (!$authed) {
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlixCooks Admin Login</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(360px, 90vw); }
.login-card h1 { margin: 0 0 12px; }
.login-card form { display: grid; gap: 12px; }
.login-card input { padding: 12px; border-radius: 10px; border: 1px solid rgba(0,0,0,0.1); }
.login-card button { padding: 12px; border-radius: 10px; border: none; background: var(--accent); color: #fff; font-weight: 700; cursor: pointer; }
.error { color: #b00020; margin: 0; }
</style>
</head>
<body>
<div class="login-card">
<h1>Admin</h1>
<p>Enter your password to manage recipes.</p>
<?php if (!empty($error)): ?><p class="error"><?php echo e($error); ?></p><?php endif; ?>
<form method="post">
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>
<?php
exit;
}
$allRecipes = load_recipes();
$message = null;
$editing = null;
$editIndex = null;
$editingEn = [];
$editingDe = [];
if (isset($_GET['edit'])) {
$slugEdit = trim($_GET['edit']);
foreach ($allRecipes as $idx => $r) {
if (($r['slug'] ?? '') === $slugEdit) {
$editing = $r;
$editIndex = $idx;
$editingEn = $r['i18n']['en'] ?? [];
$editingDe = $r['i18n']['de'] ?? [];
break;
}
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_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)) {
$message = 'Recipe deleted.';
} else {
$message = 'Could not delete. Check permissions.';
}
$editing = null;
$editIndex = null;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'save') {
// helpers
$parse_csv = fn($text) => array_values(array_filter(array_map('trim', explode(',', $text ?? '')), 'strlen'));
$parse_lines = fn($text) => array_values(array_filter(array_map('trim', preg_split('/\\r?\\n/', $text ?? '')), 'strlen'));
$titleEn = trim($_POST['title_en'] ?? '');
$titleDe = trim($_POST['title_de'] ?? '');
$descriptionEn = trim($_POST['description_en'] ?? '');
$descriptionDe = trim($_POST['description_de'] ?? '');
$hero = trim($_POST['hero'] ?? '');
$categoryEn = trim($_POST['category_en'] ?? '');
$categoryDe = trim($_POST['category_de'] ?? '');
$tagsEn = $parse_csv($_POST['tags_en'] ?? '');
$tagsDe = $parse_csv($_POST['tags_de'] ?? '');
$prep = (int) ($_POST['prep_time'] ?? 0);
$cook = (int) ($_POST['cook_time'] ?? 0);
$total = (int) ($_POST['total_time'] ?? ($prep + $cook));
$servings = (int) ($_POST['servings'] ?? 0);
$difficultyEn = trim($_POST['difficulty_en'] ?? 'Easy');
$difficultyDe = trim($_POST['difficulty_de'] ?? 'Einfach');
$ingredientsEn = $parse_lines($_POST['ingredients_en'] ?? '');
$ingredientsDe = $parse_lines($_POST['ingredients_de'] ?? '');
$utensilsEn = $parse_lines($_POST['utensils_en'] ?? '');
$utensilsDe = $parse_lines($_POST['utensils_de'] ?? '');
$stepsEn = $parse_lines($_POST['steps_en'] ?? '');
$stepsDe = $parse_lines($_POST['steps_de'] ?? '');
$featured = isset($_POST['featured']) && $_POST['featured'] === '1';
$comingSoon = isset($_POST['coming_soon']) && $_POST['coming_soon'] === '1';
$slugOriginal = trim($_POST['slug_original'] ?? '');
// keep previous language data if fields left blank while editing
$prevEn = $editing['i18n']['en'] ?? [];
$prevDe = $editing['i18n']['de'] ?? [];
$titleEn = $titleEn !== '' ? $titleEn : ($prevEn['title'] ?? '');
$titleDe = $titleDe !== '' ? $titleDe : ($prevDe['title'] ?? '');
$descriptionEn = $descriptionEn !== '' ? $descriptionEn : ($prevEn['description'] ?? '');
$descriptionDe = $descriptionDe !== '' ? $descriptionDe : ($prevDe['description'] ?? '');
$categoryEn = $categoryEn !== '' ? $categoryEn : ($prevEn['category'] ?? '');
$categoryDe = $categoryDe !== '' ? $categoryDe : ($prevDe['category'] ?? '');
$difficultyEn = $difficultyEn !== '' ? $difficultyEn : ($prevEn['difficulty'] ?? 'Easy');
$difficultyDe = $difficultyDe !== '' ? $difficultyDe : ($prevDe['difficulty'] ?? 'Einfach');
$tagsEn = !empty($tagsEn) ? $tagsEn : ($prevEn['tags'] ?? []);
$tagsDe = !empty($tagsDe) ? $tagsDe : ($prevDe['tags'] ?? []);
$ingredientsEn = !empty($ingredientsEn) ? $ingredientsEn : ($prevEn['ingredients'] ?? []);
$ingredientsDe = !empty($ingredientsDe) ? $ingredientsDe : ($prevDe['ingredients'] ?? []);
$utensilsEn = !empty($utensilsEn) ? $utensilsEn : ($prevEn['utensils'] ?? []);
$utensilsDe = !empty($utensilsDe) ? $utensilsDe : ($prevDe['utensils'] ?? []);
$stepsEn = !empty($stepsEn) ? $stepsEn : ($prevEn['steps'] ?? []);
$stepsDe = !empty($stepsDe) ? $stepsDe : ($prevDe['steps'] ?? []);
if ($titleEn === '') {
$message = 'English title is required.';
} elseif (!$comingSoon && (empty($ingredientsEn) || empty($stepsEn))) {
$message = 'Ingredients and steps are required unless marked coming soon.';
} else {
// determine slug
if ($slugOriginal) {
$slug = $slugOriginal;
} else {
$slug = slugify($titleEn ?: $titleDe);
// ensure unique slug
$existingSlugs = array_map(fn($r) => $r['slug'] ?? '', $allRecipes);
$base = $slug;
$i = 1;
while (in_array($slug, $existingSlugs, true)) {
$slug = $base . '-' . $i;
$i++;
}
}
if ($featured) {
foreach ($allRecipes as &$r) {
$r['featured'] = false;
}
unset($r);
}
$record = [
'slug' => $slug,
'hero' => $hero,
'prep_time' => $prep,
'cook_time' => $cook,
'total_time' => $total,
'servings' => $servings,
'featured' => $featured,
'coming_soon' => $comingSoon,
'i18n' => [
'en' => [
'title' => $titleEn,
'description' => $descriptionEn,
'category' => $categoryEn,
'difficulty' => $difficultyEn,
'tags' => $tagsEn,
'ingredients' => $ingredientsEn,
'utensils' => $utensilsEn,
'steps' => $stepsEn,
],
'de' => [
'title' => $titleDe ?: $titleEn,
'description' => $descriptionDe ?: $descriptionEn,
'category' => $categoryDe ?: $categoryEn,
'difficulty' => $difficultyDe,
'tags' => !empty($tagsDe) ? $tagsDe : $tagsEn,
'ingredients' => !empty($ingredientsDe) ? $ingredientsDe : $ingredientsEn,
'utensils' => !empty($utensilsDe) ? $utensilsDe : $utensilsEn,
'steps' => !empty($stepsDe) ? $stepsDe : $stepsEn,
],
],
];
if ($slugOriginal && $editIndex !== null) {
$allRecipes[$editIndex] = $record;
} else {
$allRecipes[] = $record;
}
if (save_recipes($allRecipes)) {
$message = $slugOriginal ? 'Saved changes.' : 'Saved! New recipe added.';
} else {
$message = 'Could not save the file. Check permissions on data/recipes.json.';
}
}
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlixCooks Admin</title>
<link rel="stylesheet" href="/assets/style.css">
<style>
body { max-width: 1100px; margin: 24px auto 64px; padding: 0 16px; color: var(--ink); }
.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; }
form.admin-form {
display: grid;
gap: 12px;
background: var(--surface-2);
padding: 18px;
border-radius: 16px;
box-shadow: var(--shadow);
border: 1px solid rgba(255,255,255,0.08);
color: var(--ink);
}
form.admin-form label { font-weight: 700; display: block; margin-bottom: 6px; color: var(--ink); }
form.admin-form input, form.admin-form textarea, form.admin-form select {
width: 100%;
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(255,255,255,0.08);
color: var(--ink);
}
form.admin-form textarea { resize: vertical; }
form.admin-form input::placeholder,
form.admin-form textarea::placeholder {
color: rgba(255,255,255,0.6);
}
form.admin-form textarea { min-height: 120px; }
.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.message { padding: 12px; border-radius: 12px; background: var(--accent-soft); color: var(--ink); }
.list { margin-top: 18px; }
.list-item { padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.08); display: flex; justify-content: space-between; align-items: center; }
.pill { background: rgba(255,255,255,0.06); color: var(--ink); }
</style>
</head>
<body>
<div class="admin-header">
<h1>FlixCooks Admin</h1>
<div>
<a class="button" href="/index.php" style="padding:10px 14px;">View site</a>
<?php if ($editing): ?>
<a class="button" href="/admin.php" style="padding:10px 14px;">Back</a>
<?php endif; ?>
</div>
</div>
<?php if ($message): ?>
<div class="message"><?php echo e($message); ?></div>
<?php endif; ?>
<form class="admin-form" method="post">
<input type="hidden" name="action" value="save">
<?php if ($editing): ?>
<input type="hidden" name="slug_original" value="<?php echo e($editing['slug']); ?>">
<?php endif; ?>
<label>Hero Image URL</label>
<input type="text" name="hero" placeholder="https://... or /assets/images/hero.jpg" value="<?php echo e($editing['hero'] ?? ''); ?>">
<p style="color: var(--muted); margin: 4px 0 8px; font-size: 13px;">Accepts full URLs or relative paths (e.g. /assets/images/hero.jpg).</p>
<div class="row">
<div>
<label>Prep time (minutes)</label>
<input type="number" name="prep_time" min="0" value="<?php echo e($editing['prep_time'] ?? 0); ?>">
</div>
<div>
<label>Cook time (minutes)</label>
<input type="number" name="cook_time" min="0" value="<?php echo e($editing['cook_time'] ?? 0); ?>">
</div>
<div>
<label>Total time (minutes)</label>
<input type="number" name="total_time" min="0" value="<?php echo e($editing['total_time'] ?? 0); ?>" placeholder="0 to auto-sum">
</div>
<div>
<label>Servings</label>
<input type="number" name="servings" min="1" value="<?php echo e($editing['servings'] ?? 2); ?>">
</div>
</div>
<hr>
<h3>English</h3>
<div class="row">
<div>
<label>Title (EN) *</label>
<input type="text" name="title_en" required value="<?php echo e($editingEn['title'] ?? ''); ?>">
</div>
<div>
<label>Category (EN)</label>
<input type="text" name="category_en" placeholder="Dinner, Dessert..." value="<?php echo e($editingEn['category'] ?? ''); ?>">
</div>
<div>
<label>Difficulty (EN)</label>
<?php $diffEn = $editingEn['difficulty'] ?? 'Easy'; ?>
<select name="difficulty_en">
<option <?php echo $diffEn==='Easy'?'selected':''; ?>>Easy</option>
<option <?php echo $diffEn==='Medium'?'selected':''; ?>>Medium</option>
<option <?php echo $diffEn==='Hard'?'selected':''; ?>>Hard</option>
</select>
</div>
</div>
<label>Description (EN)</label>
<textarea name="description_en" placeholder="Short teaser"><?php echo e($editingEn['description'] ?? ''); ?></textarea>
<label>Tags (EN, comma separated)</label>
<input type="text" name="tags_en" placeholder="gluten-free, 30-minutes" value="<?php echo e(isset($editingEn['tags']) ? implode(', ', $editingEn['tags']) : ''); ?>">
<label>Ingredients (EN, one per line) *</label>
<textarea name="ingredients_en" placeholder="1 cup flour&#10;2 eggs"><?php echo isset($editingEn['ingredients']) ? e(implode("\n", $editingEn['ingredients'])) : ''; ?></textarea>
<label>Utensils (EN, one per line)</label>
<textarea name="utensils_en" placeholder="12&quot; skillet&#10;Sheet pan&#10;Rubber spatula"><?php echo isset($editingEn['utensils']) ? e(implode("\n", $editingEn['utensils'])) : ''; ?></textarea>
<label>Steps (EN, one per line) *</label>
<textarea name="steps_en" placeholder="Preheat oven...&#10;Mix dry ingredients..."><?php echo isset($editingEn['steps']) ? e(implode("\n", $editingEn['steps'])) : ''; ?></textarea>
<hr>
<h3>Deutsch</h3>
<div class="row">
<div>
<label>Titel (DE)</label>
<input type="text" name="title_de" value="<?php echo e($editingDe['title'] ?? ''); ?>">
</div>
<div>
<label>Kategorie (DE)</label>
<input type="text" name="category_de" placeholder="Abendessen, Dessert..." value="<?php echo e($editingDe['category'] ?? ''); ?>">
</div>
<div>
<label>Schwierigkeitsgrad (DE)</label>
<?php $diffDe = $editingDe['difficulty'] ?? 'Einfach'; ?>
<select name="difficulty_de">
<option <?php echo strtolower($diffDe)==='einfach'?'selected':''; ?>>Einfach</option>
<option <?php echo strtolower($diffDe)==='mittel'?'selected':''; ?>>Mittel</option>
<option <?php echo strtolower($diffDe)==='schwer'?'selected':''; ?>>Schwer</option>
</select>
</div>
</div>
<label>Beschreibung (DE)</label>
<textarea name="description_de" placeholder="Kurzer Teaser"><?php echo e($editingDe['description'] ?? ''); ?></textarea>
<label>Tags (DE, komma-getrennt)</label>
<input type="text" name="tags_de" placeholder="schnell, vegetarisch" value="<?php echo e(isset($editingDe['tags']) ? implode(', ', $editingDe['tags']) : ''); ?>">
<label>Zutaten (DE, eine pro Zeile)</label>
<textarea name="ingredients_de" placeholder="300 g Mehl&#10;2 Eier"><?php echo isset($editingDe['ingredients']) ? e(implode("\n", $editingDe['ingredients'])) : ''; ?></textarea>
<label>Werkzeug (DE, eine pro Zeile)</label>
<textarea name="utensils_de" placeholder="Pfanne&#10;Kochtopf"><?php echo isset($editingDe['utensils']) ? e(implode("\n", $editingDe['utensils'])) : ''; ?></textarea>
<label>Schritte (DE, eine pro Zeile)</label>
<textarea name="steps_de" placeholder="Backofen vorheizen...&#10;Teig kneten..."><?php echo isset($editingDe['steps']) ? e(implode("\n", $editingDe['steps'])) : ''; ?></textarea>
<label><input type="checkbox" name="featured" value="1" <?php echo !empty($editing['featured']) ? 'checked' : ''; ?>> Mark as featured</label>
<label><input type="checkbox" name="coming_soon" value="1" <?php echo !empty($editing['coming_soon']) ? 'checked' : ''; ?>> Mark as coming soon (name & image only)</label>
<button type="submit" class="button" style="width: fit-content;"><?php echo $editing ? 'Save changes' : 'Save recipe'; ?></button>
</form>
<div class="list">
<h2>Existing recipes (<?php echo count($allRecipes); ?>)</h2>
<?php if (empty($allRecipes)): ?>
<p>No recipes yet.</p>
<?php else: ?>
<?php foreach ($allRecipes as $r): ?>
<?php $display = localize_recipe($r, 'en'); ?>
<div class="list-item">
<div>
<strong><?php echo e($display['title'] ?? $r['slug']); ?></strong>
<span class="pill"><?php echo e($display['category'] ?? ''); ?></span>
<?php if (!empty($r['featured'])): ?><span class="pill">Featured</span><?php endif; ?>
<?php if (!empty($r['coming_soon'])): ?><span class="pill">Coming soon</span><?php endif; ?>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<small><?php echo e($r['slug']); ?></small>
<a class="button" href="/admin.php?edit=<?php echo urlencode($r['slug']); ?>" style="padding:8px 12px;">Edit</a>
<form method="post" style="margin:0;" onsubmit="return confirm('Delete this recipe?');">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="slug" value="<?php echo e($r['slug']); ?>">
<button type="submit" class="button" style="padding:8px 12px;">Delete</button>
</form>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" role="img" aria-labelledby="title desc">
<title id="title">Coming soon placeholder</title>
<desc id="desc">A simple placeholder card for upcoming recipes.</desc>
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#232833" />
<stop offset="100%" stop-color="#171b23" />
</linearGradient>
</defs>
<rect width="640" height="360" rx="28" fill="url(#bg)" />
<circle cx="138" cy="180" r="56" fill="#2b3342" />
<rect x="230" y="124" width="264" height="28" rx="14" fill="#394256" />
<rect x="230" y="170" width="176" height="24" rx="12" fill="#313949" />
<rect x="230" y="214" width="210" height="24" rx="12" fill="#313949" />
<text x="230" y="278" fill="#d8deea" font-family="Manrope, Arial, sans-serif" font-size="28" font-weight="700">Coming soon</text>
</svg>

After

Width:  |  Height:  |  Size: 906 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+1024
View File
File diff suppressed because it is too large Load Diff
+240
View File
@@ -0,0 +1,240 @@
[
{
"slug": "frische-tagliatelle-mit-cremiger-tomatensauce",
"hero": "/assets/pasta-tomato.jpg",
"prep_time": 50,
"cook_time": 10,
"total_time": 60,
"servings": 2,
"featured": false,
"coming_soon": false,
"i18n": {
"en": {
"title": "Tagliatelle with tomato sauce",
"description": "Simple but tasty, short cooking time but self made. The root dish to start in a cozy evening and satisfy the carbs cravings without any hidden additives. Noodles with Tomato Sauce, more hearty isnt possible.",
"category": "Pasta",
"difficulty": "Easy",
"tags": [
"Pasta",
"Fast",
"Vegetarian"
],
"ingredients": [
"300 g flour",
"3 eggs",
"10 cherry tomatoes",
"1/2 clove garlic",
"1 tbsp olive oil",
"80 ml cream",
"Fresh basil",
"Parmesan",
"Salt and pepper"
],
"utensils": [
"Rolling pin",
"Optional: Pasta machine",
"Optional: Blender"
],
"steps": [
"Pile the flour on your counter, press a well in the center, crack in the eggs. Gradually pull flour into the eggs, then knead 5\u201310 minutes until smooth.",
"Roll the dough to 1\u20132 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."
]
},
"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.",
"category": "Pasta",
"difficulty": "Einfach",
"tags": [
"Pasta",
"Schnell",
"Vegetarisch"
],
"ingredients": [
"300 g Mehl",
"3 Eier",
"10 Cherry-Tomaten",
"1/2 Knoblauchzehe",
"1 EL Oliven\u00f6l",
"80 ml Sahne",
"Frischer Basilikum",
"Parmesan",
"Salz und Pfeffer"
],
"utensils": [
"Nudelholz",
"Optional: Nudelmaschine",
"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."
]
}
}
},
{
"slug": "oat-pancakes",
"hero": "/assets/pancakes.jpg",
"prep_time": 0,
"cook_time": 0,
"total_time": 0,
"servings": 2,
"featured": false,
"coming_soon": true,
"i18n": {
"en": {
"title": "Oat-Pancakes",
"description": "",
"category": "",
"difficulty": "Easy",
"tags": [],
"ingredients": [],
"utensils": [],
"steps": []
},
"de": {
"title": "Hafer-Pfannkuchen",
"description": "",
"category": "",
"difficulty": "Einfach",
"tags": [],
"ingredients": [],
"utensils": [],
"steps": []
}
}
},
{
"slug": "steak-with-onion-jam",
"hero": "/assets/steak.JPEG",
"prep_time": 0,
"cook_time": 0,
"total_time": 0,
"servings": 2,
"featured": true,
"coming_soon": false,
"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.",
"category": "",
"difficulty": "Easy",
"tags": [
"meat",
"dinner"
],
"ingredients": [
"2 x 250g rump steak",
"300g potatoes",
"1 large red onion",
"1/2 clove garlic",
"50ml cream",
"50ml red wine",
"4 carrots (yellow and purple)",
"parmesan",
"Sicilian orange salt",
"thyme",
"1 tbsp butter"
],
"utensils": [
"knife",
"cutting board",
"pan",
"pot",
"oven"
],
"steps": [
"Take the steak out 30 minutes before cooking",
"Halve the potatoes",
"Halve the carrots",
"Dice the onion",
"Cook the potatoes and carrots until al dente",
"Sear the steak with butter",
"Once both sides are golden brown, bake for 6 minutes at 160\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",
"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.",
"category": "",
"difficulty": "Einfach",
"tags": [
"Fleisch",
"Abendessen"
],
"ingredients": [
"2 x 250g Rumpsteak",
"300gr Kartoffeln",
"1 Gro\u00dfe Rote Zwiebel",
"1/2 Knoblauch-Zehe",
"50ml Sahne",
"50ml Rotwein",
"4 Karotten (Geld und Lila)",
"Parmesan",
"Sizilianisches Orangensalz",
"Thymian",
"1 EL Butter"
],
"utensils": [
"Messer",
"Brett",
"Pfanne",
"Topf",
"Ofen"
],
"steps": [
"Steak 30 Minuten vor dem anbraten raus legen",
"Kartoffeln halbieren",
"Karotten halbieren",
"Zwiebel w\u00fcrfeln",
"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",
"Anrichten!"
]
}
}
},
{
"slug": "oat-cake",
"hero": "/assets/haferkuchen.JPEG",
"prep_time": 0,
"cook_time": 0,
"total_time": 0,
"servings": 2,
"featured": false,
"coming_soon": true,
"i18n": {
"en": {
"title": "Oat cake",
"description": "",
"category": "",
"difficulty": "Easy",
"tags": [],
"ingredients": [],
"utensils": [],
"steps": []
},
"de": {
"title": "Haferkuchen",
"description": "",
"category": "",
"difficulty": "Einfach",
"tags": [],
"ingredients": [],
"utensils": [],
"steps": []
}
}
}
]
Binary file not shown.
+131
View File
@@ -0,0 +1,131 @@
<?php
// Shared helper functions for the FlixCooks food blog.
function load_recipes(): array {
$path = __DIR__ . '/data/recipes.json';
if (!file_exists($path)) {
return [];
}
$json = file_get_contents($path);
$data = json_decode($json, true);
if (!is_array($data)) {
return [];
}
foreach ($data as &$recipe) {
if (!empty($recipe['hero']) && is_string($recipe['hero'])) {
$recipe['hero'] = normalize_asset_path($recipe['hero']);
}
}
unset($recipe);
return $data;
}
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 slugify(string $text): string {
$text = strtolower($text);
$text = preg_replace('~[^a-z0-9]+~', '-', $text);
$text = trim($text, '-');
return $text ?: uniqid('recipe-');
}
function find_recipe_by_slug(array $recipes, string $slug): ?array {
foreach ($recipes as $recipe) {
if (($recipe['slug'] ?? '') === $slug) {
return $recipe;
}
}
return null;
}
function localize_recipe(array $recipe, string $lang): array {
$localized = $recipe;
$i18n = $recipe['i18n'][$lang] ?? ($recipe['i18n']['en'] ?? []);
foreach ($i18n as $key => $value) {
$localized[$key] = $value;
}
return $localized;
}
function localize_recipes(array $recipes, string $lang): array {
return array_map(function ($recipe) use ($lang) {
return localize_recipe($recipe, $lang);
}, $recipes);
}
function filter_recipes(array $recipes, ?string $query = null, ?string $tag = null, string $lang = 'en'): array {
$query = $query ? strtolower($query) : null;
$tag = $tag ? strtolower($tag) : null;
$localized = localize_recipes($recipes, $lang);
return array_values(array_filter($localized, function ($recipe) use ($query, $tag) {
$matchesQuery = true;
if ($query) {
$haystack = strtolower(($recipe['title'] ?? '') . ' ' . ($recipe['description'] ?? ''));
$matchesQuery = strpos($haystack, $query) !== false;
}
$matchesTag = true;
if ($tag) {
$tags = array_map('strtolower', $recipe['tags'] ?? []);
$matchesTag = in_array($tag, $tags, true);
}
return $matchesQuery && $matchesTag;
}));
}
function format_minutes(int $minutes): string {
if ($minutes < 60) {
return $minutes . ' min';
}
$hours = intdiv($minutes, 60);
$mins = $minutes % 60;
return $hours . ' hr' . ($hours > 1 ? 's ' : ' ') . ($mins ? $mins . ' min' : '');
}
function e(?string $value): string {
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function normalize_asset_path(string $path): string {
if (!str_starts_with($path, '/assets/')) {
return $path;
}
$absolute = __DIR__ . $path;
if (file_exists($absolute)) {
return $path;
}
$dir = dirname($absolute);
$target = basename($absolute);
if (!is_dir($dir)) {
return $path;
}
foreach (scandir($dir) ?: [] as $entry) {
if (strcasecmp($entry, $target) === 0) {
return rtrim(dirname($path), '/') . '/' . $entry;
}
}
return $path;
}
/**
* Build a URL to the current path with a specific language parameter, preserving other query params.
*/
function lang_url(string $lang): string {
$params = $_GET;
$params['lang'] = $lang;
$path = strtok($_SERVER['REQUEST_URI'], '?') ?: '/';
return $path . '?' . http_build_query($params);
}
+47
View File
File diff suppressed because one or more lines are too long
+287
View File
@@ -0,0 +1,287 @@
<?php
require __DIR__ . '/helpers.php';
// 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) ?? ''));
$tag = trim((string) (filter_input(INPUT_GET, 'tag', FILTER_UNSAFE_RAW) ?? ''));
// Language copy
$copy = [
'en' => [
'home' => 'Home',
'latest' => 'Latest',
'basics' => 'Basics',
'eyebrow' => 'Seasonal, unfussy, ridiculously tasty.',
'headline' => 'Cook better <span>weeknights</span> and brighter <span>weekends</span>.',
'subhead' => 'Search for a craving, filter by vibe, or jump into this week\'s featured recipe.',
'search_placeholder' => 'Search for pasta, brunch, sauce...',
'search_button' => 'Search',
'tag_clear' => 'Clear',
'featured' => 'Featured',
'latest_drops' => 'Latest drops',
'count_suffix_default' => ' recipes ready to cook.',
'count_suffix_filtered' => ' recipes matching your filter.',
'kitchen_basics' => 'Kitchen basics',
'basics_sub' => 'Quick wins that upgrade everything else.',
'servings' => 'servings',
'cook_it' => 'Cook it',
'coming_features' => 'Coming soon',
'coming_features_sub' => 'Features Im shipping next.',
],
'de' => [
'home' => 'Start',
'latest' => 'Neueste',
'basics' => 'Basics',
'eyebrow' => 'Saisonal, unkompliziert, richtig lecker.',
'headline' => 'Besser kochen unter der Woche, glänzen am Wochenende.',
'subhead' => 'Suche nach einem Gericht, filtere nach Stimmung oder starte mit dem Highlight der Woche.',
'search_placeholder' => 'Suche nach Pasta, Brunch, Sauce...',
'search_button' => 'Suchen',
'tag_clear' => 'Zurücksetzen',
'featured' => 'Highlight',
'latest_drops' => 'Frisch dazugekommen',
'count_suffix_default' => ' Rezepte bereit zum Kochen.',
'count_suffix_filtered' => ' Rezepte passend zum Filter.',
'kitchen_basics' => 'Küchenbasics',
'basics_sub' => 'Kleine Tricks, die alles besser machen.',
'servings' => 'Portionen',
'cook_it' => 'Nachkochen',
'coming_features' => 'Demnächst',
'coming_features_sub' => 'Features, an denen ich gerade baue.',
],
];
$t = $copy[$lang];
$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']))); // hide coming soon from main list
$comingSoon = array_values(array_filter($localizedAll, fn($r) => !empty($r['coming_soon'])));
$placeholderTitle = $lang === 'de' ? 'Bald verfügbar' : 'Coming soon';
$comingSoonDisplay = array_pad($comingSoon, 5, [
'hero' => '/assets/placeholder.svg',
'title' => $placeholderTitle,
]);
$featured = null;
foreach ($allRecipes as $recipe) {
if (!empty($recipe['featured']) && empty($recipe['coming_soon'])) {
$featured = $recipe;
break;
}
}
$featured = $featured ?? ($allRecipes[0] ?? null);
$featured = $featured ? localize_recipe($featured, $lang) : null;
// Build tag cloud
$allTags = [];
foreach ($localizedAll as $recipe) {
foreach ($recipe['tags'] ?? [] as $tTag) {
$allTags[] = $tTag;
}
}
$allTags = array_values(array_unique($allTags));
$comingFeatures = $lang === 'de'
? [
'Nährwerte & Rezepte Vorschlag individuell angepasst auf Ernährungsziel',
'Einkaufsliste ans Handy senden',
'Step-by-Step Rezepte Assistent mit Videos',
]
: [
'Nutrition info & recipe suggestions tailored to your goals',
'Send your grocery list straight to your phone',
'Step-by-step recipe assistant with videos',
];
$pageTitle = $lang === 'de'
? 'FlixCooks | Moderner Foodblog für schnelle Alltagsküche'
: 'FlixCooks | Modern food blog for busy home cooks';
$description = $lang === 'de'
? 'Frische, schnelle Rezepte mit klaren Schritten und großem Geschmack. Gebaut für Hetzner Webhosting in einfachem PHP.'
: 'Fresh, fast recipes with clear steps, smart prep notes, and big flavor. Built for Hetzner web hosting in plain PHP.';
$langSwitchLabel = $lang === 'de' ? 'DE' : 'EN';
$langSwitchHref = lang_url($lang === 'de' ? 'en' : 'de');
$navHome = $t['home'];
$navLatest = $t['latest'];
$navBasics = $t['basics'];
// Small helpers for URLs
$langQuery = ['lang' => $lang];
$recipeUrl = fn(array $recipe) => '/recipe.php?' . http_build_query(['slug' => $recipe['slug']] + $langQuery);
$tagUrl = fn(string $tagValue) => '/index.php?' . http_build_query(['tag' => $tagValue] + $langQuery);
include __DIR__ . '/partials/head.php';
?>
<?php // ── LANDING SECTION ─────────────────────────────────────────────────── ?>
<section class="landing" id="landing" aria-label="Landing">
<div class="landing__blobs" aria-hidden="true">
<div class="blob blob--a"></div>
<div class="blob blob--b"></div>
<div class="blob blob--c"></div>
</div>
<div class="landing__chips" aria-hidden="true">
<div class="chip chip--1">
<img src="/assets/pasta-tomato.jpg" alt="">
<span><?php echo $lang === 'de' ? 'Pasta' : 'Pasta'; ?></span>
</div>
<div class="chip chip--2">
<img src="/assets/steak.jpg" alt="">
<span><?php echo $lang === 'de' ? 'Steak' : 'Steak'; ?></span>
</div>
<div class="chip chip--3">
<img src="/assets/pancakes.jpg" alt="">
<span><?php echo $lang === 'de' ? 'Pfannkuchen' : 'Pancakes'; ?></span>
</div>
<div class="chip chip--4">
<img src="/assets/haferkuchen.JPEG" alt="">
<span><?php echo $lang === 'de' ? 'Kuchen' : 'Cake'; ?></span>
</div>
</div>
<div class="landing__copy">
<p class="landing__eyebrow">
<?php echo $lang === 'de'
? 'Saisonal &nbsp;·&nbsp; Unkompliziert &nbsp;·&nbsp; Richtig lecker'
: 'Seasonal &nbsp;·&nbsp; Unfussy &nbsp;·&nbsp; Ridiculously tasty'; ?>
</p>
<h1 class="landing__headline">
<?php if ($lang === 'de'): ?>
<span class="line line--1">Besser&nbsp;<em>kochen</em></span>
<span class="line line--2">unter der Woche.</span>
<span class="line line--3">Glänzen&nbsp;<em>am Wochenende.</em></span>
<?php else: ?>
<span class="line line--1">Cook&nbsp;<em>better</em></span>
<span class="line line--2">weeknights.</span>
<span class="line line--3">Brighter&nbsp;<em>weekends.</em></span>
<?php endif; ?>
</h1>
<p class="landing__sub"><?php echo e($t['subhead']); ?></p>
<a href="#recipes-start" class="landing__cta" id="landingCta">
<span><?php echo $lang === 'de' ? 'Rezepte entdecken' : 'Explore recipes'; ?></span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
</a>
</div>
<div class="landing__scroll-nudge" aria-hidden="true">
<div class="scroll-line"></div>
<span>scroll</span>
</div>
</section>
<?php include __DIR__ . '/partials/header.php'; ?>
<div id="recipes-start">
<script>
document.getElementById('landingCta').addEventListener('click', function(e){
e.preventDefault();
document.getElementById('recipes-start').scrollIntoView({ behavior: 'smooth' });
});
</script>
?>
<?php if (!empty($comingSoon)): ?>
<div class="coming-strip" aria-label="Coming soon recipes">
<div class="coming-strip__head">
<p class="pill pill--ghost"><?php echo e($lang === 'de' ? 'Bald verfügbar' : 'Coming soon'); ?></p>
<small><?php echo count($comingSoon); ?> <?php echo $lang === 'de' ? 'in Arbeit' : 'in progress'; ?></small>
</div>
<div class="coming-strip__row">
<?php foreach (array_slice($comingSoonDisplay, 0, 5) as $cs): ?>
<div class="coming-chip">
<div class="coming-chip__image">
<img src="<?php echo e($cs['hero'] ?? '/assets/placeholder.svg'); ?>" alt="<?php echo e($cs['title'] ?? 'Coming soon'); ?>">
</div>
<div class="coming-chip__title"><?php echo e($cs['title'] ?? 'New recipe'); ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<section class="hero">
<div class="hero-copy">
<p class="eyebrow"><?php echo e($t['eyebrow']); ?></p>
<h1><?php echo $t['headline']; ?></h1>
<p><?php echo e($t['subhead']); ?></p>
<form class="search" method="get" action="/index.php">
<input type="hidden" name="lang" value="<?php echo e($lang); ?>">
<input type="text" name="q" placeholder="<?php echo e($t['search_placeholder']); ?>" value="<?php echo e($q); ?>">
<?php if ($tag): ?>
<input type="hidden" name="tag" value="<?php echo e($tag); ?>">
<?php endif; ?>
<button type="submit"><?php echo e($t['search_button']); ?></button>
</form>
<div class="tag-row">
<?php foreach ($allTags as $tTag): ?>
<a class="tag<?php echo strtolower($tTag) === strtolower($tag ?? '') ? ' active' : ''; ?>" href="<?php echo e($tagUrl($tTag)); ?>">#<?php echo e($tTag); ?></a>
<?php endforeach; ?>
<?php if ($tag): ?>
<a class="tag clear" href="/index.php?lang=<?php echo e($lang); ?>"><?php echo e($t['tag_clear']); ?></a>
<?php endif; ?>
</div>
</div>
<?php if ($featured): ?>
<div class="hero-card">
<img src="<?php echo e($featured['hero']); ?>" alt="<?php echo e($featured['title']); ?>">
<div class="hero-card__body">
<p class="pill"><?php echo e($t['featured']); ?></p>
<h3><?php echo e($featured['title']); ?></h3>
<p><?php echo e($featured['description']); ?></p>
<div class="meta">
<span>⏱ <?php echo format_minutes((int) ($featured['total_time'] ?? 0)); ?></span>
<span>🍽 <?php echo e($featured['servings']); ?> <?php echo e($t['servings']); ?></span>
</div>
<a class="button" href="<?php echo e($recipeUrl($featured)); ?>"><?php echo e($t['cook_it']); ?></a>
</div>
</div>
<?php endif; ?>
</section>
<section class="latest" id="latest">
<div class="section-header">
<h2><?php echo e($t['latest_drops']); ?></h2>
<p><?php echo count($recipes); ?><?php echo $q || $tag ? $t['count_suffix_filtered'] : $t['count_suffix_default']; ?></p>
</div>
<div class="grid">
<?php foreach ($recipes as $recipe): ?>
<article class="card">
<a href="<?php echo e($recipeUrl($recipe)); ?>" class="card__image">
<img src="<?php echo e($recipe['hero']); ?>" alt="<?php echo e($recipe['title']); ?>">
<span class="pill pill--ghost"><?php echo e($recipe['category']); ?></span>
</a>
<div class="card__body">
<h3><a href="<?php echo e($recipeUrl($recipe)); ?>"><?php echo e($recipe['title']); ?></a></h3>
<p><?php echo e($recipe['description']); ?></p>
<div class="meta">
<span>⏱ <?php echo format_minutes((int) ($recipe['total_time'] ?? 0)); ?></span>
<span>🙂 <?php echo e($recipe['difficulty']); ?></span>
</div>
<div class="tags">
<?php foreach ($recipe['tags'] as $tTag): ?>
<a href="<?php echo e($tagUrl($tTag)); ?>" class="tag">#<?php echo e($tTag); ?></a>
<?php endforeach; ?>
</div>
</div>
</article>
<?php endforeach; ?>
</div>
</section>
<section class="coming-features" id="coming-features">
<div class="section-header">
<h2><?php echo e($t['coming_features']); ?></h2>
<p><?php echo e($t['coming_features_sub']); ?></p>
</div>
<div class="feature-grid">
<?php foreach ($comingFeatures as $feature): ?>
<article class="feature-card">
<div class="feature-icon" aria-hidden="true">✨</div>
<p><?php echo e($feature); ?></p>
</article>
<?php endforeach; ?>
</div>
</section>
</div><!-- /#recipes-start -->
<?php include __DIR__ . '/partials/footer.php'; ?>
+3
View File
@@ -0,0 +1,3 @@
<?php
// Thin wrapper so /maintenance.php loads the maintenance screen.
require __DIR__ . '/maintenance/index.php';
+128
View File
@@ -0,0 +1,128 @@
<?php
// Simple maintenance page for FlixCooks
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlixCooks | Wartungsmodus</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600&family=Playfair+Display:wght@600&display=swap');
:root {
--bg-1: #12141c;
--bg-2: #1c1f2a;
--text: #dce1eb;
--muted: #8c93a3;
--accent: #6c7ea0;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: radial-gradient(circle at 20% 20%, rgba(255,255,255,0.05), transparent 35%),
radial-gradient(circle at 80% 0%, rgba(255,255,255,0.04), transparent 30%),
linear-gradient(135deg, var(--bg-1), var(--bg-2));
color: var(--text);
font-family: 'Montserrat', system-ui, sans-serif;
overflow: hidden;
}
.grain {
position: fixed;
inset: 0;
background-image: radial-gradient(rgba(255,255,255,0.03) 1px, transparent 0);
background-size: 22px 22px;
opacity: 0.35;
pointer-events: none;
mix-blend-mode: screen;
}
.wrap {
text-align: center;
padding: 32px 18px 48px;
}
h1 {
font-family: 'Playfair Display', serif;
font-size: clamp(42px, 6vw, 68px);
margin: 0 0 18px;
letter-spacing: -0.01em;
}
.sub {
margin: 0 0 10px;
text-transform: uppercase;
letter-spacing: 0.24em;
font-weight: 600;
color: var(--muted);
}
.copy { color: var(--muted); margin: 0 0 22px; }
.countdown {
margin-top: -6px;
color: var(--ink);
}
.divider { width: 90px; height: 1px; background: rgba(255,255,255,0.18); margin: 16px auto 12px; }
.dots { display: inline-flex; gap: 8px; align-items: center; justify-content: center; }
.dot {
width: 10px; height: 10px;
border-radius: 50%;
background: rgba(255,255,255,0.32);
animation: pulse 1.4s infinite ease-in-out;
}
.dot:nth-child(2) { animation-delay: 0.15s; }
.dot:nth-child(3) { animation-delay: 0.3s; }
@keyframes pulse {
0%, 80%, 100% { opacity: 0.2; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-4px); }
}
</style>
</head>
<body>
<div class="grain"></div>
<div class="wrap">
<h1>FlixCooks</h1>
<div class="divider"></div>
<p class="sub">Website in Bearbeitung</p>
<p class="copy">Wird in Kürze veröffentlicht</p>
<p id="countdown" class="copy countdown" aria-live="polite">Countdown lädt...</p>
<div class="dots" aria-label="Lädt">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div>
</body>
<script>
function nextTarget() {
const now = new Date();
const target = new Date(now);
// Aim for today at 18:00; if already passed, roll to tomorrow.
target.setHours(18, 0, 0, 0);
if (now > target) {
target.setDate(target.getDate() + 1);
}
return target;
}
function updateCountdown() {
const el = document.getElementById('countdown');
if (!el) return;
const now = new Date();
let target = nextTarget();
if (now > target) {
target = nextTarget();
}
const diff = target - now;
if (diff <= 0) {
el.textContent = 'Live now';
return;
}
const hours = Math.floor(diff / 36e5);
const minutes = Math.floor((diff % 36e5) / 6e4);
const seconds = Math.floor((diff % 6e4) / 1000);
el.textContent = `Countdown: ${hours.toString().padStart(2,'0')}:${minutes.toString().padStart(2,'0')}:${seconds.toString().padStart(2,'0')}`;
}
updateCountdown();
setInterval(updateCountdown, 1000);
</script>
</html>
+72
View File
@@ -0,0 +1,72 @@
<footer class="site-footer">
<div class="footer-meta">
<p>Cook or get cooked.</p>
<a class="contact-link" href="mailto:contact@flixcooks.at">Contact me</a>
<small>© <?php echo date('Y'); ?> FlixCooks</small>
<a href="https://www.perplexity.ai/computer" target="_blank" rel="noopener noreferrer" style="font-size:0.75rem;color:var(--muted)">Created with Perplexity Computer</a>
</div>
</footer>
<button id="backToTop" aria-label="Back to top">↑ Back to top</button>
<!-- Fixed dark/light toggle — always visible, scrolls with page -->
<button class="theme-toggle" data-theme-toggle aria-label="Switch to dark mode">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
<script>
// ── Back to top ───────────────────────────────────────────────────────────────
(function(){
const btn = document.getElementById('backToTop');
if (!btn) return;
const check = () => btn.classList.toggle('show', window.scrollY > 240);
window.addEventListener('scroll', check, { passive: true });
btn.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));
check();
})();
// ── Dark / light mode toggle ─────────────────────────────────────────────────
(function(){
const r = document.documentElement;
let d = r.getAttribute('data-theme') || 'light';
r.setAttribute('data-theme', d);
const SUN = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>';
const MOON = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
function setIcon() {
const icon = d === 'dark' ? SUN : MOON;
const label = 'Switch to ' + (d === 'dark' ? 'light' : 'dark') + ' mode';
document.querySelectorAll('[data-theme-toggle]').forEach(t => {
t.innerHTML = icon;
t.setAttribute('aria-label', label);
});
}
// Use event delegation so it works regardless of DOM order
document.addEventListener('click', function(e) {
if (e.target.closest('[data-theme-toggle]')) {
d = d === 'dark' ? 'light' : 'dark';
r.setAttribute('data-theme', d);
setIcon();
}
});
// Toggle adapts visually when scrolled off landing section
(function(){
const landing = document.getElementById('landing');
const toggle = document.querySelector('.theme-toggle');
if (!toggle) return;
function check() {
const past = !landing || window.scrollY > (landing.offsetHeight - 80);
toggle.classList.toggle('on-site', past);
}
window.addEventListener('scroll', check, { passive: true });
check();
})();
setIcon();
})();
</script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
<?php
$pageTitle = $pageTitle ?? 'FlixCooks | Food Blog & Recipes';
$description = $description ?? 'Seasonal recipes, tested tips, and approachable cooking for busy weeknights.';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="<?php echo e($description); ?>">
<title><?php echo e($pageTitle); ?></title>
<link rel="icon" type="image/png" href="/assets/favicon.png">
<link rel="alternate icon" type="image/x-icon" sizes="16x16 32x32" href="/favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,500;0,600;0,700;1,500;1,600&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/style.css">
<!-- Always start light -->
<script>document.documentElement.setAttribute('data-theme','light');</script>
</head>
<body>
+31
View File
@@ -0,0 +1,31 @@
<header class="site-header">
<div class="site-header__top">
<a class="logo" href="/index.php<?php echo $lang === 'de' ? '?lang=de' : ''; ?>">
<img src="/assets/logo.png" alt="FlixCooks">
</a>
<div class="header-actions">
<a class="lang-toggle" href="<?php echo e($langSwitchHref ?? '#'); ?>"><?php echo e($langSwitchLabel ?? 'DE/EN'); ?></a>
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="siteNav">
<span class="menu-toggle__icon" aria-hidden="true"></span>
<span class="menu-toggle__label"><?php echo $lang === 'de' ? 'Menü' : 'Menu'; ?></span>
</button>
</div>
</div>
<nav id="siteNav" class="site-nav" data-collapsed="true">
<a href="/index.php<?php echo $lang === 'de' ? '?lang=de' : ''; ?>"><?php echo e($navHome ?? 'Home'); ?></a>
<a href="/index.php<?php echo $lang === 'de' ? '?lang=de' : ''; ?>#latest"><?php echo e($navLatest ?? 'Latest'); ?></a>
<a href="/index.php<?php echo $lang === 'de' ? '?lang=de' : ''; ?>#basics"><?php echo e($navBasics ?? 'Basics'); ?></a>
</nav>
</header>
<script>
(() => {
const toggle = document.currentScript.previousElementSibling.querySelector('.menu-toggle');
const nav = document.getElementById('siteNav');
if (!toggle || !nav) return;
toggle.addEventListener('click', () => {
const isCollapsed = nav.getAttribute('data-collapsed') === 'true';
nav.setAttribute('data-collapsed', String(!isCollapsed));
toggle.setAttribute('aria-expanded', String(isCollapsed));
});
})();
</script>
+150
View File
@@ -0,0 +1,150 @@
<?php
require __DIR__ . '/helpers.php';
$lang = (isset($_GET['lang']) && strtolower($_GET['lang']) === 'de') ? 'de' : 'en';
$copy = [
'en' => [
'home' => 'Home',
'latest' => 'Latest',
'basics' => 'Basics',
'not_found' => 'Recipe not found',
'not_found_sub' => 'Try searching for something else.',
'back_home' => '← Back to all recipes',
'ingredients' => 'Ingredients',
'utensils' => 'Utensils',
'steps' => 'Steps',
'also_tasty' => 'Also tasty',
'more_dishes' => 'More dishes to keep you cooking.',
'prep' => 'Prep',
'cook' => 'Cook',
'total' => 'Total',
'serves' => 'Serves',
'level' => 'Level',
],
'de' => [
'home' => 'Start',
'latest' => 'Neueste',
'basics' => 'Basics',
'not_found' => 'Rezept nicht gefunden',
'not_found_sub' => 'Versuche eine andere Suche.',
'back_home' => '← Zurück zu allen Rezepten',
'ingredients' => 'Zutaten',
'utensils' => 'Küchenwerkzeug',
'steps' => 'Schritte',
'also_tasty' => 'Auch lecker',
'more_dishes' => 'Noch mehr zum Nachkochen.',
'prep' => 'Vorbereitung',
'cook' => 'Garen',
'total' => 'Gesamt',
'serves' => 'Portionen',
'level' => 'Level',
],
];
$t = $copy[$lang];
$allRecipes = load_recipes();
$slug = isset($_GET['slug']) ? trim($_GET['slug']) : '';
$recipeRaw = $slug ? find_recipe_by_slug($allRecipes, $slug) : null;
$recipe = $recipeRaw ? localize_recipe($recipeRaw, $lang) : null;
$allRecipesLocalized = localize_recipes($allRecipes, $lang);
$langSwitchLabel = $lang === 'de' ? 'EN' : 'DE';
$langSwitchHref = lang_url($lang === 'de' ? 'en' : 'de');
$navHome = $t['home'];
$navLatest = $t['latest'];
$navBasics = $t['basics'];
if (!$recipe) {
http_response_code(404);
$pageTitle = $t['not_found'] . ' | FlixCooks';
$description = $t['not_found_sub'];
include __DIR__ . '/partials/head.php';
include __DIR__ . '/partials/header.php';
?>
<main class="not-found">
<h1><?php echo e($t['not_found']); ?></h1>
<p><?php echo e($t['not_found_sub']); ?></p>
<a class="button" href="/index.php?lang=<?php echo e($lang); ?>#recipes-start"><?php echo e($t['back_home']); ?></a>
</main>
<?php
include __DIR__ . '/partials/footer.php';
exit;
}
$pageTitle = e($recipe['title']) . ' | FlixCooks';
$description = $recipe['description'] ?? '';
include __DIR__ . '/partials/head.php';
include __DIR__ . '/partials/header.php';
?>
<article class="recipe">
<div class="recipe__hero">
<img src="<?php echo e($recipe['hero']); ?>" alt="<?php echo e($recipe['title']); ?>">
<div class="recipe__hero__copy">
<p class="pill pill--ghost"><?php echo e($recipe['category']); ?></p>
<h1><?php echo e($recipe['title']); ?></h1>
<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>
<span><?php echo e($t['cook']); ?>: <?php echo format_minutes((int) $recipe['cook_time']); ?></span>
<span><?php echo e($t['total']); ?>: <?php echo format_minutes((int) $recipe['total_time']); ?></span>
<span><?php echo e($t['serves']); ?>: <?php echo e($recipe['servings']); ?></span>
<span><?php echo e($t['level']); ?>: <?php echo e($recipe['difficulty']); ?></span>
</div>
<a class="button" href="/index.php?lang=<?php echo e($lang); ?>#recipes-start"><?php echo e($t['back_home']); ?></a>
</div>
</div>
<div class="recipe__body">
<section class="panel">
<h2><?php echo e($t['ingredients']); ?></h2>
<ul class="ingredients">
<?php foreach ($recipe['ingredients'] as $item): ?>
<li><?php echo e($item); ?></li>
<?php endforeach; ?>
</ul>
</section>
<?php if (!empty($recipe['utensils'])): ?>
<section class="panel">
<h2><?php echo e($t['utensils']); ?></h2>
<ul class="ingredients">
<?php foreach ($recipe['utensils'] as $item): ?>
<li><?php echo e($item); ?></li>
<?php endforeach; ?>
</ul>
</section>
<?php endif; ?>
<section class="panel">
<h2><?php echo e($t['steps']); ?></h2>
<ol class="steps">
<?php foreach ($recipe['steps'] as $step): ?>
<li><?php echo e($step); ?></li>
<?php endforeach; ?>
</ol>
</section>
</div>
<section class="more">
<div class="section-header">
<h3><?php echo e($t['also_tasty']); ?></h3>
<p><?php echo e($t['more_dishes']); ?></p>
</div>
<div class="grid grid--tight">
<?php foreach ($allRecipesLocalized as $other): ?>
<?php if ($other['slug'] === $recipe['slug'] || !empty($other['coming_soon'])) continue; ?>
<article class="card card--bare">
<h4><a href="/recipe.php?<?php echo http_build_query(['slug' => $other['slug'], 'lang' => $lang]); ?>"><?php echo e($other['title']); ?></a></h4>
<p><?php echo e($other['description']); ?></p>
<div class="meta">
<span> <?php echo format_minutes((int) ($other['total_time'] ?? 0)); ?></span>
<span>🙂 <?php echo e($other['difficulty']); ?></span>
</div>
</article>
<?php endforeach; ?>
</div>
</section>
</article>
<?php include __DIR__ . '/partials/footer.php'; ?>