615 lines
22 KiB
HTML
615 lines
22 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Tandoor AI Import</title>
|
||
<link rel="stylesheet" href="/shared/boehmi.css">
|
||
<script src="/shared/boehmi.js"></script>
|
||
<style>
|
||
/* ----------------------------------------------------------------
|
||
Tandoor AI Import · plugin-eigenes Layout.
|
||
Farben, Buttons, Felder, Karten kommen aus /shared/boehmi.css,
|
||
damit das Tool wie der Rest der Suite aussieht. Klassennamen und
|
||
Markup bleiben identisch zum Einzeltool.
|
||
---------------------------------------------------------------- */
|
||
|
||
/* Kompatibilitaets-Aliase der alten Variablennamen */
|
||
:root{
|
||
--bg:var(--bt-bg); --panel:var(--bt-surface); --panel-2:var(--bt-surface-2);
|
||
--text:var(--bt-ink); --muted:var(--bt-muted); --line:var(--bt-line);
|
||
--accent:var(--bt-accent); --danger:var(--bt-err);
|
||
--warning:var(--bt-warn); --info:var(--bt-info);
|
||
}
|
||
|
||
body > header{
|
||
padding: 18px clamp(14px, 4vw, 24px) 0;
|
||
width: min(1520px, 96vw); margin: 0 auto; border: 0; background: none;
|
||
}
|
||
body > header h1{
|
||
margin: 0; font-size: 22px; font-weight: 850; letter-spacing: -.02em;
|
||
}
|
||
body > header p{ margin: 5px 0 0; color: var(--bt-muted); }
|
||
|
||
main{ width: min(1520px, 96vw); margin: 14px auto 60px; }
|
||
|
||
.bar, .panel{
|
||
background: var(--bt-surface); border: 1px solid var(--bt-line);
|
||
border-radius: var(--bt-r-lg); box-shadow: var(--bt-shadow);
|
||
}
|
||
.bar{ display: grid; grid-template-columns: 1fr auto; gap: 10px; padding: 12px; }
|
||
.panel{ padding: 16px; }
|
||
|
||
textarea#jsonEditor{
|
||
min-height: 620px; font-family: var(--bt-mono);
|
||
font-size: 12.5px; line-height: 1.5; tab-size: 2;
|
||
background: var(--bt-surface-2);
|
||
}
|
||
|
||
button.secondary{
|
||
background: var(--bt-surface); color: var(--bt-ink); border: 1px solid var(--bt-line);
|
||
font-weight: 650;
|
||
}
|
||
button.secondary:hover{ background: var(--bt-bg-2); }
|
||
#analyze, #import{
|
||
background: var(--bt-accent); border-color: var(--bt-accent);
|
||
color: var(--bt-accent-ink); font-weight: 700;
|
||
}
|
||
#analyze:hover:not(:disabled), #import:hover:not(:disabled){
|
||
background: var(--bt-accent-hi); border-color: var(--bt-accent-hi);
|
||
}
|
||
|
||
.status{
|
||
display: flex; align-items: center; gap: 8px;
|
||
margin: 12px 0; padding: 9px 13px; min-height: 40px;
|
||
border: 1px solid var(--bt-line); border-radius: var(--bt-r);
|
||
background: var(--bt-surface); color: var(--bt-ink-soft); font-size: 13px;
|
||
box-shadow: var(--bt-shadow);
|
||
}
|
||
.status.error{ color: var(--bt-err); border-color: var(--bt-err); background: var(--bt-err-soft); }
|
||
.status.ok{ color: var(--bt-ok); border-color: var(--bt-ok); background: var(--bt-ok-soft); }
|
||
|
||
.layout{
|
||
display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(420px, .9fr);
|
||
gap: 16px; align-items: start;
|
||
}
|
||
|
||
.tabs{ display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
|
||
.tabs button{ padding: 6px 12px; font-size: 12.5px; border-radius: var(--bt-r-sm); }
|
||
.tabs button.secondary.active, .tabs button.active{
|
||
background: var(--bt-accent-soft); color: var(--bt-accent);
|
||
border-color: var(--bt-accent); outline: none;
|
||
}
|
||
.tab{ display: none; }
|
||
.tab.active{ display: block; }
|
||
|
||
.warnings{ display: grid; gap: 8px; margin-bottom: 16px; }
|
||
.warning{
|
||
padding: 10px 13px; font-size: 12.5px; color: var(--bt-ink-soft);
|
||
border: 1px solid var(--bt-line); border-left: 3px solid var(--bt-muted);
|
||
border-radius: 0 var(--bt-r) var(--bt-r) 0; background: var(--bt-surface-2);
|
||
}
|
||
.warning.blocking{ border-left-color: var(--bt-err); background: var(--bt-err-soft); }
|
||
.warning.warning{ border-left-color: var(--bt-warn); background: var(--bt-warn-soft); }
|
||
.warning.info{ border-left-color: var(--bt-info); background: var(--bt-info-soft); }
|
||
|
||
.recipe h2{ margin: 0 0 5px; font-size: 19px; font-weight: 800; letter-spacing: -.02em; }
|
||
.meta{ color: var(--bt-muted); margin-bottom: 18px; font-size: 12.5px; }
|
||
.step{ padding: 15px 0; border-top: 1px solid var(--bt-line-soft); }
|
||
.step h3{
|
||
margin: 0 0 9px; font-size: 11.5px; font-weight: 800;
|
||
letter-spacing: .09em; text-transform: uppercase; color: var(--bt-teal);
|
||
}
|
||
.ingredients{
|
||
padding: 11px 14px; background: var(--bt-surface-2);
|
||
border: 1px solid var(--bt-line-soft); border-radius: var(--bt-r); margin-bottom: 11px;
|
||
}
|
||
.ingredients ul{ margin: 0; padding-left: 19px; font-size: 13px; }
|
||
.instruction{ white-space: pre-line; line-height: 1.6; color: var(--bt-ink-soft); }
|
||
|
||
.mapping{ overflow-x: auto; }
|
||
table{ width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||
th{
|
||
text-align: left; padding: 9px 8px; border-bottom: 1px solid var(--bt-line);
|
||
font-size: 11px; font-weight: 800; letter-spacing: .07em;
|
||
text-transform: uppercase; color: var(--bt-muted);
|
||
}
|
||
td{ text-align: left; vertical-align: top; padding: 9px 8px; border-bottom: 1px solid var(--bt-line-soft); }
|
||
|
||
.pill{
|
||
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
||
font-size: 11px; font-weight: 700;
|
||
background: var(--bt-bg-2); border: 1px solid var(--bt-line-soft); color: var(--bt-ink-soft);
|
||
}
|
||
.pill.bad{ color: var(--bt-err); background: var(--bt-err-soft); border-color: transparent; }
|
||
.pill.good{ color: var(--bt-ok); background: var(--bt-ok-soft); border-color: transparent; }
|
||
|
||
.actions{
|
||
position: sticky; bottom: 12px; display: flex; gap: 10px;
|
||
justify-content: flex-end; align-items: center; padding: 11px;
|
||
margin-top: 14px; background: var(--bt-surface);
|
||
border: 1px solid var(--bt-line); border-radius: var(--bt-r-lg);
|
||
box-shadow: var(--bt-shadow-lg);
|
||
}
|
||
|
||
.empty{
|
||
color: var(--bt-muted); padding: 56px 10px; text-align: center;
|
||
font-size: 13px;
|
||
}
|
||
.checkbox{
|
||
display: flex; align-items: center; gap: 7px;
|
||
color: var(--bt-ink-soft); font-size: 13px; font-weight: 600;
|
||
margin-right: auto;
|
||
}
|
||
.checkbox input{ width: auto; }
|
||
|
||
@media (max-width: 950px){
|
||
.layout{ grid-template-columns: 1fr; }
|
||
.bar{ grid-template-columns: 1fr; }
|
||
textarea#jsonEditor{ min-height: 420px; }
|
||
.object-picker{ min-width: 0; }
|
||
}
|
||
|
||
/* --- ab v1.1: Zuordnungs-Dropdowns --------------------------------- */
|
||
button.small{ padding: 6px 11px; font-size: 12px; border-radius: var(--bt-r-sm); }
|
||
|
||
.pill.review{ color: var(--bt-warn); background: var(--bt-warn-soft); border-color: transparent; }
|
||
|
||
.object-picker{ min-width: 280px; display: grid; gap: 6px; }
|
||
.object-picker select{
|
||
font-size: 12.5px; padding: 7px 9px; background: var(--bt-surface);
|
||
font-family: var(--bt-mono);
|
||
}
|
||
.object-picker select:has(option[value=""]:checked){ border-color: var(--bt-err); }
|
||
|
||
.manual-row{ display: grid; grid-template-columns: 1fr auto; gap: 6px; }
|
||
.manual-row input{ font-size: 12.5px; padding: 7px 9px; }
|
||
|
||
.hint{ color: var(--bt-muted); font-size: 11.5px; line-height: 1.4; }
|
||
.source-text{ color: var(--bt-muted); font-size: 12px; line-height: 1.45; }
|
||
</style>
|
||
</head>
|
||
<body data-bt-title="Tandoor AI Import" data-bt-icon="🥕">
|
||
<header>
|
||
<h1>🥕 Tandoor AI Import</h1>
|
||
<p>URL analysieren, Rezept prüfen, Zutaten zuordnen und kontrolliert importieren.</p>
|
||
</header>
|
||
|
||
<main>
|
||
<section class="bar">
|
||
<input id="url" type="text" placeholder="example.com/rezept.html oder https://…" autocomplete="off">
|
||
<button id="analyze">URL analysieren</button>
|
||
</section>
|
||
<div id="status" class="status">Bereit.</div>
|
||
|
||
<section class="layout">
|
||
<div class="panel">
|
||
<div id="warnings" class="warnings"></div>
|
||
<div id="preview" class="recipe empty">Noch kein Rezept analysiert.</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="tabs">
|
||
<button class="secondary active" data-tab="mapping">Zuordnungen</button>
|
||
<button class="secondary" data-tab="json">JSON</button>
|
||
</div>
|
||
|
||
<div id="tab-mapping" class="tab active">
|
||
<div id="mapping" class="mapping empty">Noch keine Zuordnungen.</div>
|
||
</div>
|
||
|
||
<div id="tab-json" class="tab">
|
||
<textarea id="jsonEditor" spellcheck="false" placeholder="Das analysierte Rezept erscheint hier."></textarea>
|
||
</div>
|
||
|
||
<div class="actions">
|
||
<label class="checkbox">
|
||
<input id="importImage" type="checkbox" checked>
|
||
Bild übernehmen
|
||
</label>
|
||
<button id="validate" class="secondary" disabled>Änderungen prüfen</button>
|
||
<button id="import" disabled>In Tandoor importieren</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<script>
|
||
const state = {
|
||
runId: null,
|
||
recipe: null,
|
||
resolution: null,
|
||
warnings: [],
|
||
blocking: true,
|
||
};
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const status = $("status");
|
||
const analyzeButton = $("analyze");
|
||
const validateButton = $("validate");
|
||
const importButton = $("import");
|
||
const editor = $("jsonEditor");
|
||
|
||
function setStatus(message, kind = "") {
|
||
status.textContent = message;
|
||
status.className = "status " + kind;
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function amountText(ingredient) {
|
||
if (ingredient.no_amount) return "";
|
||
const base = Number(ingredient.amount).toLocaleString("de-DE");
|
||
const max = ingredient.amount_max == null
|
||
? ""
|
||
: "–" + Number(ingredient.amount_max).toLocaleString("de-DE");
|
||
const unit = ingredient.unit_name ? " " + ingredient.unit_name : "";
|
||
return `${base}${max}${unit}`;
|
||
}
|
||
|
||
function renderPreview() {
|
||
if (!state.recipe) {
|
||
$("preview").innerHTML = "Noch kein Rezept analysiert.";
|
||
$("preview").className = "recipe empty";
|
||
return;
|
||
}
|
||
const r = state.recipe;
|
||
const steps = r.steps.map(step => `
|
||
<section class="step">
|
||
<h3>${escapeHtml(step.name)}</h3>
|
||
${step.ingredients.length ? `
|
||
<div class="ingredients"><ul>
|
||
${step.ingredients.map(i => `
|
||
<li>
|
||
<strong>${escapeHtml(amountText(i))}</strong>
|
||
${escapeHtml(i.food_name)}
|
||
${i.note ? `<span>(${escapeHtml(i.note)})</span>` : ""}
|
||
</li>
|
||
`).join("")}
|
||
</ul></div>` : ""}
|
||
<div class="instruction">${escapeHtml(step.instruction)}</div>
|
||
</section>
|
||
`).join("");
|
||
|
||
$("preview").className = "recipe";
|
||
$("preview").innerHTML = `
|
||
<h2>${escapeHtml(r.name)}</h2>
|
||
<div class="meta">
|
||
${escapeHtml(r.servings_text || `${r.servings} Portionen`)}
|
||
· ${r.working_time} Min. Arbeit
|
||
· ${r.waiting_time} Min. Warte-/Garzeit
|
||
· Vertrauen: ${escapeHtml(r.confidence)}
|
||
</div>
|
||
${r.description ? `<p>${escapeHtml(r.description)}</p>` : ""}
|
||
${steps}
|
||
<p><a href="${escapeHtml(r.source_url)}" target="_blank" rel="noopener noreferrer">Originalquelle öffnen</a></p>
|
||
`;
|
||
}
|
||
|
||
function renderWarnings() {
|
||
const container = $("warnings");
|
||
if (!state.warnings.length) {
|
||
container.innerHTML = `<div class="warning info">Keine Warnungen. Der Küchenpass ist grün. ✅</div>`;
|
||
return;
|
||
}
|
||
container.innerHTML = state.warnings.map(w => `
|
||
<div class="warning ${escapeHtml(w.severity)}">
|
||
<strong>${escapeHtml(w.severity.toUpperCase())}</strong>
|
||
· ${escapeHtml(w.message)}
|
||
</div>
|
||
`).join("");
|
||
}
|
||
|
||
function objectFields(kind) {
|
||
return kind === "food"
|
||
? {
|
||
id: "preferred_food_id",
|
||
create: "create_food",
|
||
name: "food_name",
|
||
plural: "food_plural_name",
|
||
label: "Food",
|
||
}
|
||
: {
|
||
id: "preferred_unit_id",
|
||
create: "create_unit",
|
||
name: "unit_name",
|
||
plural: "unit_plural_name",
|
||
label: "Einheit",
|
||
};
|
||
}
|
||
|
||
function statusPill(resolution) {
|
||
if (!resolution) return `<span class="pill good">keine Einheit</span>`;
|
||
let css = resolution.blocking ? "bad" : (resolution.needs_review ? "review" : "good");
|
||
return `<span class="pill ${css}">${escapeHtml(resolution.status)}</span>`;
|
||
}
|
||
|
||
function objectPicker(mapping, kind) {
|
||
const resolution = mapping[kind];
|
||
if (!resolution) return `<span class="hint">Keine Einheit vorgesehen</span>`;
|
||
|
||
const fields = objectFields(kind);
|
||
const selectedId = resolution.selected_id;
|
||
const createSelected = resolution.status === "create";
|
||
const noSelection = resolution.blocking && !createSelected && selectedId == null;
|
||
const candidates = (resolution.candidates || []).map(candidate => {
|
||
const selected = Number(candidate.id) === Number(selectedId) && !createSelected;
|
||
const extras = [
|
||
candidate.reason,
|
||
`${candidate.score}%`,
|
||
candidate.has_properties ? "Properties" : "",
|
||
].filter(Boolean).join(" · ");
|
||
return `
|
||
<option value="id:${candidate.id}" data-name="${escapeHtml(candidate.name)}" ${selected ? "selected" : ""}>
|
||
${escapeHtml(candidate.name)} [${candidate.id}]${extras ? ` · ${escapeHtml(extras)}` : ""}
|
||
</option>
|
||
`;
|
||
}).join("");
|
||
|
||
const lookupName = resolution.lookup_name || resolution.requested || "";
|
||
return `
|
||
<div class="object-picker" data-kind="${kind}" data-step="${mapping.step_index}" data-ingredient="${mapping.ingredient_index}">
|
||
${statusPill(resolution)}
|
||
<select class="object-select">
|
||
<option value="" ${noSelection ? "selected" : ""}>Bitte zuordnen …</option>
|
||
${candidates}
|
||
<option value="create" ${createSelected ? "selected" : ""}>➕ Neu anlegen: ${escapeHtml(lookupName)}</option>
|
||
</select>
|
||
<div class="manual-row">
|
||
<input class="object-name" value="${escapeHtml(lookupName)}" aria-label="${fields.label}-Name">
|
||
<button class="secondary small object-search" type="button">Suchen</button>
|
||
</div>
|
||
<div class="hint">
|
||
${resolution.message ? escapeHtml(resolution.message) : "Treffer ist vorausgewählt und kann jederzeit geändert werden."}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderMappings() {
|
||
const container = $("mapping");
|
||
const rows = state.resolution?.mappings || [];
|
||
if (!rows.length) {
|
||
container.className = "mapping empty";
|
||
container.innerHTML = "Noch keine Zuordnungen.";
|
||
return;
|
||
}
|
||
|
||
container.className = "mapping";
|
||
container.innerHTML = `
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Quelle</th>
|
||
<th>Food-Zuordnung</th>
|
||
<th>Einheit-Zuordnung</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${rows.map(mapping => `
|
||
<tr>
|
||
<td>
|
||
<strong>${escapeHtml(mapping.step_name)}</strong><br>
|
||
<span class="source-text">${escapeHtml(mapping.original_text)}</span>
|
||
</td>
|
||
<td>${objectPicker(mapping, "food")}</td>
|
||
<td>${objectPicker(mapping, "unit")}</td>
|
||
</tr>
|
||
`).join("")}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
|
||
container.querySelectorAll(".object-picker").forEach(picker => {
|
||
const stepIndex = Number(picker.dataset.step);
|
||
const ingredientIndex = Number(picker.dataset.ingredient);
|
||
const kind = picker.dataset.kind;
|
||
const fields = objectFields(kind);
|
||
const select = picker.querySelector(".object-select");
|
||
const nameInput = picker.querySelector(".object-name");
|
||
const searchButton = picker.querySelector(".object-search");
|
||
|
||
select.addEventListener("change", async () => {
|
||
const ingredient = state.recipe.steps[stepIndex].ingredients[ingredientIndex];
|
||
const value = select.value;
|
||
if (value.startsWith("id:")) {
|
||
const option = select.options[select.selectedIndex];
|
||
ingredient[fields.id] = Number(value.slice(3));
|
||
ingredient[fields.create] = false;
|
||
ingredient[fields.name] = option.dataset.name;
|
||
nameInput.value = option.dataset.name;
|
||
} else if (value === "create") {
|
||
const enteredName = nameInput.value.trim();
|
||
if (!enteredName) {
|
||
setStatus(`${fields.label}-Name darf nicht leer sein.`, "error");
|
||
select.value = "";
|
||
return;
|
||
}
|
||
ingredient[fields.id] = null;
|
||
ingredient[fields.create] = true;
|
||
ingredient[fields.name] = enteredName;
|
||
} else {
|
||
ingredient[fields.id] = null;
|
||
ingredient[fields.create] = false;
|
||
}
|
||
syncEditor();
|
||
await validateRecipe();
|
||
});
|
||
|
||
searchButton.addEventListener("click", async () => {
|
||
const enteredName = nameInput.value.trim();
|
||
if (!enteredName) {
|
||
setStatus(`${fields.label}-Name darf nicht leer sein.`, "error");
|
||
return;
|
||
}
|
||
const ingredient = state.recipe.steps[stepIndex].ingredients[ingredientIndex];
|
||
ingredient[fields.name] = enteredName;
|
||
ingredient[fields.id] = null;
|
||
ingredient[fields.create] = false;
|
||
syncEditor();
|
||
await validateRecipe();
|
||
});
|
||
|
||
nameInput.addEventListener("keydown", event => {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
searchButton.click();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function syncEditor() {
|
||
editor.value = state.recipe ? JSON.stringify(state.recipe, null, 2) : "";
|
||
}
|
||
|
||
function updateButtons() {
|
||
validateButton.disabled = !state.recipe;
|
||
importButton.disabled = !state.recipe || state.blocking;
|
||
}
|
||
|
||
function applyResponse(data) {
|
||
state.runId = data.run_id;
|
||
state.recipe = data.recipe;
|
||
state.resolution = data.resolution;
|
||
state.warnings = data.warnings || [];
|
||
state.blocking = Boolean(data.blocking);
|
||
syncEditor();
|
||
renderPreview();
|
||
renderWarnings();
|
||
renderMappings();
|
||
updateButtons();
|
||
}
|
||
|
||
async function api(url, options = {}) {
|
||
const response = await fetch(BT.url(url), {
|
||
headers: {"Content-Type": "application/json"},
|
||
...options,
|
||
});
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(data.detail || `HTTP ${response.status}`);
|
||
return data;
|
||
}
|
||
|
||
async function analyze() {
|
||
const url = $("url").value.trim();
|
||
if (!url) {
|
||
setStatus("Bitte eine URL eintragen.", "error");
|
||
return;
|
||
}
|
||
analyzeButton.disabled = true;
|
||
importButton.disabled = true;
|
||
setStatus("Quelle wird gelesen und von OpenAI strukturiert …");
|
||
try {
|
||
const data = await api("/api/analyze", {
|
||
method: "POST",
|
||
body: JSON.stringify({url}),
|
||
});
|
||
applyResponse(data);
|
||
setStatus(
|
||
data.blocking
|
||
? "Analyse abgeschlossen. Noch nicht zugeordnete Einträge sind markiert."
|
||
: "Analyse und Tandoor-Abgleich erfolgreich. Vorauswahlen bitte prüfen.",
|
||
data.blocking ? "error" : "ok"
|
||
);
|
||
} catch (error) {
|
||
setStatus(error.message, "error");
|
||
} finally {
|
||
analyzeButton.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function validateRecipe() {
|
||
if (!state.runId) return;
|
||
setStatus("Rezept wird validiert und erneut mit Tandoor abgeglichen …");
|
||
try {
|
||
const parsed = JSON.parse(editor.value);
|
||
const data = await api(`/api/runs/${state.runId}/validate`, {
|
||
method: "POST",
|
||
body: JSON.stringify({recipe: parsed}),
|
||
});
|
||
applyResponse(data);
|
||
setStatus(
|
||
data.blocking ? "Noch nicht zugeordnete Einträge vorhanden." : "Validierung erfolgreich.",
|
||
data.blocking ? "error" : "ok"
|
||
);
|
||
} catch (error) {
|
||
setStatus(`Validierung fehlgeschlagen: ${error.message}`, "error");
|
||
}
|
||
}
|
||
|
||
async function importRecipe() {
|
||
if (!state.runId || state.blocking) return;
|
||
const newObjects = (state.resolution?.mappings || []).flatMap(mapping =>
|
||
[mapping.food, mapping.unit]
|
||
.filter(item => item?.status === "create")
|
||
.map(item => item.lookup_name)
|
||
);
|
||
const addition = newObjects.length
|
||
? `\n\nNeu in Tandoor anzulegen:\n• ${newObjects.join("\n• ")}`
|
||
: "";
|
||
const confirmed = window.confirm(
|
||
`„${state.recipe.name}“ jetzt wirklich in Tandoor anlegen?${addition}`
|
||
);
|
||
if (!confirmed) return;
|
||
|
||
importButton.disabled = true;
|
||
setStatus("Stammdaten und Rezept werden angelegt und anschließend verifiziert …");
|
||
try {
|
||
const parsed = JSON.parse(editor.value);
|
||
const data = await api(`/api/runs/${state.runId}/import`, {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
recipe: parsed,
|
||
import_image: $("importImage").checked,
|
||
force_duplicate: false,
|
||
}),
|
||
});
|
||
|
||
if (data.status === "duplicate") {
|
||
const list = data.duplicates.map(d => `ID ${d.id}: ${d.name}`).join(", ");
|
||
setStatus(`Nicht importiert: Rezept existiert bereits (${list}).`, "error");
|
||
} else {
|
||
const created = (data.created_objects || []).map(o => o.name).join(", ");
|
||
setStatus(
|
||
`Import erfolgreich und verifiziert.${created ? ` Neu angelegt: ${created}.` : ""}`,
|
||
"ok"
|
||
);
|
||
$("preview").insertAdjacentHTML(
|
||
"afterbegin",
|
||
`<p><a href="${escapeHtml(data.recipe_url)}" target="_blank" rel="noopener noreferrer"><strong>In Tandoor öffnen →</strong></a></p>`
|
||
);
|
||
}
|
||
} catch (error) {
|
||
setStatus(`Import fehlgeschlagen: ${error.message}`, "error");
|
||
} finally {
|
||
updateButtons();
|
||
}
|
||
}
|
||
|
||
document.querySelectorAll("[data-tab]").forEach(button => {
|
||
button.addEventListener("click", () => {
|
||
document.querySelectorAll("[data-tab]").forEach(b => b.classList.remove("active"));
|
||
document.querySelectorAll(".tab").forEach(tab => tab.classList.remove("active"));
|
||
button.classList.add("active");
|
||
$(`tab-${button.dataset.tab}`).classList.add("active");
|
||
});
|
||
});
|
||
|
||
analyzeButton.addEventListener("click", analyze);
|
||
validateButton.addEventListener("click", validateRecipe);
|
||
importButton.addEventListener("click", importRecipe);
|
||
$("url").addEventListener("keydown", event => {
|
||
if (event.key === "Enter") analyze();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|