Stable Website before landingpage
@@ -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"
|
||||||
@@ -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.
|
||||||
@@ -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 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" skillet Sheet pan 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... 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 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 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... 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>
|
||||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 61 KiB |
@@ -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 |
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 71 KiB |
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 I’m 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 · Unkompliziert · Richtig lecker'
|
||||||
|
: 'Seasonal · Unfussy · Ridiculously tasty'; ?>
|
||||||
|
</p>
|
||||||
|
<h1 class="landing__headline">
|
||||||
|
<?php if ($lang === 'de'): ?>
|
||||||
|
<span class="line line--1">Besser <em>kochen</em></span>
|
||||||
|
<span class="line line--2">unter der Woche.</span>
|
||||||
|
<span class="line line--3">Glänzen <em>am Wochenende.</em></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="line line--1">Cook <em>better</em></span>
|
||||||
|
<span class="line line--2">weeknights.</span>
|
||||||
|
<span class="line line--3">Brighter <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'; ?>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
// Thin wrapper so /maintenance.php loads the maintenance screen.
|
||||||
|
require __DIR__ . '/maintenance/index.php';
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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'; ?>
|
||||||