Files
recipe-importer/app/templates/import.html
T
admin 458b1e362a feat: Tandoor integration — settings, test connection, import, duplicate detection
Add TandoorClient (app/tandoor.py) with full recipe creation, image upload,
and duplicate detection via the Tandoor REST API. Settings page now has
separate Mealie and Tandoor sections. Import page shows both send buttons
based on which services are configured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:29:58 +01:00

446 lines
15 KiB
HTML

{% extends "base.html" %}
{% block title %}Importálás — Recept Importáló{% endblock %}
{% block head %}
<style>
.recipe-preview { display: none; }
.recipe-preview.visible { display: block; }
.recipe-image {
max-width: 300px;
max-height: 200px;
border-radius: var(--radius);
object-fit: cover;
}
.ingredient-header {
display: flex;
gap: 0.5rem;
margin-bottom: 0.3rem;
padding-right: 34px; /* space for remove btn */
}
.ingredient-header span {
font-size: 0.8rem;
color: var(--text-dim);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.ingredient-header .col-qty { width: 60px; flex-shrink: 0; }
.ingredient-header .col-unit { width: 80px; flex-shrink: 0; }
.ingredient-header .col-food { flex: 1; }
.ingredient-header .col-extra { width: 120px; flex-shrink: 0; }
.ingredient-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.4rem;
}
.ingredient-row input { margin-bottom: 0; }
.ingredient-row .ing-qty { width: 60px; flex-shrink: 0; }
.ingredient-row .ing-unit { width: 80px; flex-shrink: 0; }
.ingredient-row .ing-food { flex: 1; }
.ingredient-row .ing-extra { width: 120px; flex-shrink: 0; }
.ingredient-row button {
background: var(--danger);
border: none;
color: #fff;
width: 28px;
height: 28px;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
}
.ingredient-group {
display: flex;
gap: 0.5rem;
align-items: center;
margin: 0.8rem 0 0.3rem;
}
.ingredient-group input {
margin-bottom: 0;
flex: 1;
font-weight: 600;
color: var(--accent);
border-style: dashed;
}
.ingredient-group button {
background: var(--danger);
border: none;
color: #fff;
width: 28px;
height: 28px;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
}
.instruction-row {
display: flex;
gap: 0.5rem;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.instruction-row .step-num {
background: var(--accent);
color: #fff;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
font-weight: 700;
flex-shrink: 0;
margin-top: 0.4rem;
}
.instruction-row textarea { margin-bottom: 0; flex: 1; min-height: 60px; }
.instruction-row button {
background: var(--danger);
border: none;
color: #fff;
width: 28px;
height: 28px;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
margin-top: 0.4rem;
}
.add-btn {
background: var(--surface2);
border: 1px dashed var(--border);
color: var(--text-dim);
padding: 0.4rem 0.8rem;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.9rem;
transition: border-color 0.15s;
}
.add-btn:hover { border-color: var(--accent); color: var(--text); }
.result-card { display: none; }
.result-card.visible { display: block; }
</style>
{% endblock %}
{% block content %}
<!-- Step 1: URL input -->
<div class="card">
<h2>Recept importálása</h2>
<div class="flex">
<input type="url" id="recipeUrl" class="grow" style="margin-bottom:0"
placeholder="https://www.mindmegette.hu/recept/brassoi">
<button class="btn btn-primary" id="scrapeBtn" onclick="scrapeRecipe()">
Beolvasás
</button>
</div>
<div id="scrapeStatus" class="mt-1"></div>
</div>
<!-- Step 2: Editable preview -->
<div class="recipe-preview" id="previewCard">
<div class="card">
<h2>Recept adatai</h2>
<div class="flex flex-wrap mb-2">
<div class="grow">
<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>
<div>
<img id="recipeImage" class="recipe-image" src="" alt="">
</div>
</div>
<!-- Ingredients -->
<label>Hozzávalók</label>
<div class="ingredient-header">
<span class="col-qty">Menny.</span>
<span class="col-unit">Egység</span>
<span class="col-food">Hozzávaló</span>
<span class="col-extra">Megjegyzés</span>
</div>
<div id="ingredientsList"></div>
<div class="flex gap-1 mt-1 mb-2">
<button class="add-btn" onclick="addIngredient({})">+ Hozzávaló</button>
<button class="add-btn" onclick="addIngredientGroup('')">+ Csoport</button>
</div>
<!-- Instructions -->
<label>Elkészítés</label>
<div id="instructionsList"></div>
<button class="add-btn mt-1 mb-2" onclick="addInstruction('')">+ Lépés hozzáadása</button>
<div class="flex mt-2">
{% if has_mealie %}
<button class="btn btn-success" id="sendMealieBtn" onclick="sendToMealie()">
Importálás Mealie-be
</button>
{% endif %}
{% if has_tandoor %}
<button class="btn btn-success" id="sendTandoorBtn" onclick="sendToTandoor()">
Importálás Tandoor-ba
</button>
{% endif %}
<span id="sendStatus"></span>
</div>
</div>
</div>
<!-- Step 3: Result -->
<div class="result-card" id="resultCard">
<div class="card">
<h2 class="text-success">Recept sikeresen importálva!</h2>
<p class="mt-1" id="resultLinks"></p>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentRecipe = null;
async function scrapeRecipe() {
const url = document.getElementById('recipeUrl').value.trim();
if (!url) return;
const btn = document.getElementById('scrapeBtn');
const status = document.getElementById('scrapeStatus');
btn.disabled = true;
status.innerHTML = '<span class="spinner"></span> Beolvasás folyamatban...';
document.getElementById('previewCard').classList.remove('visible');
document.getElementById('resultCard').classList.remove('visible');
Object.keys(importedLinks).forEach(k => delete importedLinks[k]);
try {
const form = new FormData();
form.append('url', url);
const resp = await fetch('/scrape', { method: 'POST', body: form });
const data = await resp.json();
if (!data.ok) {
status.innerHTML = '<span class="text-danger">Hiba: ' + data.error + '</span>';
btn.disabled = false;
return;
}
currentRecipe = data.data;
populatePreview(currentRecipe);
document.getElementById('previewCard').classList.add('visible');
const warnings = [];
if (data.duplicate) {
warnings.push('Mealie: <a href="' + escHtml(data.duplicate.url) + '" target="_blank" style="color:var(--accent)">'
+ escHtml(data.duplicate.name) + '</a>');
}
if (data.tandoor_duplicate) {
warnings.push('Tandoor: <a href="' + escHtml(data.tandoor_duplicate.url) + '" target="_blank" style="color:var(--accent)">'
+ escHtml(data.tandoor_duplicate.name) + '</a>');
}
if (warnings.length > 0) {
status.innerHTML = '<span class="text-warning">⚠ Ez a recept már létezik: ' + warnings.join(' | ') + '</span>';
} else {
status.innerHTML = '<span class="text-success">✓ Beolvasva</span>';
}
} catch (e) {
status.innerHTML = '<span class="text-danger">Hálózati hiba: ' + e.message + '</span>';
}
btn.disabled = false;
}
function populatePreview(r) {
document.getElementById('recipeTitle').value = r.title || '';
document.getElementById('recipeDesc').value = r.description || '';
const img = document.getElementById('recipeImage');
if (r.image_url) {
img.src = r.image_url;
img.style.display = 'block';
} else {
img.style.display = 'none';
}
// Ingredients
const ingList = document.getElementById('ingredientsList');
ingList.innerHTML = '';
(r.ingredients || []).forEach(i => addIngredient(i));
// Instructions
const instList = document.getElementById('instructionsList');
instList.innerHTML = '';
(r.instructions || []).forEach(t => addInstruction(t));
}
function addIngredient(item) {
if (typeof item === 'string') item = { food: item };
// Group header marker
if (item.group !== undefined && item.food === undefined) {
addIngredientGroup(item.group);
return;
}
const list = document.getElementById('ingredientsList');
const row = document.createElement('div');
row.className = 'ingredient-row';
row.innerHTML = '<input type="text" class="ing-qty" placeholder="" value="' + escHtml(item.quantity || '') + '">'
+ '<input type="text" class="ing-unit" placeholder="" value="' + escHtml(item.unit || '') + '">'
+ '<input type="text" class="ing-food" placeholder="Hozzávaló" value="' + escHtml(item.food || '') + '">'
+ '<input type="text" class="ing-extra" placeholder="" value="' + escHtml(item.extra || '') + '">'
+ '<button onclick="this.parentElement.remove()">✕</button>';
list.appendChild(row);
}
function addIngredientGroup(name) {
const list = document.getElementById('ingredientsList');
const row = document.createElement('div');
row.className = 'ingredient-group';
row.innerHTML = '<input type="text" class="ing-group-name" placeholder="Csoport neve" value="' + escHtml(name || '') + '">'
+ '<button onclick="this.parentElement.remove()">✕</button>';
list.appendChild(row);
}
function addInstruction(value) {
const list = document.getElementById('instructionsList');
const idx = list.children.length + 1;
const row = document.createElement('div');
row.className = 'instruction-row';
row.innerHTML = '<span class="step-num">' + idx + '</span>'
+ '<textarea>' + escHtml(value) + '</textarea>'
+ '<button onclick="removeInstruction(this)">✕</button>';
list.appendChild(row);
}
function removeInstruction(btn) {
btn.closest('.instruction-row').remove();
renumberInstructions();
}
function renumberInstructions() {
document.querySelectorAll('#instructionsList .step-num').forEach((el, i) => {
el.textContent = i + 1;
});
}
function gatherRecipe() {
const ingredients = [];
document.querySelectorAll('#ingredientsList > div').forEach(el => {
if (el.classList.contains('ingredient-group')) {
const name = el.querySelector('.ing-group-name').value.trim();
if (name) ingredients.push({ group: name });
} else if (el.classList.contains('ingredient-row')) {
const qty = el.querySelector('.ing-qty').value.trim();
const unit = el.querySelector('.ing-unit').value.trim();
const food = el.querySelector('.ing-food').value.trim();
const extra = el.querySelector('.ing-extra').value.trim();
if (food || qty) {
ingredients.push({ quantity: qty, unit: unit, food: food, extra: extra });
}
}
});
const instructions = [];
document.querySelectorAll('#instructionsList .instruction-row textarea').forEach(ta => {
const v = ta.value.trim();
if (v) instructions.push(v);
});
return {
title: document.getElementById('recipeTitle').value.trim(),
description: document.getElementById('recipeDesc').value.trim(),
image_url: currentRecipe ? currentRecipe.image_url : null,
ingredients: ingredients,
instructions: instructions,
original_url: currentRecipe ? currentRecipe.original_url : '',
};
}
const importedLinks = {};
async function sendToMealie() {
const recipe = gatherRecipe();
if (!recipe.title) { alert('A recept neve kötelező!'); return; }
const btn = document.getElementById('sendMealieBtn');
const status = document.getElementById('sendStatus');
btn.disabled = true;
status.innerHTML = '<span class="spinner"></span> Importálás Mealie-be...';
try {
const resp = await fetch('/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recipe),
});
const data = await resp.json();
if (!data.ok) {
status.innerHTML = '<span class="text-danger">Hiba: ' + data.error + '</span>';
btn.disabled = false;
return;
}
status.innerHTML = '<span class="text-success">✓ Mealie kész</span>';
importedLinks['Mealie'] = data.url;
showResultCard();
} catch (e) {
status.innerHTML = '<span class="text-danger">Hálózati hiba: ' + e.message + '</span>';
}
btn.disabled = false;
}
async function sendToTandoor() {
const recipe = gatherRecipe();
if (!recipe.title) { alert('A recept neve kötelező!'); return; }
const btn = document.getElementById('sendTandoorBtn');
const status = document.getElementById('sendStatus');
btn.disabled = true;
status.innerHTML = '<span class="spinner"></span> Importálás Tandoor-ba...';
try {
const resp = await fetch('/send-tandoor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recipe),
});
const data = await resp.json();
if (!data.ok) {
status.innerHTML = '<span class="text-danger">Hiba: ' + data.error + '</span>';
btn.disabled = false;
return;
}
status.innerHTML = '<span class="text-success">✓ Tandoor kész</span>';
importedLinks['Tandoor'] = data.url;
showResultCard();
} catch (e) {
status.innerHTML = '<span class="text-danger">Hálózati hiba: ' + e.message + '</span>';
}
btn.disabled = false;
}
function showResultCard() {
const links = Object.entries(importedLinks).map(([name, url]) =>
'<a href="' + escHtml(url) + '" target="_blank" style="color:var(--accent);">Megnyitás ' + name + '-ben →</a>'
).join('<br>');
document.getElementById('resultLinks').innerHTML = links;
document.getElementById('resultCard').classList.add('visible');
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
</script>
{% endblock %}