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:
+33
@@ -349,6 +349,39 @@ def api_update_recipe(backend, recipe_id):
|
||||
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"])
|
||||
def api_delete_recipes(backend):
|
||||
"""AJAX — delete one or more recipes."""
|
||||
|
||||
@@ -214,6 +214,25 @@ class MealieClient:
|
||||
r = self.session.delete(f"{self.api_url}/api/recipes/{slug}", timeout=10)
|
||||
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:
|
||||
"""Create a recipe in Mealie from a scraper result dict.
|
||||
|
||||
|
||||
@@ -235,6 +235,24 @@ class TandoorClient:
|
||||
"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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
+148
-16
@@ -3,13 +3,10 @@
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
/* Wider layout for edit page */
|
||||
.container { max-width: 1100px; }
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -84,7 +81,7 @@
|
||||
flex-shrink: 0;
|
||||
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 {
|
||||
background: var(--danger);
|
||||
border: none;
|
||||
@@ -179,6 +176,56 @@
|
||||
}
|
||||
.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-wrap {
|
||||
text-align: center;
|
||||
@@ -221,17 +268,31 @@
|
||||
<a href="/recipes" class="btn btn-secondary">← Vissza</a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap mb-2">
|
||||
<div class="grow">
|
||||
<label for="recipeTitle">Név</label>
|
||||
<input type="text" id="recipeTitle">
|
||||
<label for="recipeTitle">Név</label>
|
||||
<input type="text" id="recipeTitle">
|
||||
|
||||
<label for="recipeDesc">Leírás</label>
|
||||
<textarea id="recipeDesc" rows="2"></textarea>
|
||||
</div>
|
||||
<label for="recipeDesc">Leírás</label>
|
||||
<textarea id="recipeDesc" rows="2"></textarea>
|
||||
|
||||
<!-- Image section -->
|
||||
<div class="image-section">
|
||||
<label>Kép</label>
|
||||
<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 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;">
|
||||
📷 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>
|
||||
|
||||
<hr class="section-divider">
|
||||
@@ -333,10 +394,17 @@ function populateForm(r) {
|
||||
document.getElementById('recipeTitle').value = r.title || '';
|
||||
document.getElementById('recipeDesc').value = r.description || '';
|
||||
|
||||
// Image
|
||||
const img = document.getElementById('recipeImage');
|
||||
const noImg = document.getElementById('noImageText');
|
||||
if (r.image_url) {
|
||||
img.src = r.image_url;
|
||||
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
|
||||
@@ -379,6 +447,13 @@ function addIngredientGroup(name) {
|
||||
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 ===== */
|
||||
function addInstruction(value) {
|
||||
const list = document.getElementById('instructionsList');
|
||||
@@ -389,6 +464,10 @@ function addInstruction(value) {
|
||||
+ '<textarea>' + escHtml(value) + '</textarea>'
|
||||
+ '<button onclick="removeInstruction(this)">✕</button>';
|
||||
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) {
|
||||
@@ -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">✓ 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 ===== */
|
||||
function gatherRecipe() {
|
||||
const ingredients = [];
|
||||
@@ -531,7 +663,7 @@ function gatherRecipe() {
|
||||
return {
|
||||
title: document.getElementById('recipeTitle').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,
|
||||
instructions: instructions,
|
||||
tags: tags,
|
||||
|
||||
Reference in New Issue
Block a user