Edit page: auto-expand step textareas, wider layout, image management

- Textareas auto-resize to fit content (min 120px)
- Edit page container widened to 1100px
- Show recipe image with URL input and file upload options
- Add image upload endpoint (POST /api/recipes/<backend>/<id>/image)
- Add upload_image_bytes() to both Mealie and Tandoor clients

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 09:07:56 +01:00
parent ae2f0e062f
commit e746dc10c9
4 changed files with 218 additions and 16 deletions
+33
View File
@@ -349,6 +349,39 @@ def api_update_recipe(backend, recipe_id):
return jsonify({"ok": False, "error": str(exc)}) return jsonify({"ok": False, "error": str(exc)})
@app.route("/api/recipes/<backend>/<path:recipe_id>/image", methods=["POST"])
def api_upload_image(backend, recipe_id):
"""AJAX — upload an image file to a recipe."""
cfg = config.load()
if "image" not in request.files:
return jsonify({"ok": False, "error": "Nincs fájl."})
file = request.files["image"]
if not file.filename:
return jsonify({"ok": False, "error": "Nincs fájl."})
content_type = file.content_type or "image/jpeg"
image_data = file.read()
try:
if backend == "mealie":
if not cfg.get("mealie_url") or not cfg.get("mealie_api_key"):
return jsonify({"ok": False, "error": "Mealie nincs beállítva."})
client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"],
api_url=config.MEALIE_INTERNAL_URL)
client.upload_image_bytes(recipe_id, image_data, content_type)
elif backend == "tandoor":
if not cfg.get("tandoor_url") or not cfg.get("tandoor_api_key"):
return jsonify({"ok": False, "error": "Tandoor nincs beállítva."})
client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"],
api_url=config.TANDOOR_INTERNAL_URL)
client.upload_image_bytes(int(recipe_id), image_data, content_type)
else:
return jsonify({"ok": False, "error": "Ismeretlen backend."})
return jsonify({"ok": True, "message": "Kép feltöltve."})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)})
@app.route("/api/recipes/<backend>/delete", methods=["POST"]) @app.route("/api/recipes/<backend>/delete", methods=["POST"])
def api_delete_recipes(backend): def api_delete_recipes(backend):
"""AJAX — delete one or more recipes.""" """AJAX — delete one or more recipes."""
+19
View File
@@ -214,6 +214,25 @@ class MealieClient:
r = self.session.delete(f"{self.api_url}/api/recipes/{slug}", timeout=10) r = self.session.delete(f"{self.api_url}/api/recipes/{slug}", timeout=10)
r.raise_for_status() r.raise_for_status()
def upload_image_bytes(self, slug: str, image_data: bytes,
content_type: str = "image/jpeg") -> None:
"""Upload raw image bytes to a recipe."""
ext = "jpg"
if "png" in content_type:
ext = "png"
elif "webp" in content_type:
ext = "webp"
files = {
"image": (f"recipe.{ext}", io.BytesIO(image_data), content_type),
}
r = self.session.put(
f"{self.api_url}/api/recipes/{slug}/image",
files=files,
data={"extension": ext},
timeout=30,
)
r.raise_for_status()
def create_recipe(self, recipe: dict) -> str: def create_recipe(self, recipe: dict) -> str:
"""Create a recipe in Mealie from a scraper result dict. """Create a recipe in Mealie from a scraper result dict.
+18
View File
@@ -235,6 +235,24 @@ class TandoorClient:
"url": f"{self.base_url}/view/recipe/{recipe_id}", "url": f"{self.base_url}/view/recipe/{recipe_id}",
} }
def upload_image_bytes(self, recipe_id: int, image_data: bytes,
content_type: str = "image/jpeg") -> None:
"""Upload raw image bytes to a recipe."""
ext = "jpg"
if "png" in content_type:
ext = "png"
elif "webp" in content_type:
ext = "webp"
files = {
"image": (f"recipe.{ext}", io.BytesIO(image_data), content_type),
}
r = self.session.put(
f"{self.api_url}/api/recipe/{recipe_id}/image/",
files=files,
timeout=30,
)
r.raise_for_status()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Internal # Internal
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+148 -16
View File
@@ -3,13 +3,10 @@
{% block head %} {% block head %}
<style> <style>
/* Wider layout for edit page */
.container { max-width: 1100px; }
.section-divider { border: none; border-top: 1px solid var(--border); margin: 1.2rem 0; } .section-divider { border: none; border-top: 1px solid var(--border); margin: 1.2rem 0; }
.recipe-image {
max-width: 300px;
max-height: 200px;
border-radius: var(--radius);
object-fit: cover;
}
.ingredient-header { .ingredient-header {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -84,7 +81,7 @@
flex-shrink: 0; flex-shrink: 0;
margin-top: 0.4rem; margin-top: 0.4rem;
} }
.instruction-row textarea { margin-bottom: 0; flex: 1; min-height: 120px; } .instruction-row textarea { margin-bottom: 0; flex: 1; min-height: 120px; overflow: hidden; }
.instruction-row button { .instruction-row button {
background: var(--danger); background: var(--danger);
border: none; border: none;
@@ -179,6 +176,56 @@
} }
.edit-top-bar h2 { margin-bottom: 0; flex: 1; } .edit-top-bar h2 { margin-bottom: 0; flex: 1; }
/* Image section */
.image-section {
margin-top: 0.5rem;
}
.image-preview {
max-width: 350px;
max-height: 250px;
border-radius: var(--radius);
object-fit: cover;
border: 1px solid var(--border);
}
.image-controls {
margin-top: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: flex-end;
}
.image-controls .url-input-group {
flex: 1;
min-width: 200px;
}
.image-controls .url-input-group input {
margin-bottom: 0;
}
.image-controls .url-input-group label {
font-size: 0.8rem;
}
.file-upload-label {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.6rem 1rem;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-dim);
cursor: pointer;
font-size: 0.9rem;
transition: border-color 0.15s, color 0.15s;
}
.file-upload-label:hover {
border-color: var(--accent);
color: var(--text);
}
.image-upload-status {
font-size: 0.85rem;
margin-top: 0.5rem;
}
/* Loading */ /* Loading */
.loading-wrap { .loading-wrap {
text-align: center; text-align: center;
@@ -221,17 +268,31 @@
<a href="/recipes" class="btn btn-secondary">&#8592; Vissza</a> <a href="/recipes" class="btn btn-secondary">&#8592; Vissza</a>
</div> </div>
<div class="flex flex-wrap mb-2"> <label for="recipeTitle">Név</label>
<div class="grow"> <input type="text" id="recipeTitle">
<label for="recipeTitle">Név</label>
<input type="text" id="recipeTitle">
<label for="recipeDesc">Leírás</label> <label for="recipeDesc">Leírás</label>
<textarea id="recipeDesc" rows="2"></textarea> <textarea id="recipeDesc" rows="2"></textarea>
</div>
<!-- Image section -->
<div class="image-section">
<label>Kép</label>
<div> <div>
<img id="recipeImage" class="recipe-image" src="" alt="" style="display:none;"> <img id="recipeImage" class="image-preview" src="" alt="" style="display:none;">
<div id="noImageText" class="text-dim" style="display:none; font-size:0.9rem;">Nincs kép</div>
</div> </div>
<div class="image-controls">
<div class="url-input-group">
<label for="imageUrlInput">Kép URL</label>
<input type="url" id="imageUrlInput" placeholder="https://...">
</div>
<button class="btn btn-secondary" onclick="setImageFromUrl()" id="setUrlBtn" style="margin-bottom:0;">Beállítás</button>
<label class="file-upload-label" style="margin-bottom:0;">
&#128247; Fájl feltöltése
<input type="file" id="imageFileInput" accept="image/*" onchange="uploadImageFile()" style="display:none;">
</label>
</div>
<div id="imageUploadStatus" class="image-upload-status"></div>
</div> </div>
<hr class="section-divider"> <hr class="section-divider">
@@ -333,10 +394,17 @@ function populateForm(r) {
document.getElementById('recipeTitle').value = r.title || ''; document.getElementById('recipeTitle').value = r.title || '';
document.getElementById('recipeDesc').value = r.description || ''; document.getElementById('recipeDesc').value = r.description || '';
// Image
const img = document.getElementById('recipeImage'); const img = document.getElementById('recipeImage');
const noImg = document.getElementById('noImageText');
if (r.image_url) { if (r.image_url) {
img.src = r.image_url; img.src = r.image_url;
img.style.display = 'block'; img.style.display = 'block';
noImg.style.display = 'none';
document.getElementById('imageUrlInput').value = r.image_url;
} else {
img.style.display = 'none';
noImg.style.display = 'block';
} }
// Ingredients // Ingredients
@@ -379,6 +447,13 @@ function addIngredientGroup(name) {
list.appendChild(row); list.appendChild(row);
} }
/* ===== Auto-resize textarea ===== */
function autoResize(ta) {
ta.style.height = 'auto';
const h = Math.max(120, ta.scrollHeight);
ta.style.height = h + 'px';
}
/* ===== Instructions ===== */ /* ===== Instructions ===== */
function addInstruction(value) { function addInstruction(value) {
const list = document.getElementById('instructionsList'); const list = document.getElementById('instructionsList');
@@ -389,6 +464,10 @@ function addInstruction(value) {
+ '<textarea>' + escHtml(value) + '</textarea>' + '<textarea>' + escHtml(value) + '</textarea>'
+ '<button onclick="removeInstruction(this)">&#10005;</button>'; + '<button onclick="removeInstruction(this)">&#10005;</button>';
list.appendChild(row); list.appendChild(row);
const ta = row.querySelector('textarea');
ta.addEventListener('input', function() { autoResize(this); });
// Auto-resize after content is set
setTimeout(() => autoResize(ta), 0);
} }
function removeInstruction(btn) { function removeInstruction(btn) {
@@ -499,6 +578,59 @@ document.addEventListener('click', e => {
} }
}); });
/* ===== Image ===== */
function setImageFromUrl() {
const url = document.getElementById('imageUrlInput').value.trim();
if (!url) return;
const img = document.getElementById('recipeImage');
const noImg = document.getElementById('noImageText');
img.src = url;
img.style.display = 'block';
noImg.style.display = 'none';
if (currentRecipe) currentRecipe.image_url = url;
showToast('Kép URL beállítva. Mentsd a receptet a véglegesítéshez.', 'success');
}
async function uploadImageFile() {
const fileInput = document.getElementById('imageFileInput');
const file = fileInput.files[0];
if (!file) return;
const status = document.getElementById('imageUploadStatus');
status.innerHTML = '<span class="spinner"></span> Feltöltés...';
const formData = new FormData();
formData.append('image', file);
try {
const resp = await fetch('/api/recipes/' + BACKEND + '/' + encodeURIComponent(RECIPE_ID) + '/image', {
method: 'POST',
body: formData,
});
const data = await resp.json();
if (data.ok) {
status.innerHTML = '<span class="text-success">&#10003; Kép feltöltve</span>';
// Reload recipe to get new image URL
const r2 = await fetch('/api/recipes/' + BACKEND + '/' + encodeURIComponent(RECIPE_ID));
const d2 = await r2.json();
if (d2.ok && d2.recipe.image_url) {
const img = document.getElementById('recipeImage');
img.src = d2.recipe.image_url + '?t=' + Date.now();
img.style.display = 'block';
document.getElementById('noImageText').style.display = 'none';
document.getElementById('imageUrlInput').value = d2.recipe.image_url;
if (currentRecipe) currentRecipe.image_url = d2.recipe.image_url;
}
showToast('Kép sikeresen feltöltve.', 'success');
} else {
status.innerHTML = '<span class="text-danger">Hiba: ' + escHtml(data.error) + '</span>';
}
} catch (e) {
status.innerHTML = '<span class="text-danger">Hiba: ' + escHtml(e.message) + '</span>';
}
fileInput.value = '';
}
/* ===== Gather form data ===== */ /* ===== Gather form data ===== */
function gatherRecipe() { function gatherRecipe() {
const ingredients = []; const ingredients = [];
@@ -531,7 +663,7 @@ function gatherRecipe() {
return { return {
title: document.getElementById('recipeTitle').value.trim(), title: document.getElementById('recipeTitle').value.trim(),
description: document.getElementById('recipeDesc').value.trim(), description: document.getElementById('recipeDesc').value.trim(),
image_url: currentRecipe ? currentRecipe.image_url : null, image_url: document.getElementById('imageUrlInput').value.trim() || (currentRecipe ? currentRecipe.image_url : ''),
ingredients: ingredients, ingredients: ingredients,
instructions: instructions, instructions: instructions,
tags: tags, tags: tags,