<!DOCTYPE html>
<html lang="{{ tr.lang_string() }}" class="">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Cook{% endblock %}</title>
<link rel="icon" type="image/x-icon" href="{{ prefix }}/static/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="{{ prefix }}/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="{{ prefix }}/static/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="{{ prefix }}/static/apple-touch-icon.png">
<link rel="manifest" href="{{ prefix }}/static/site.webmanifest">
{% if !static_mode %}
<link rel="alternate" type="application/atom+xml" title="{{ tr.t("recipes-title") }}" href="{{ prefix }}/atom.xml">
<link rel="alternate" type="application/rss+xml" title="{{ tr.t("recipes-title") }}" href="{{ prefix }}/rss.xml">
{% endif %}
<link href="{{ prefix }}/static/css/output.css" rel="stylesheet">
<script>
// Check for saved theme preference, or check for prefers-color-scheme, or default to light
const theme = localStorage.getItem('theme')
|| (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.classList.toggle('dark', theme === 'dark');
// False for a guest, or a role below shopper, on a server that requires signing in.
window.__CAN_EDIT_LISTS__ = {{ viewer.can_edit_lists()|json|safe }};
// `--recipes-only` and nobody signed in: no shopping list or pantry.
window.__RECIPES_ONLY__ = {{ viewer.recipes_only()|json|safe }};
</script>
<style>
.viewport {
width: 100%;
max-width: 72rem;
margin: 2rem auto;
padding: 0 1rem;
}
/* Search result keyboard selection. --surface-sunk alone is barely
visible against --surface in dark mode, so the accent bar carries
the signal. */
#search-results a.search-selected {
background: var(--accent-soft);
box-shadow: inset 3px 0 0 var(--accent);
}
@media print {
/* Token values are reset to light in input.css; this resets the
browser's own defaults so unstyled text is dark too. */
html.dark, html {
color-scheme: light !important;
}
* {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
body {
background: white !important;
margin: 0;
padding: 0;
}
.viewport {
max-width: 100%;
margin: 0;
padding: 0;
}
/* Hide navigation and non-essential elements */
nav, #search-container, #search-input, #search-results, .print\:hidden, .breadcrumb {
display: none !important;
}
/* Hide all navigation links */
a[href="{{ prefix }}/"]{% if !static_mode %}, a[href="{{ prefix }}/shopping-list"], a[href="{{ prefix }}/preferences"]{% endif %} {
display: none !important;
}
/* Recipe layout for print: one column */
.grid {
display: block !important;
}
.md\:col-span-1, .md\:col-span-2 {
page-break-inside: avoid;
width: 100% !important;
}
/* Square corners on paper */
.card, .step-box, .ingredient-row, .recipe-note, .rounded-xl {
border-radius: 0 !important;
}
/* Step numbers */
.step-number {
background: #000 !important;
color: #fff !important;
}
/* Images */
img {
max-height: 300px !important;
max-width: 100% !important;
height: auto !important;
object-fit: contain !important;
page-break-inside: avoid;
page-break-after: avoid;
}
/* Recipe sections on same page if possible */
h2, h3 {
page-break-after: avoid;
}
ol, ul {
page-break-inside: avoid;
}
/* Compact spacing */
.mb-8, .mb-6, .mb-4 {
margin-bottom: 0.5rem !important;
}
.p-6 {
padding: 0.5rem !important;
}
/* Recipe header */
h1 {
color: #000 !important;
font-size: 1.5rem !important;
margin-bottom: 0 !important;
}
/* Shopping list print styles */
.lg\:w-2\/5, .xl\:w-1\/3 {
display: none !important;
}
#shopping-list-results {
font-size: 11pt !important;
line-height: 1.2 !important;
}
#shopping-list-results input[type="checkbox"] {
display: none !important;
}
#shopping-list-results li .flex-1 {
width: auto !important;
flex: 1 1 0% !important;
}
#shopping-list-results label {
cursor: default !important;
}
#shopping-list-results h2 {
font-size: 11pt !important;
color: #000 !important;
margin-bottom: 0 !important;
}
#shopping-list-results .space-y-2 > * + *,
#shopping-list-results ul > * + * {
margin-top: 0 !important;
}
#shopping-list-results li {
padding: 0 !important;
line-height: 1.3 !important;
border-bottom: 1px dotted #ccc;
}
#shopping-list-results .card {
margin-bottom: 0.15rem !important;
padding: 0 !important;
border: none !important;
}
/* Menu print styles */
#menu-content {
font-size: 10pt !important;
line-height: 1.4 !important;
}
#menu-content h1 {
font-size: 14pt !important;
margin-bottom: 0.25rem !important;
color: #000 !important;
}
#menu-content .space-y-6 > * + * {
margin-top: 0.3rem !important;
}
#menu-content .menu-section {
border: 1px solid #ccc !important;
border-radius: 0 !important;
page-break-inside: avoid;
}
#menu-content .menu-section-header {
background: #eee !important;
color: #000 !important;
padding: 0.15rem 0.5rem !important;
}
#menu-content .menu-section-header h2 {
font-size: 11pt !important;
color: #000 !important;
}
#menu-content .menu-section-body {
padding: 0.25rem 0.5rem !important;
}
#menu-content .menu-section-body .space-y-2 > * + *,
#menu-content .menu-section-body > * + * {
margin-top: 0.05rem !important;
}
#menu-content .menu-section-body h3 {
font-size: 10pt !important;
margin-top: 0.2rem !important;
margin-bottom: 0 !important;
}
#menu-content .menu-section-body .pl-6 {
padding-left: 1rem !important;
}
/* Preserve word spacing in flex lines */
#menu-content .menu-section-body .flex {
gap: 0.25em !important;
}
#menu-content .ingredient-badge {
font-weight: bold !important;
color: #000 !important;
}
#menu-content a {
color: #000 !important;
text-decoration: none !important;
}
}
</style>
</head>
<body class="bg-bg text-text">
<div class="viewport">
<nav class="nav-card relative">
<div class="px-3 lg:px-6 py-4 relative">
<div class="flex items-center justify-between flex-wrap gap-y-3">
<a href="{{ prefix }}/{% if static_mode %}index.html{% endif %}" class="order-1 hidden md:flex items-center space-x-2">
<img src="{{ prefix }}/static/android-chrome-192x192.png" alt="Cook" class="brand-mark h-8 w-8 rounded-md" width="32" height="32">
</a>
<div class="relative z-50 order-2 flex-1 min-w-0 mr-2 md:ml-2 md:mr-4" id="search-container">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-faint" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<input type="text"
id="search-input"
placeholder="{{ tr.t("search-placeholder") }}"
class="search-input">
<div id="search-results"
class="absolute top-full mt-2 w-full card shadow-[var(--shadow-overlay)] hidden max-h-96 overflow-y-auto"
style="z-index: 9999;">
</div>
</div>
<div class="order-3 flex items-center gap-1 lg:gap-2">
{% if features.show_shopping_list || features.show_pantry %}
<div class="hidden md:flex items-center gap-1 mr-2 lg:mr-4">
<a href="{{ prefix }}/{% if static_mode %}index.html{% endif %}"
class="nav-pill {% if active == "recipes" %}active{% endif %}">
{{ tr.t("nav-recipes") }}
</a>
{% if features.show_shopping_list && !static_mode %}
<a href="{{ prefix }}/shopping-list"
class="nav-pill {% if active == "shopping" %}active{% endif %}">
{{ tr.t("nav-shopping-list") }}
</a>
{% endif %}
{% if features.show_pantry && !static_mode %}
<a href="{{ prefix }}/pantry"
class="nav-pill {% if active == "pantry" %}active{% endif %}">
{{ tr.t("nav-pantry") }}
</a>
{% endif %}
</div>
{% endif %}
<!-- Inline buttons on md+ screens -->
{% if !static_mode %}
<a href="{{ prefix }}/preferences" class="icon-btn hidden md:inline-flex {% if active == "preferences" %}bg-accent-soft text-accent-text{% endif %}" aria-label="{{ tr.t("aria-preferences") }}" title="{{ tr.t("aria-preferences") }}">
<span>⚙️</span>
</a>
{% endif %}
<button onclick="showShortcutsHelp()" class="icon-btn hidden md:inline-flex ml-2 print:hidden" aria-label="{{ tr.t("aria-keyboard-shortcuts") }}" title="{{ tr.t("aria-keyboard-shortcuts") }} (?)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</button>
<button onclick="toggleTheme()" class="icon-btn hidden md:inline-flex ml-2 print:hidden" aria-label="{{ tr.t("aria-toggle-theme") }}">
<svg class="hidden dark:block" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"></path>
</svg>
<svg class="block dark:hidden" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</svg>
</button>
{% if !static_mode && viewer.auth_enabled() %}
{% if viewer.is_signed_in() %}
<form action="{{ prefix }}/logout" method="POST" class="hidden md:flex items-center gap-2 ml-2 print:hidden">
<span class="text-sm text-muted" title="{{ tr.t("signed-in-as") }} {{ viewer.username() }}">{{ viewer.username() }}</span>
<button type="submit" class="btn btn-sm">{{ tr.t("sign-out") }}</button>
</form>
{% else %}
<a href="{{ prefix }}/login" data-login-link class="btn btn-sm hidden md:inline-flex ml-2 print:hidden">{{ tr.t("sign-in") }}</a>
{% endif %}
{% endif %}
<!-- Overflow menu for small screens -->
<div class="relative md:hidden ml-auto" id="more-menu-container">
<button onclick="document.getElementById('more-dropdown').classList.toggle('hidden')" class="icon-btn" aria-label="{{ tr.t("aria-more-options") }}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"></path>
</svg>
</button>
<div id="more-dropdown" class="hidden absolute right-0 mt-2 w-56 card shadow-[var(--shadow-overlay)] z-50 py-1">
{% if features.show_shopping_list || features.show_pantry %}
<a href="{{ prefix }}/{% if static_mode %}index.html{% endif %}" class="menu-item {% if active == "recipes" %}active{% endif %}">
<span>🍳</span> <span>{{ tr.t("nav-recipes") }}</span>
</a>
{% if features.show_shopping_list && !static_mode %}
<a href="{{ prefix }}/shopping-list" class="menu-item {% if active == "shopping" %}active{% endif %}">
<span>🛒</span> <span>{{ tr.t("nav-shopping-list") }}</span>
</a>
{% endif %}
{% if features.show_pantry && !static_mode %}
<a href="{{ prefix }}/pantry" class="menu-item {% if active == "pantry" %}active{% endif %}">
<span>🥫</span> <span>{{ tr.t("nav-pantry") }}</span>
</a>
{% endif %}
<div class="border-t border-line my-1"></div>
{% endif %}
{% if !static_mode %}
<a href="{{ prefix }}/preferences" class="menu-item">
<span>⚙️</span> <span>Preferences</span>
</a>
{% endif %}
{% if !static_mode && viewer.auth_enabled() %}
{% if viewer.is_signed_in() %}
<form action="{{ prefix }}/logout" method="POST">
<button type="submit" class="menu-item w-full" title="{{ tr.t("signed-in-as") }} {{ viewer.username() }}">
<span>🔓</span> <span>{{ tr.t("sign-out") }} ({{ viewer.username() }})</span>
</button>
</form>
{% else %}
<a href="{{ prefix }}/login" data-login-link class="menu-item">
<span>🔒</span> <span>{{ tr.t("sign-in") }}</span>
</a>
{% endif %}
{% endif %}
<button onclick="showShortcutsHelp(); document.getElementById('more-dropdown').classList.add('hidden');" class="menu-item">
<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="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>Keyboard Shortcuts</span>
</button>
<button onclick="toggleTheme(); document.getElementById('more-dropdown').classList.add('hidden');" class="menu-item">
<svg class="w-4 h-4 hidden dark:block" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"></path></svg>
<svg class="w-4 h-4 block dark:hidden" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
<span>Toggle Theme</span>
</button>
</div>
</div>
</div>
</div>
</div>
</nav>
<main>
{% block content %}{% endblock %}
</main>
{% if static_mode %}
<footer class="mt-12 mb-6 text-center text-sm text-faint print:hidden">
Built with
<a href="https://cooklang.org/cli/" class="text-accent-text hover:underline" target="_blank" rel="noopener">CookCLI</a>
{% if let Some(url) = repo_url %}
·
<a href="{{ url }}" class="text-accent-text hover:underline" target="_blank" rel="noopener">View source</a>
{% endif %}
</footer>
{% endif %}
</div>
<script>
// Theme toggle function
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.classList.contains('dark') ? 'dark' : 'light';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.classList.toggle('dark');
localStorage.setItem('theme', newTheme);
}
</script>
{% if !static_mode %}
<script>
// Translations for JavaScript
const translations = {
noRecipes: {{ tr.t("search-no-results")|json|safe }}
};
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
let searchTimeout;
let selectedIndex = -1;
function updateSearchSelection() {
const items = searchResults.querySelectorAll('a');
items.forEach((item, i) => {
if (i === selectedIndex) {
item.classList.add('search-selected');
item.scrollIntoView({ block: 'nearest' });
} else {
item.classList.remove('search-selected');
}
});
}
function escapeHtml(s) {
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
}
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
const query = this.value.trim();
selectedIndex = -1;
if (query.length < 2) {
searchResults.classList.add('hidden');
return;
}
searchTimeout = setTimeout(async () => {
try {
const response = await fetch(`{{ prefix }}/api/search?q=${encodeURIComponent(query)}`);
const results = await response.json();
selectedIndex = -1;
if (results.length === 0) {
searchResults.innerHTML = '<div class="p-4 text-muted text-center">' + translations.noRecipes + '</div>';
} else {
searchResults.innerHTML = results.map(recipe =>
`<a href="{{ prefix }}/recipe/${escapeHtml(recipe.path)}" class="search-result block px-4 py-3 hover:bg-sunk border-b border-line last:border-b-0">
<div class="font-medium text-text">${escapeHtml(recipe.name)}</div>
</a>`
).join('');
}
searchResults.classList.remove('hidden');
} catch (error) {
console.error('Search failed:', error);
}
}, 300);
});
searchInput.addEventListener('keydown', function(e) {
const items = searchResults.querySelectorAll('a');
if (items.length === 0 || searchResults.classList.contains('hidden')) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, items.length - 1);
updateSearchSelection();
break;
case 'ArrowUp':
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, -1);
updateSearchSelection();
break;
case 'Enter':
if (selectedIndex >= 0 && selectedIndex < items.length) {
e.preventDefault();
items[selectedIndex].click();
}
break;
}
});
document.addEventListener('click', function(e) {
if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) {
searchResults.classList.add('hidden');
selectedIndex = -1;
}
// Close more-dropdown when clicking outside
const moreContainer = document.getElementById('more-menu-container');
const moreDropdown = document.getElementById('more-dropdown');
if (moreContainer && moreDropdown && !moreContainer.contains(e.target)) {
moreDropdown.classList.add('hidden');
}
});
</script>
{% else %}
<script>window.__PREFIX__ = {{ prefix|json|safe }};</script>
<script src="{{ prefix }}/static/js/search.js"></script>
{% endif %}
<!-- URL prefix for JavaScript -->
<script>
window.__PREFIX__ = {{ prefix|json|safe }};
window.__STATIC_MODE__ = {{ static_mode|json|safe }};
window.__STRINGS__ = { close: {{ tr.t("aria-close")|json|safe }} };
// Bring the visitor back to this page after signing in.
document.querySelectorAll('[data-login-link]').forEach((link) => {
const prefix = window.__PREFIX__;
const path = location.pathname.startsWith(prefix)
? location.pathname.slice(prefix.length)
: location.pathname;
if (path === '/login') return;
link.href = `${prefix}/login?next=${encodeURIComponent((path || '/') + location.search)}`;
});
</script>
<!-- Keyboard shortcuts -->
<script src="{{ prefix }}/static/js/keyboard-shortcuts.js"></script>
<!-- Expose showShortcutsHelp globally for the help button -->
<script>
// showShortcutsHelp is defined in keyboard-shortcuts.js
// This ensures it's available for the onclick handler
if (typeof showShortcutsHelp === 'undefined') {
window.showShortcutsHelp = function() {
console.warn('Keyboard shortcuts not loaded');
};
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>