{% extends "base.html" %}
{% block title %}{{ recipe.name }} - Cook{% endblock %}
{% block content %}
<script type="application/ld+json">{{ self.recipe_jsonld()|safe }}</script>
<div>
<nav class="mb-4 breadcrumb print:hidden">
<ol class="flex items-center space-x-2 text-sm text-muted">
<li><a href="{{ prefix }}/{% if static_mode %}index.html{% endif %}" class="hover:text-text">{{ tr.t("nav-recipes") }}</a></li>
{% for crumb in breadcrumbs %}
<li class="flex items-center">
<span class="mx-2 text-faint">/</span>
{% if loop.last %}
<span class="text-text">{{ crumb }}</span>
{% else %}
<a href="{{ prefix }}/directory/{{ breadcrumbs[..loop.index0 + 1].join("/") }}{% if static_mode %}.html{% endif %}" class="hover:text-text">{{ crumb }}</a>
{% endif %}
</li>
{% endfor %}
</ol>
</nav>
<div class="mb-8">
<!-- Recipe image if available -->
{% match image_path %}
{% when Some with (img) %}
<div class="mb-6 max-w-4xl mx-auto flex justify-center">
<img src="{{ img }}" alt="{{ recipe.name }}" class="rounded-xl shadow-[var(--shadow-card)] max-w-full h-auto max-h-[500px]">
</div>
{% when None %}
{% endmatch %}
<!-- Title bar with scale and shopping list button -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6 print:flex-row print:items-center">
<h1 class="text-display font-bold text-text print:text-2xl">
{{ recipe.name }}
</h1>
{% if scale != 1.0 %}
<div class="hidden print:block text-lg font-normal text-muted">{{ tr.t("recipe-scale-label") }}: {{ scale }}x</div>
{% endif %}
<div class="flex flex-wrap items-center gap-2 lg:gap-3 print:hidden">
{% if !static_mode %}
<div class="flex items-center">
<label for="scale" class="text-sm font-medium text-muted mr-2">{{ tr.t("recipe-scale-label") }}:</label>
<div class="stepper">
<button type="button" aria-label="{{ tr.t("aria-decrease-scale") }}" onclick="adjustScale(-0.5)">−</button>
<input type="number"
id="scale"
value="{{ scale }}"
min="0.5"
max="200"
step="0.5"
onchange="goToScale(this.value)">
<button type="button" aria-label="{{ tr.t("aria-increase-scale") }}" onclick="adjustScale(0.5)">+</button>
</div>
</div>
<a href="{{ prefix }}/edit/{{ recipe_path }}" class="btn" title="{{ tr.t("action-edit") }}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
<span class="hidden lg:inline">{{ tr.t("action-edit") }}</span>
</a>
<button onclick="addToShoppingList(event, {{ recipe_path|json }})" class="btn" title="{{ tr.t("recipe-add-to-shopping") }}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
<span class="hidden lg:inline">{{ tr.t("recipe-add-to-shopping") }}</span>
</button>
{% endif %}
<button id="start-cooking-btn" onclick="startCookingMode()" class="btn btn-primary" title="Start Cooking">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span class="hidden lg:inline">Cook</span>
</button>
{% if static_mode %}
<a href="{{ prefix }}/recipe/{{ recipe_path }}.cook" download class="btn" title="Download .cook source">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5 5-5M12 15V3"></path>
</svg>
<span class="hidden lg:inline">.cook</span>
</a>
{% endif %}
</div>
</div>
<!-- Recipe info section -->
<div class="space-y-4">
<!-- Tags row -->
{% if !tags.is_empty() %}
<div class="flex flex-wrap gap-2">
{% for tag in tags %}
<span class="tag">#{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
{% match recipe.metadata %}
{% when Some with (metadata) %}
<!-- Description if present -->
{% match metadata.description %}
{% when Some with (description) %}
<div class="recipe-note">
<p class="m-0">{{ description }}</p>
</div>
{% when None %}
{% endmatch %}
<!-- Metadata row -->
<div id="metadata-container" class="flex flex-wrap gap-3">
{% match metadata.servings %}
{% when Some with (servings) %}
<span class="metadata-pill metadata-servings">👥 {{ servings }} {{ tr.t("recipe-servings-label") }}</span>
{% when None %}
{% endmatch %}
{% match metadata.time %}
{% when Some with (time) %}
<span class="metadata-pill metadata-time">⏱️ {{ time }}</span>
{% when None %}
{% endmatch %}
{% match metadata.difficulty %}
{% when Some with (difficulty) %}
<span class="metadata-pill metadata-difficulty">📊 {{ difficulty }}</span>
{% when None %}
{% endmatch %}
{% match metadata.course %}
{% when Some with (course) %}
<span class="metadata-pill metadata-course">🍽️ {{ course }}</span>
{% when None %}
{% endmatch %}
{% match metadata.prep_time %}
{% when Some with (prep_time) %}
<span class="metadata-pill metadata-prep">⏱️ {{ tr.t("meta-prep-time") }}: {{ prep_time }}</span>
{% when None %}
{% endmatch %}
{% match metadata.cook_time %}
{% when Some with (cook_time) %}
<span class="metadata-pill metadata-cook">🔥 {{ tr.t("meta-cook-time") }}: {{ cook_time }}</span>
{% when None %}
{% endmatch %}
{% match metadata.cuisine %}
{% when Some with (cuisine) %}
<span class="metadata-pill metadata-cuisine">🌍 {{ cuisine }}</span>
{% when None %}
{% endmatch %}
{% match metadata.diet %}
{% when Some with (diet) %}
<span class="metadata-pill metadata-diet">🥗 {{ diet }}</span>
{% when None %}
{% endmatch %}
{% match metadata.author %}
{% when Some with (author) %}
<span class="metadata-pill metadata-author">👤 {{ author }}</span>
{% when None %}
{% endmatch %}
{% match metadata.source %}
{% when Some with (source) %}
<span class="metadata-pill metadata-source">📖 {{ source }}</span>
{% when None %}
{% endmatch %}
{% match metadata.source_url %}
{% when Some with (source_url) %}
<span class="metadata-pill metadata-source-url">🔗 <a href="{{ source_url }}" class="text-info hover:underline ml-1">{{ source_url|hostname }}</a></span>
{% when None %}
{% endmatch %}
<!-- Custom metadata -->
{% for (key, value) in metadata.custom %}
<span class="metadata-pill metadata-custom">{{ key }}: {{ value }}</span>
{% endfor %}
</div>
{% when None %}
{% endmatch %}
</div>
</div>
<div id="recipe-body" class="grid md:grid-cols-3 gap-8 mb-8">
<div class="md:col-span-1">
<div class="card p-6">
<h2 class="text-title font-bold mb-4 text-accent-text">🥘 {{ tr.t("recipe-ingredients") }}</h2>
{% if sections.len() > 1 %}
<!-- Display ingredients grouped by section -->
{% for section in sections %}
{% if section.ingredients.len() > 0 %}
{% match section.name %}
{% when Some with (name) %}
<h3 class="text-title font-semibold mt-4 mb-2 text-accent-text">{{ name }}</h3>
{% when None %}
{% if !loop.first %}<h3 class="text-title font-semibold mt-4 mb-2 text-accent-text">{{ tr.t("recipe-main-section") }}</h3>{% endif %}
{% endmatch %}
<ul class="ingredient-list space-y-3 {% if !loop.last %}mb-4{% endif %}">
{% for ingredient in section.ingredients %}
<li class="ingredient-row">
<div class="flex-1 flex items-center">
{% match ingredient.reference_path %}
{% when Some with (path) %}
{% if !static_mode %}
<input type="checkbox" checked
class="ref-checkbox accent-[var(--accent)] mr-2 shrink-0"
data-ref-path="{{ path }}"
title="{{ tr.t("shopping-include-in-list") }}">
{% endif %}
<a href="{{ prefix }}/recipe/{{ path }}{% if static_mode %}.html{% endif %}" class="font-medium text-info hover:underline flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
{{ ingredient.name }}
</a>
{% when None %}
<span class="font-medium">{{ ingredient.name }}</span>
{% endmatch %}
{% match ingredient.note %}
{% when Some with (note) %}
<span class="row-note italic ml-1 break-words" aria-label="{{ tr.t("recipe-preparation") }}: {{ note }}">({{ note }})</span>
{% when None %}
{% endmatch %}
</div>
<span class="row-value ml-2">
{% match ingredient.quantity %}
{% when Some with (quantity) %}{{ quantity }}{% when None %}{% endmatch %}
{% match ingredient.unit %}
{% when Some with (unit) %} {{ unit }}{% when None %}{% endmatch %}
</span>
</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
{% else %}
<!-- Fallback to original flat list for recipes without sections -->
<ul class="ingredient-list space-y-3">
{% for ingredient in ingredients %}
<li class="ingredient-row">
<div class="flex-1 flex items-center">
{% match ingredient.reference_path %}
{% when Some with (path) %}
{% if !static_mode %}
<input type="checkbox" checked
class="ref-checkbox accent-[var(--accent)] mr-2 shrink-0"
data-ref-path="{{ path }}"
title="{{ tr.t("shopping-include-in-list") }}">
{% endif %}
<a href="{{ prefix }}/recipe/{{ path }}{% if static_mode %}.html{% endif %}" class="font-medium text-info hover:underline flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
{{ ingredient.name }}
</a>
{% when None %}
<span class="font-medium">{{ ingredient.name }}</span>
{% endmatch %}
{% match ingredient.note %}
{% when Some with (note) %}
<span class="row-note italic ml-1 break-words" aria-label="{{ tr.t("recipe-preparation") }}: {{ note }}">({{ note }})</span>
{% when None %}
{% endmatch %}
</div>
<span class="row-value ml-2">
{% match ingredient.quantity %}
{% when Some with (quantity) %}{{ quantity }}{% when None %}{% endmatch %}
{% match ingredient.unit %}
{% when Some with (unit) %} {{ unit }}{% when None %}{% endmatch %}
</span>
</li>
{% endfor %}
</ul>
{% endif %}
{% if cookware.len() > 0 %}
<h2 class="text-title font-bold mt-6 mb-4 text-text">🍳 {{ tr.t("recipe-cookware") }}</h2>
<ul class="cookware-list ingredient-list space-y-2">
{% for item in cookware %}
<li class="ingredient-row">
<span class="font-medium">{{ item.name }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
<div class="md:col-span-2">
<div class="card p-6">
{% for section in sections %}
{% match section.name %}
{% when Some with (name) %}
<h2 class="text-title font-semibold mt-6 mb-4 text-accent-text border-b border-line pb-2">{{ name }}</h2>
{% when None %}
{% endmatch %}
<ol class="step-list space-y-4 {% if !loop.first %}mt-4{% endif %}">
{% for item in section.items %}
{% match item %}
{% when crate::web::templates::RecipeSectionItem::Step with (step) %}
<li class="step-box">
<div class="flex flex-col gap-4">
{% match step.image_path %}
{% when Some with (img) %}
<img class="image-step" src="{{ img }}" />
{% when None %}
{% endmatch %}
<div class="flex gap-4">
<div class="step-number">{{ step.number }}</div>
<div class="flex-1">
<div class="step-body mb-2">
{% for step_item in step.items %}
{% match step_item %}
{% when crate::web::templates::StepItem::Text with (text) %}{{ text }}{% when crate::web::templates::StepItem::Ingredient with { name, reference_path } %}{% match reference_path %}{% when Some with (path) %}<a href="{{ prefix }}/recipe/{{ path }}{% if static_mode %}.html{% endif %}" class="ingredient-badge hover:underline" title="View recipe: {{ name }}">{{ name }}</a>{% when None %}<span class="ingredient-badge">{{ name }}</span>{% endmatch %}{% when crate::web::templates::StepItem::Cookware with (name) %}<span class="cookware-badge">{{ name }}</span>{% when crate::web::templates::StepItem::Timer with (name) %}<span class="timer-badge">⏱️ {{ name }}</span>{% when crate::web::templates::StepItem::Quantity with (qty) %}<span class="font-bold text-accent-text">{{ qty }}</span>{% when crate::web::templates::StepItem::LineBreak %}<br>{% endmatch %}{% endfor %}
</div>
{% if step.ingredients.len() > 0 %}
<div class="step-refs">
{% for ing in step.ingredients %}
<span class="inline-block mr-3">
{{ ing.name }}{% match ing.quantity %}{% when Some with (q) %}: {{ q }}{% when None %}{% endmatch %}{% match ing.unit %}{% when Some with (u) %} {{ u }}{% when None %}{% endmatch %}{% match ing.note %}{% when Some with (note) %} <span class="italic text-faint break-words" aria-label="{{ tr.t("recipe-preparation") }}: {{ note }}">({{ note }})</span>{% when None %}{% endmatch %}{% if !loop.last %},{% endif %}
</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
</li>
{% when crate::web::templates::RecipeSectionItem::Note with (note) %}
<li class="recipe-note mb-3">
<span>📝</span>
<p class="m-0" style="white-space: pre-line">{{ note }}</p>
</li>
{% endmatch %}
{% endfor %}
</ol>
{% endfor %}
</div>
</div>
</div>
</div>
<script id="cooking-mode-data" type="application/json">
{{ self.cooking_mode_json()|safe }}
</script>
{% if !static_mode %}
<script>
async function addToShoppingList(event, recipePath) {
const scale = document.getElementById('scale').value;
// Hide any previous error
const existingError = document.getElementById('recipe-error-banner');
if (existingError) existingError.remove();
// Collect checked recipe references
const refCheckboxes = document.querySelectorAll('.ref-checkbox');
let payload = {
path: recipePath,
scale: parseFloat(scale)
};
if (refCheckboxes.length > 0) {
payload.included_references = Array.from(refCheckboxes)
.filter(cb => cb.checked)
.map(cb => cb.dataset.refPath);
}
try {
const response = await fetch('{{ prefix }}/api/shopping_list/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
});
if (response.ok) {
// Change button temporarily to show success
const button = event.target.closest('button');
const originalContent = button.innerHTML;
button.innerHTML = `
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
{{ tr.t("recipe-added") }}
`;
button.classList.add('bg-ok-soft', 'text-ok', 'border-ok');
// Reset after 2 seconds
setTimeout(() => {
button.innerHTML = originalContent;
button.classList.remove('bg-ok-soft', 'text-ok', 'border-ok');
}, 2000);
} else {
const data = await response.json().catch(() => ({}));
showRecipeError(data.error || {{ tr.t("shopping-failed-to-add")|json|safe }});
}
} catch (error) {
console.error('Failed to add to shopping list:', error);
showRecipeError({{ tr.t("shopping-failed-to-add")|json|safe }});
}
}
function showRecipeError(message) {
const existing = document.getElementById('recipe-error-banner');
if (existing) existing.remove();
const banner = document.createElement('div');
banner.id = 'recipe-error-banner';
banner.className = 'mb-4 card p-4 border-l-[3px] border-l-danger bg-danger-soft';
banner.innerHTML = `
<div class="flex items-start gap-3">
<div class="shrink-0 text-danger">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
</div>
<p class="flex-1 text-text text-sm font-mono whitespace-pre-wrap"></p>
<button type="button" onclick="this.closest('#recipe-error-banner').remove()" class="icon-btn shrink-0" aria-label="{{ tr.t("aria-dismiss") }}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
`;
banner.querySelector('p').textContent = message;
// Insert before the main content
const content = document.getElementById('recipe-body');
content.parentNode.insertBefore(banner, content);
}
// Scale navigation stashes the scroll position so a scale tap does not
// jump the reader back to the top of the recipe.
const SCALE_SCROLL_KEY = 'recipe-scale-scroll';
// |json puts the path in a JS string literal with <, >, & escaped as
// \uXXXX, so it survives the <script> context (Askama's HTML escaping
// would not be decoded here the way it is inside an attribute).
const RECIPE_URL = {{ prefix|json|safe }} + '/recipe/' + {{ recipe_path|json|safe }};
const DEFAULT_SCALE = {{ scale }};
function goToScale(value) {
const input = document.getElementById('scale');
const n = parseFloat(value);
if (!Number.isFinite(n)) {
// Cleared or non-numeric: put the current scale back, do not navigate.
input.value = DEFAULT_SCALE;
return;
}
const min = parseFloat(input.min) || 0.5;
const max = parseFloat(input.max) || 200;
const clamped = Math.min(max, Math.max(min, n));
try {
sessionStorage.setItem(SCALE_SCROLL_KEY, window.location.pathname + '|' + window.scrollY);
} catch (e) { /* private mode / storage disabled */ }
window.location.href = RECIPE_URL + '?scale=' + encodeURIComponent(clamped);
}
// Only ever set immediately before a scale navigation, cleared as soon as it
// is read, and only honoured on the same recipe path, so a stale stash from a
// failed navigation cannot scroll a different recipe.
(function restoreScaleScroll() {
let stashed = null;
try {
stashed = sessionStorage.getItem(SCALE_SCROLL_KEY);
if (stashed !== null) sessionStorage.removeItem(SCALE_SCROLL_KEY);
} catch (e) { return; }
if (stashed === null) return;
const sep = stashed.lastIndexOf('|');
if (sep === -1 || stashed.slice(0, sep) !== window.location.pathname) return;
const y = parseInt(stashed.slice(sep + 1), 10);
if (!Number.isFinite(y) || y <= 0) return;
window.addEventListener('load', function () { window.scrollTo(0, y); });
})();
</script>
{% endif %}
{% endblock %}
{% block scripts %}
<link rel="stylesheet" href="{{ prefix }}/static/css/cooking-mode.css">
<script src="{{ prefix }}/static/js/cooking-mode.js"></script>
{% endblock %}