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)})
|
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."""
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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">← Vissza</a>
|
<a href="/recipes" class="btn btn-secondary">← 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;">
|
||||||
|
📷 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)">✕</button>';
|
+ '<button onclick="removeInstruction(this)">✕</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">✓ 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user