{% extends "base.html" %}
{% block title %}{{ tr.t("action-edit") }}: {{ recipe_name }} - Cook{% endblock %}
{% block content %}
<div class="flex flex-col h-[calc(100vh-12rem)]">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<div class="flex items-center gap-4">
<a href="{{ prefix }}/recipe/{{ recipe_path }}" class="btn">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
{{ tr.t("action-back") }}
</a>
<h1 class="text-display font-bold text-text">{{ recipe_name }}</h1>
</div>
<div class="flex items-center gap-3">
<span id="save-status" class="text-sm text-muted"></span>
<button onclick="showPictureModal()" class="btn">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
{{ tr.t("picture-button") }}
</button>
<button onclick="showRenameModal()" class="btn">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
</svg>
{{ tr.t("action-rename") }}
</button>
<button onclick="showDeleteModal()" class="btn btn-danger">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
{{ tr.t("action-delete") }}
</button>
</div>
</div>
<div id="delete-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="card shadow-[var(--shadow-overlay)] p-6 max-w-md mx-4">
<div class="flex items-center gap-3 mb-4">
<div class="w-12 h-12 rounded-full bg-danger-soft flex items-center justify-center">
<svg class="w-6 h-6 text-danger" 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-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</div>
<h2 class="text-title font-bold text-text">{{ tr.t("delete-recipe") }}</h2>
</div>
<p class="text-muted mb-2">{{ tr.t("delete-recipe-confirm") }}</p>
<p class="text-sm text-danger mb-6">{{ tr.t("delete-recipe-warning") }}</p>
<div class="flex justify-end gap-3">
<button onclick="hideDeleteModal()" class="btn">
{{ tr.t("action-cancel") }}
</button>
<button onclick="deleteRecipe()" class="btn btn-danger">
{{ tr.t("action-delete") }}
</button>
</div>
</div>
</div>
<div id="rename-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<form id="rename-form" class="card shadow-[var(--shadow-overlay)] p-6 w-full max-w-md mx-4" role="dialog" aria-modal="true" aria-labelledby="rename-heading">
<h2 id="rename-heading" class="text-title font-bold text-text mb-4">{{ tr.t("rename-title") }}</h2>
<label for="rename-input" class="block text-sm font-medium text-muted mb-2">{{ tr.t("rename-label") }}</label>
<div class="flex items-center gap-2">
<input id="rename-input" type="text" required autocomplete="off" spellcheck="false" maxlength="200"
aria-describedby="rename-hint rename-error"
class="flex-1 min-w-0 px-4 py-3 border border-line rounded-[var(--radius-control)] bg-surface text-text outline-hidden focus:border-accent focus:shadow-[0_0_0_2px_var(--accent-soft)]">
<span class="text-faint">{% if is_menu %}.menu{% else %}.cook{% endif %}</span>
</div>
<p id="rename-hint" class="mt-2 text-sm text-faint">{{ tr.t("rename-hint") }}</p>
<p id="rename-error" class="mt-2 text-sm text-danger empty:hidden" aria-live="polite"></p>
<div class="flex justify-end gap-3 mt-6">
<button type="button" onclick="hideRenameModal()" class="btn">
{{ tr.t("action-cancel") }}
</button>
<button id="rename-submit" type="submit" class="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
{{ tr.t("action-rename") }}
</button>
</div>
</form>
</div>
<div id="picture-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="card shadow-[var(--shadow-overlay)] p-6 w-full max-w-lg mx-4" role="dialog" aria-modal="true" aria-labelledby="picture-heading">
<h2 id="picture-heading" class="text-title font-bold text-text mb-4">{{ tr.t("picture-title") }}</h2>
{% if !is_menu %}
<div id="picture-target-row" class="hidden mb-4">
<label for="picture-target" class="block text-sm text-muted mb-1">{{ tr.t("picture-for") }}</label>
<select id="picture-target" class="select w-full"></select>
</div>
{% endif %}
<div id="picture-drop" onclick="choosePicture()"
class="flex items-center justify-center h-64 rounded-[var(--radius-control)] border border-dashed border-line-strong bg-sunk overflow-hidden cursor-pointer">
<img id="picture-preview" class="hidden w-full h-full object-contain" alt="{{ tr.t("picture-title") }}">
<p id="picture-empty" class="text-sm text-muted text-center px-6">{{ tr.t("picture-none") }}</p>
</div>
<p id="picture-metadata-note" class="hidden text-sm text-muted mt-3">{{ tr.t("picture-from-metadata") }}</p>
<p id="picture-step-note" class="hidden text-sm text-muted mt-3">{{ tr.t("picture-step-note") }}</p>
<p class="text-xs text-faint mt-3">{{ tr.t("picture-hint") }}</p>
<input id="picture-input" type="file" accept="image/jpeg,image/png,image/webp" class="hidden">
<div id="picture-actions" class="flex flex-wrap items-center justify-end gap-3 mt-6">
<button id="picture-remove" type="button" onclick="askRemovePicture()" class="btn hidden disabled:opacity-50 disabled:cursor-not-allowed">
{{ tr.t("picture-remove") }}
</button>
<span id="picture-status" class="text-sm text-muted mr-auto" aria-live="polite"></span>
<button id="picture-close" type="button" onclick="hidePictureModal()" class="btn">
{{ tr.t("aria-close") }}
</button>
<button id="picture-choose" type="button" onclick="choosePicture()" class="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
{{ tr.t("picture-choose") }}
</button>
</div>
<div id="picture-confirm" class="hidden mt-6">
<p class="text-sm text-danger mb-3">{{ tr.t("picture-remove-confirm") }}</p>
<div class="flex justify-end gap-3">
<button type="button" onclick="cancelRemovePicture()" class="btn">
{{ tr.t("action-cancel") }}
</button>
<button type="button" onclick="removePicture()" class="btn btn-danger">
{{ tr.t("picture-remove") }}
</button>
</div>
</div>
</div>
</div>
{% include "recipe_picker.html" %}
<div id="editor-toolbar" class="editor-toolbar" role="toolbar" aria-label="{{ tr.t("editor-toolbar-label") }}" data-mode="{% if is_menu %}menu{% else %}recipe{% endif %}">
{% if is_menu %}
<div class="editor-toolbar-group" role="group" aria-label="{{ tr.t("editor-toolbar-menu") }}">
<button type="button" class="btn btn-sm" data-action="day" data-default="{{ tr.t("editor-toolbar-day-default") }}"
title="{{ tr.t("editor-toolbar-day-title") }}" aria-label="{{ tr.t("editor-toolbar-day") }}">
<span class="editor-toolbar-glyph" aria-hidden="true">==</span>
{{ tr.t("editor-toolbar-day") }}
</button>
<input type="date" class="editor-toolbar-date" data-day-date
title="{{ tr.t("editor-toolbar-day-date-title") }}" aria-label="{{ tr.t("editor-toolbar-day-date") }}">
<div class="editor-toolbar-menu">
<button type="button" id="meal-button" class="btn btn-sm" aria-haspopup="menu" aria-expanded="false" aria-controls="meal-menu"
title="{{ tr.t("editor-toolbar-meal-title") }}" aria-label="{{ tr.t("editor-toolbar-meal") }}">
<span class="editor-toolbar-glyph" aria-hidden="true">:</span>
{{ tr.t("editor-toolbar-meal") }}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div id="meal-menu" class="editor-toolbar-popup" role="menu" aria-labelledby="meal-button" hidden>
<button type="button" role="menuitem" tabindex="-1" data-action="meal" data-meal="{{ tr.t("editor-toolbar-meal-breakfast") }}">{{ tr.t("editor-toolbar-meal-breakfast") }}</button>
<button type="button" role="menuitem" tabindex="-1" data-action="meal" data-meal="{{ tr.t("editor-toolbar-meal-lunch") }}">{{ tr.t("editor-toolbar-meal-lunch") }}</button>
<button type="button" role="menuitem" tabindex="-1" data-action="meal" data-meal="{{ tr.t("editor-toolbar-meal-dinner") }}">{{ tr.t("editor-toolbar-meal-dinner") }}</button>
<button type="button" role="menuitem" tabindex="-1" data-action="meal" data-meal="{{ tr.t("editor-toolbar-meal-snacks") }}">{{ tr.t("editor-toolbar-meal-snacks") }}</button>
</div>
</div>
<button type="button" class="btn btn-sm" data-action="add-recipe"
title="{{ tr.t("editor-toolbar-add-recipe-title") }}" aria-label="{{ tr.t("editor-toolbar-add-recipe") }}">
<span class="editor-toolbar-glyph" aria-hidden="true">- @</span>
{{ tr.t("editor-toolbar-add-recipe") }}
</button>
</div>
{% endif %}
<div class="editor-toolbar-group" role="group" aria-label="{% if is_menu %}{{ tr.t("editor-toolbar-other") }}{% else %}{{ tr.t("editor-toolbar-inline") }}{% endif %}">
<button type="button" class="btn btn-sm" data-action="ingredient"
title="{{ tr.t("editor-toolbar-ingredient-title") }}" aria-label="{{ tr.t("editor-toolbar-ingredient") }}">
<span class="editor-toolbar-glyph" aria-hidden="true">@</span>
{{ tr.t("editor-toolbar-ingredient") }}
</button>
{% if !is_menu %}
<button type="button" class="btn btn-sm" data-action="cookware"
title="{{ tr.t("editor-toolbar-cookware-title") }}" aria-label="{{ tr.t("editor-toolbar-cookware") }}">
<span class="editor-toolbar-glyph" aria-hidden="true">#</span>
{{ tr.t("editor-toolbar-cookware") }}
</button>
<button type="button" class="btn btn-sm" data-action="timer"
title="{{ tr.t("editor-toolbar-timer-title") }}" aria-label="{{ tr.t("editor-toolbar-timer") }}">
<span class="editor-toolbar-glyph" aria-hidden="true">~</span>
{{ tr.t("editor-toolbar-timer") }}
</button>
<button type="button" class="btn btn-sm" data-action="recipe-reference"
title="{{ tr.t("editor-toolbar-recipe-reference-title") }}" aria-label="{{ tr.t("editor-toolbar-recipe-reference") }}">
<span class="editor-toolbar-glyph" aria-hidden="true">@./</span>
{{ tr.t("editor-toolbar-recipe-reference") }}
</button>
</div>
<div class="editor-toolbar-group" role="group" aria-label="{{ tr.t("editor-toolbar-block") }}">
<button type="button" class="btn btn-sm" data-action="section" data-default="{{ tr.t("editor-toolbar-section-default") }}"
title="{{ tr.t("editor-toolbar-section-title") }}" aria-label="{{ tr.t("editor-toolbar-section") }}">
<span class="editor-toolbar-glyph" aria-hidden="true">==</span>
{{ tr.t("editor-toolbar-section") }}
</button>
<button type="button" class="btn btn-sm" data-action="note"
title="{{ tr.t("editor-toolbar-note-title") }}" aria-label="{{ tr.t("editor-toolbar-note") }}">
<span class="editor-toolbar-glyph" aria-hidden="true">></span>
{{ tr.t("editor-toolbar-note") }}
</button>
{% endif %}
<button type="button" class="btn btn-sm" data-action="comment"
title="{{ tr.t("editor-toolbar-comment-title") }}" aria-label="{{ tr.t("editor-toolbar-comment") }}">
<span class="editor-toolbar-glyph" aria-hidden="true">--</span>
{{ tr.t("editor-toolbar-comment") }}
</button>
<button type="button" class="btn btn-sm" data-action="metadata"
title="{{ tr.t("editor-toolbar-metadata-title") }}" aria-label="{{ tr.t("editor-toolbar-metadata") }}">
<span class="editor-toolbar-glyph" aria-hidden="true">---</span>
{{ tr.t("editor-toolbar-metadata") }}
</button>
</div>
</div>
<div id="editor-container" class="flex-1 card overflow-hidden"></div>
<div id="status-bar" class="mt-2 px-4 py-2 bg-sunk rounded-[var(--radius-control)] flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span id="lsp-status" class="flex items-center gap-1">
<span id="lsp-indicator" class="w-2 h-2 rounded-full bg-inactive"></span>
<span id="lsp-text">Disconnected</span>
</span>
</div>
<div id="cursor-position" class="text-muted">
Line 1, Col 1
</div>
</div>
</div>
<script src="{{ prefix }}/static/js/editor.bundle.js"></script>
<script>
const recipePath = {{ recipe_path|json|safe }};
const basePath = {{ base_path|json|safe }};
let originalContent = {{ content|json|safe }};
let editorView = null;
let autosaveTimer = null;
let isSaving = false;
const AUTOSAVE_DELAY_MS = 1000;
const LSP_INITIAL_CONNECT_DELAY_MS = 500;
const LSP_RECONNECT_DELAY_MS = 3000;
const COMPLETION_TIMEOUT_MS = 2000;
document.addEventListener('DOMContentLoaded', function() {
const container = document.getElementById('editor-container');
editorView = window.CooklangEditor.initEditor(container, originalContent, function(newContent) {
if (newContent !== originalContent) {
updateSaveStatus('modified');
if (autosaveTimer) clearTimeout(autosaveTimer);
autosaveTimer = setTimeout(() => saveRecipe(), AUTOSAVE_DELAY_MS);
}
if (lspInitialized && documentUri) {
if (lspDebounceTimer) clearTimeout(lspDebounceTimer);
lspDebounceTimer = setTimeout(() => {
documentVersion++;
sendLspNotification('textDocument/didChange', {
textDocument: {
uri: documentUri,
version: documentVersion
},
contentChanges: [{ text: newContent }]
});
}, LSP_DEBOUNCE_MS);
}
}, { menu: {{ is_menu }} });
const recipePicker = window.CooklangEditor.initRecipePicker(document.getElementById('recipe-picker'), {
prefix: {{ prefix|json|safe }},
exclude: recipePath
});
window.CooklangEditor.initToolbar(document.getElementById('editor-toolbar'), editorView, {
...window.CooklangEditor.recipeActions,
...window.CooklangEditor.menuActions,
...window.CooklangEditor.pickerActions(recipePicker)
});
window.CooklangEditor.setCursorPositionCallback((line, col) => {
const posEl = document.getElementById('cursor-position');
if (posEl) {
posEl.textContent = `Line ${line}, Col ${col}`;
}
});
});
function updateSaveStatus(state) {
const status = document.getElementById('save-status');
switch (state) {
case 'modified':
status.textContent = 'Modified';
status.className = 'text-sm text-accent-text';
break;
case 'saving':
status.textContent = 'Saving...';
status.className = 'text-sm text-muted';
break;
case 'saved':
status.textContent = 'Saved';
status.className = 'text-sm text-ok';
break;
case 'error':
status.textContent = 'Save failed';
status.className = 'text-sm text-danger';
break;
}
}
function showToast(message, type = 'error') {
const toast = document.createElement('div');
toast.className = `fixed bottom-4 right-4 px-4 py-2 rounded-[var(--radius-control)] border shadow-[var(--shadow-overlay)] z-50 ${
type === 'error' ? 'bg-danger-soft text-danger border-danger' : 'bg-ok-soft text-ok border-ok'
}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 5000);
}
let renaming = false;
async function saveRecipe() {
if (isSaving || renaming) return false;
const content = window.CooklangEditor.getContent(editorView);
if (content === originalContent) return true;
isSaving = true;
updateSaveStatus('saving');
try {
const response = await fetch(`{{ prefix }}/api/recipes/${encodeURIComponent(recipePath)}`, {
method: 'PUT',
headers: {
'Content-Type': 'text/plain',
},
body: content
});
if (response.ok) {
originalContent = content;
updateSaveStatus('saved');
return true;
} else {
updateSaveStatus('error');
const errorText = await errorMessage(response);
console.error('Save failed:', errorText);
showToast(`Save failed: ${errorText || 'Unknown error'}`);
return false;
}
} catch (error) {
updateSaveStatus('error');
console.error('Save failed:', error.message);
showToast(`Save failed: ${error.message}`);
return false;
} finally {
isSaving = false;
}
}
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (autosaveTimer) clearTimeout(autosaveTimer);
saveRecipe();
}
});
function showDeleteModal() {
const modal = document.getElementById('delete-modal');
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function hideDeleteModal() {
const modal = document.getElementById('delete-modal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
async function errorMessage(response) {
const text = await response.text();
try {
return JSON.parse(text).error || text;
} catch {
return text;
}
}
async function deleteRecipe() {
try {
const response = await fetch(`{{ prefix }}/api/recipes/${encodeURIComponent(recipePath)}`, {
method: 'DELETE'
});
if (response.ok) {
window.location.href = '{{ prefix }}/';
} else {
const errorText = await errorMessage(response);
console.error('Delete failed:', errorText);
hideDeleteModal();
showToast(`Failed to delete recipe: ${errorText || 'Unknown error'}`);
}
} catch (error) {
console.error('Delete failed:', error.message);
hideDeleteModal();
showToast(`Failed to delete recipe: ${error.message}`);
}
}
const renameMessages = {
failed: {{ tr.t("rename-failed")|json|safe }},
skipped: {{ tr.t("rename-skipped")|json|safe }},
writeFailed: {{ tr.t("rename-write-failed")|json|safe }},
shoppingList: {{ tr.t("rename-shopping-list")|json|safe }}
};
const RENAME_NOTICE_KEY = 'cook-rename-notice';
const currentName = recipePath.split('/').pop().replace(/\.(cook|menu)$/, '');
function encodePath(path) {
return path.split('/').map(encodeURIComponent).join('/');
}
function updateRenameSubmit() {
const name = document.getElementById('rename-input').value.trim();
document.getElementById('rename-submit').disabled = renaming || !name || name === currentName;
}
function showRenameModal() {
const modal = document.getElementById('rename-modal');
const input = document.getElementById('rename-input');
input.value = currentName;
document.getElementById('rename-error').textContent = '';
updateRenameSubmit();
modal.classList.remove('hidden');
modal.classList.add('flex');
input.focus();
input.select();
}
function hideRenameModal() {
if (renaming) return;
const modal = document.getElementById('rename-modal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
function renameFailed(message) {
renaming = false;
updateRenameSubmit();
if (window.CooklangEditor.getContent(editorView) !== originalContent) {
if (autosaveTimer) clearTimeout(autosaveTimer);
autosaveTimer = setTimeout(() => saveRecipe(), AUTOSAVE_DELAY_MS);
}
document.getElementById('rename-error').textContent = renameMessages.failed.replace('%s', message);
}
async function renameRecipe(event) {
event.preventDefault();
const name = document.getElementById('rename-input').value.trim();
if (renaming || !name || name === currentName) return;
if (autosaveTimer) {
clearTimeout(autosaveTimer);
autosaveTimer = null;
}
while (isSaving) {
await new Promise(resolve => setTimeout(resolve, 50));
}
if (!(await saveRecipe())) {
renameFailed('Save failed');
return;
}
renaming = true;
updateRenameSubmit();
let result;
try {
const response = await fetch(`{{ prefix }}/api/recipe_rename/${encodePath(recipePath)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (!response.ok) {
renameFailed(await errorMessage(response));
return;
}
result = await response.json();
} catch (error) {
renameFailed(error.message);
return;
}
const notices = [];
const content = window.CooklangEditor.getContent(editorView);
if (content !== originalContent) {
let saved = false;
try {
const response = await fetch(`{{ prefix }}/api/recipes/${encodeURIComponent(result.path)}`, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: content
});
saved = response.ok;
} catch (error) {
console.error('Save after rename failed:', error.message);
}
if (!saved) {
notices.push(renameMessages.writeFailed.replace('%s', result.path));
}
}
if (result.references_skipped && result.references_skipped.length) {
const files = result.references_skipped.map(s => s.line ? `${s.file}:${s.line}` : s.file);
notices.push(renameMessages.skipped.replace('%s', [...new Set(files)].join(', ')));
}
if (result.references_failed && result.references_failed.length) {
notices.push(renameMessages.writeFailed.replace('%s', result.references_failed.join(', ')));
}
if (result.shopping_list_stale) {
notices.push(renameMessages.shoppingList);
}
if (notices.length) {
try {
sessionStorage.setItem(RENAME_NOTICE_KEY, notices.join(' '));
} catch (error) {
}
}
window.location.href = `{{ prefix }}/edit/${encodePath(result.path)}`;
}
document.getElementById('rename-form').addEventListener('submit', renameRecipe);
document.getElementById('rename-input').addEventListener('input', updateRenameSubmit);
document.getElementById('rename-modal').addEventListener('click', function(e) {
if (e.target === this) {
hideRenameModal();
}
});
try {
const notice = sessionStorage.getItem(RENAME_NOTICE_KEY);
if (notice) {
sessionStorage.removeItem(RENAME_NOTICE_KEY);
showToast(notice);
}
} catch (error) {
}
document.getElementById('delete-modal').addEventListener('click', function(e) {
if (e.target === this) {
hideDeleteModal();
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
hideDeleteModal();
hideRenameModal();
hidePictureModal();
}
});
const pictureUrl = `{{ prefix }}/api/recipe_image/${encodeURIComponent(recipePath)}`;
const MAX_IMAGE_BYTES = {{ max_image_bytes }};
const MAX_IMAGE_EDGE = {{ max_image_edge }};
const SHRINK_QUALITY = 0.92;
const pictureText = {
choose: {{ tr.t("picture-choose")|json|safe }},
replace: {{ tr.t("picture-replace")|json|safe }},
close: {{ tr.t("aria-close")|json|safe }},
done: {{ tr.t("action-done")|json|safe }},
uploading: {{ tr.t("picture-uploading")|json|safe }},
removing: {{ tr.t("picture-removing")|json|safe }},
loadFailed: {{ tr.t("picture-load-failed")|json|safe }},
uploadFailed: {{ tr.t("picture-upload-failed")|json|safe }},
removeFailed: {{ tr.t("picture-remove-failed")|json|safe }},
tooLarge: {{ tr.t("picture-too-large")|json|safe }},
heif: {{ tr.t("picture-heif")|json|safe }},
title: {{ tr.t("picture-title")|json|safe }},
stepTitle: {{ tr.t("picture-step-title")|json|safe }},
step: {{ tr.tp("picture-target-step", "step text")|json|safe }},
section: {{ tr.tp("picture-target-section", "section")|json|safe }},
};
const STEP_TEXT_LENGTH = 50;
let pictureSource = null;
let pictureBusy = false;
let pictureTarget = null;
let pictureTitle = { image: null, source: null };
let pictureSections = [];
function pictureUrlFor(target) {
return target ? `${pictureUrl}?section=${target.section}&step=${target.step}` : pictureUrl;
}
async function showPictureModal() {
const modal = document.getElementById('picture-modal');
modal.classList.remove('hidden');
modal.classList.add('flex');
cancelRemovePicture();
if (editorView) {
if (autosaveTimer) clearTimeout(autosaveTimer);
await saveRecipe();
}
loadPicture();
}
function hidePictureModal() {
const modal = document.getElementById('picture-modal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
async function loadPicture() {
try {
const response = await fetch(pictureUrl);
if (!response.ok) {
showToast(await pictureError(response, pictureText.loadFailed));
return;
}
const state = await response.json();
pictureTitle = { image: state.image, source: state.source };
pictureSections = state.sections || [];
fillPictureTargets();
showPictureTarget(false);
} catch (error) {
showToast(`${pictureText.loadFailed}: ${error.message}`);
}
}
function findStep(target) {
if (!target) return null;
const section = pictureSections.find(s => s.section === target.section);
return (section && section.steps.find(s => s.step === target.step)) || null;
}
function stepLabel(step) {
const text = step.text.length > STEP_TEXT_LENGTH
? `${step.text.slice(0, STEP_TEXT_LENGTH - 1).trimEnd()}…`
: step.text;
const label = pictureText.step.replace('{step}', step.step).replace('{text}', text);
return step.image ? `${label} 📷` : label;
}
function fillPictureTargets() {
const select = document.getElementById('picture-target');
if (!select) return;
if (!findStep(pictureTarget)) pictureTarget = null;
const option = (value, label) => {
const element = document.createElement('option');
element.value = value;
element.textContent = label;
return element;
};
select.replaceChildren(option('', pictureText.title));
const grouped = pictureSections.length > 1 || pictureSections.some(s => s.name);
for (const section of pictureSections) {
let parent = select;
if (grouped) {
parent = document.createElement('optgroup');
parent.label = section.name || pictureText.section.replace('{section}', section.section);
select.append(parent);
}
for (const step of section.steps) {
parent.append(option(`${section.section}.${step.step}`, stepLabel(step)));
}
}
select.value = pictureTarget ? `${pictureTarget.section}.${pictureTarget.step}` : '';
document.getElementById('picture-target-row').classList.toggle('hidden', pictureSections.length === 0);
}
function parsePictureTarget(value) {
const match = /^(\d+)\.(\d+)$/.exec(value || '');
return match ? { section: Number(match[1]), step: Number(match[2]) } : null;
}
function showPictureTarget(changed) {
const step = findStep(pictureTarget);
const state = step
? { image: step.image, source: step.image ? 'file' : null }
: pictureTitle;
renderPicture(state, changed);
}
function applyPictureState(state) {
const step = findStep(state.step ? state : null);
if (step) {
step.image = state.image;
const option = document.querySelector(`#picture-target option[value="${state.section}.${state.step}"]`);
if (option) option.textContent = stepLabel(step);
} else {
pictureTitle = { image: state.image, source: state.source };
}
renderPicture(state, true);
}
function renderPicture(state, changed) {
pictureSource = state.source;
const forStep = pictureTarget !== null;
document.getElementById('picture-heading').textContent = forStep ? pictureText.stepTitle : pictureText.title;
document.getElementById('picture-step-note').classList.toggle('hidden', !forStep);
const preview = document.getElementById('picture-preview');
const empty = document.getElementById('picture-empty');
if (state.image) {
preview.src = changed && state.source === 'file'
? `${state.image}?v=${Date.now()}`
: state.image;
preview.classList.remove('hidden');
empty.classList.add('hidden');
} else {
preview.removeAttribute('src');
preview.classList.add('hidden');
empty.classList.remove('hidden');
}
const fromMetadata = state.source === 'metadata';
document.getElementById('picture-metadata-note').classList.toggle('hidden', !fromMetadata);
document.getElementById('picture-remove').classList.toggle('hidden', state.source !== 'file');
const choose = document.getElementById('picture-choose');
choose.textContent = state.source === 'file' ? pictureText.replace : pictureText.choose;
choose.classList.toggle('hidden', fromMetadata);
const hasPicture = Boolean(state.image);
const close = document.getElementById('picture-close');
close.textContent = hasPicture ? pictureText.done : pictureText.close;
close.classList.toggle('btn-primary', hasPicture);
choose.classList.toggle('btn-primary', !hasPicture);
if (hasPicture) {
choose.after(close);
} else {
choose.before(close);
}
updatePictureControls();
}
function updatePictureControls() {
const locked = pictureBusy || pictureSource === 'metadata';
document.getElementById('picture-choose').disabled = locked;
document.getElementById('picture-remove').disabled = locked;
document.getElementById('picture-drop').classList.toggle('cursor-pointer', !locked);
const select = document.getElementById('picture-target');
if (select) select.disabled = pictureBusy;
}
function setPictureBusy(label) {
pictureBusy = label !== null;
document.getElementById('picture-status').textContent = label || '';
updatePictureControls();
}
function choosePicture() {
if (pictureBusy || pictureSource === 'metadata') return;
document.getElementById('picture-input').click();
}
async function shrinkPicture(file) {
const url = URL.createObjectURL(file);
try {
const img = new Image();
img.src = url;
try {
await img.decode();
} catch (_) {
return file;
}
const scale = Math.min(1, MAX_IMAGE_EDGE / Math.max(img.naturalWidth, img.naturalHeight));
if (scale === 1 && file.type === 'image/jpeg') return file;
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
const context = canvas.getContext('2d');
context.fillStyle = '#ffffff';
context.fillRect(0, 0, canvas.width, canvas.height);
context.imageSmoothingQuality = 'high';
context.drawImage(img, 0, 0, canvas.width, canvas.height);
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', SHRINK_QUALITY));
return blob || file;
} finally {
URL.revokeObjectURL(url);
}
}
async function uploadPicture(file) {
if (!file || pictureBusy || pictureSource === 'metadata') return;
setPictureBusy(pictureText.uploading);
try {
const body = await shrinkPicture(file);
if (body.size > MAX_IMAGE_BYTES) {
showToast(pictureText.tooLarge);
return;
}
const response = await fetch(pictureUrlFor(pictureTarget), {
method: 'PUT',
headers: { 'Content-Type': body.type || 'application/octet-stream' },
body,
});
if (response.ok) {
applyPictureState(await response.json());
} else {
showToast(await pictureError(response, pictureText.uploadFailed));
}
} catch (error) {
showToast(`${pictureText.uploadFailed}: ${error.message}`);
} finally {
setPictureBusy(null);
document.getElementById('picture-input').value = '';
}
}
function askRemovePicture() {
if (pictureBusy) return;
document.getElementById('picture-actions').classList.add('hidden');
document.getElementById('picture-confirm').classList.remove('hidden');
}
function cancelRemovePicture() {
document.getElementById('picture-confirm').classList.add('hidden');
document.getElementById('picture-actions').classList.remove('hidden');
}
async function removePicture() {
cancelRemovePicture();
setPictureBusy(pictureText.removing);
try {
const response = await fetch(pictureUrlFor(pictureTarget), { method: 'DELETE' });
if (response.ok) {
applyPictureState(await response.json());
} else {
showToast(await pictureError(response, pictureText.removeFailed));
loadPicture();
}
} catch (error) {
showToast(`${pictureText.removeFailed}: ${error.message}`);
} finally {
setPictureBusy(null);
}
}
async function pictureError(response, fallback) {
if (response.status === 413) return pictureText.tooLarge;
let body = null;
try {
body = await response.json();
} catch (_) {
}
if (body && body.code === 'heif') return pictureText.heif;
if (body && body.code === 'too_large') return pictureText.tooLarge;
return `${fallback}: ${(body && body.error) || response.statusText}`;
}
document.getElementById('picture-input').addEventListener('change', function() {
uploadPicture(this.files[0]);
});
const pictureDrop = document.getElementById('picture-drop');
pictureDrop.addEventListener('dragover', function(e) {
e.preventDefault();
if (!pictureBusy && pictureSource !== 'metadata') {
this.classList.add('border-accent');
}
});
pictureDrop.addEventListener('dragleave', function() {
this.classList.remove('border-accent');
});
pictureDrop.addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('border-accent');
uploadPicture(e.dataTransfer.files[0]);
});
document.getElementById('picture-modal').addEventListener('click', function(e) {
if (e.target === this) {
hidePictureModal();
}
});
document.getElementById('picture-modal').addEventListener('dragover', e => e.preventDefault());
document.getElementById('picture-modal').addEventListener('drop', e => e.preventDefault());
document.getElementById('picture-target')?.addEventListener('change', function() {
pictureTarget = parsePictureTarget(this.value);
cancelRemovePicture();
showPictureTarget(false);
});
document.addEventListener('DOMContentLoaded', function() {
const requested = /^#picture=(\d+\.\d+)$/.exec(window.location.hash);
if (requested) {
pictureTarget = parsePictureTarget(requested[1]);
history.replaceState(null, '', window.location.pathname + window.location.search);
showPictureModal();
}
});
let lspSocket = null;
let lspMessageId = 1;
let documentUri = null;
let documentVersion = 0;
let lspInitialized = false;
let pendingCompletions = new Map();
let lspDebounceTimer = null;
const LSP_DEBOUNCE_MS = 150;
function connectLsp() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}{{ prefix }}/api/ws/lsp`;
console.log('Connecting to LSP at:', wsUrl);
lspSocket = new WebSocket(wsUrl);
lspSocket.onopen = function() {
console.log('LSP WebSocket connected');
updateLspStatus('connected');
sendLspRequest('initialize', {
processId: null,
rootUri: 'file://' + basePath,
capabilities: {
textDocument: {
completion: {
completionItem: {
snippetSupport: false
}
},
publishDiagnostics: {
relatedInformation: true
}
}
}
});
};
lspSocket.onclose = function() {
console.log('LSP WebSocket closed');
updateLspStatus('disconnected');
setTimeout(connectLsp, LSP_RECONNECT_DELAY_MS);
};
lspSocket.onerror = function(error) {
console.error('LSP WebSocket error:', error);
updateLspStatus('error');
};
lspSocket.onmessage = function(event) {
try {
const message = JSON.parse(event.data);
handleLspMessage(message);
} catch (e) {
console.error('Failed to parse LSP message:', e);
}
};
}
function sendLspRequest(method, params) {
if (!lspSocket || lspSocket.readyState !== WebSocket.OPEN) {
return null;
}
const id = lspMessageId++;
const message = {
jsonrpc: '2.0',
id: id,
method: method,
params: params
};
console.log('Sending LSP request:', method);
lspSocket.send(JSON.stringify(message));
return id;
}
function sendLspNotification(method, params) {
if (!lspSocket || lspSocket.readyState !== WebSocket.OPEN) {
return;
}
const message = {
jsonrpc: '2.0',
method: method,
params: params
};
lspSocket.send(JSON.stringify(message));
}
function openDocument() {
documentUri = 'file://' + recipePath;
documentVersion = 1;
sendLspNotification('textDocument/didOpen', {
textDocument: {
uri: documentUri,
languageId: 'cooklang',
version: documentVersion,
text: window.CooklangEditor.getContent(editorView)
}
});
console.log('Document opened:', documentUri);
}
function handleDiagnostics(params) {
if (!editorView) return;
const doc = editorView.state.doc;
const diagnostics = (params.diagnostics || []).map(d => {
const startLine = Math.min(d.range.start.line + 1, doc.lines);
const endLine = Math.min(d.range.end.line + 1, doc.lines);
const line1 = doc.line(startLine);
const line2 = doc.line(endLine);
const from = line1.from + Math.min(d.range.start.character, line1.length);
const to = line2.from + Math.min(d.range.end.character, line2.length);
return {
from: Math.max(0, from),
to: Math.max(from, to),
severity: d.severity === 1 ? 'error' : d.severity === 2 ? 'warning' : 'info',
message: d.message
};
});
window.CooklangEditor.setDiagnostics(editorView, diagnostics);
const errorCount = diagnostics.filter(d => d.severity === 'error').length;
const warningCount = diagnostics.filter(d => d.severity === 'warning').length;
console.log(`Diagnostics: ${errorCount} errors, ${warningCount} warnings`);
}
function handleLspMessage(message) {
if (message.id !== undefined) {
if (pendingCompletions.has(message.id)) {
const resolve = pendingCompletions.get(message.id);
pendingCompletions.delete(message.id);
const items = message.result?.items || message.result || [];
resolve(items);
return;
}
if (message.result && message.result.capabilities) {
sendLspNotification('initialized', {});
lspInitialized = true;
openDocument();
console.log('LSP initialized successfully');
}
} else if (message.method) {
console.log('LSP notification:', message.method);
if (message.method === 'textDocument/publishDiagnostics') {
handleDiagnostics(message.params);
}
}
}
function updateLspStatus(status) {
const indicator = document.getElementById('lsp-indicator');
const text = document.getElementById('lsp-text');
if (!indicator || !text) return;
switch (status) {
case 'connected':
indicator.className = 'w-2 h-2 rounded-full bg-ok';
text.textContent = 'LSP Connected';
text.className = 'text-ok';
break;
case 'disconnected':
indicator.className = 'w-2 h-2 rounded-full bg-inactive';
text.textContent = 'Disconnected';
text.className = 'text-muted';
break;
case 'error':
indicator.className = 'w-2 h-2 rounded-full bg-danger';
text.textContent = 'LSP Error';
text.className = 'text-danger';
break;
}
}
setTimeout(connectLsp, LSP_INITIAL_CONNECT_DELAY_MS);
window.CooklangEditor.setCompletionResolver(async (line, character) => {
return new Promise((resolve) => {
if (!lspInitialized || !documentUri) {
resolve([]);
return;
}
const id = sendLspRequest('textDocument/completion', {
textDocument: { uri: documentUri },
position: { line: line, character: character }
});
if (id) {
pendingCompletions.set(id, resolve);
setTimeout(() => {
if (pendingCompletions.has(id)) {
pendingCompletions.delete(id);
resolve([]);
}
}, COMPLETION_TIMEOUT_MS);
} else {
resolve([]);
}
});
});
</script>
{% endblock %}