chore: initial import

This commit is contained in:
2026-07-24 21:37:03 +02:00
commit 9b5425851a
28 changed files with 4020 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>boehmitools · nicht gefunden</title>
<link rel="stylesheet" href="/shared/boehmi.css">
</head>
<body data-bt-title="Nicht gefunden" data-bt-icon="∅">
<main class="bt-main narrow">
<div class="bt-card" style="text-align:center;padding:48px 20px">
<h1 style="margin:0 0 6px;font-size:44px;letter-spacing:-.04em">404</h1>
<p class="bt-muted" style="margin:0 0 18px">Diese Seite gehört zu keinem Tool der Suite.</p>
<a class="bt-btn primary" href="/">Zum Dashboard</a>
</div>
</main>
<script src="/shared/boehmi.js"></script>
</body>
</html>
+258
View File
@@ -0,0 +1,258 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>boehmitools · Dashboard</title>
<link rel="stylesheet" href="/shared/boehmi.css">
<style>
.hero {
display: flex; align-items: flex-end; gap: 16px;
flex-wrap: wrap; margin: 6px 0 22px;
}
.hero h1 { margin: 0; font-size: 27px; font-weight: 850; letter-spacing: -.03em; }
.hero p { margin: 6px 0 0; color: var(--bt-muted); max-width: 62ch; }
.hero .bt-spacer { flex: 1; min-width: 0; }
.health { display: flex; gap: 8px; flex-wrap: wrap; }
.hero-side { display: flex; flex-direction: column; align-items: flex-end; gap: 10px; }
.hero-side .health { justify-content: flex-end; }
#reloadPlugins { white-space: nowrap; }
.side-btns { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
#importPlugin { white-space: nowrap; }
.cat { margin: 26px 0 10px; display: flex; align-items: center; gap: 10px; }
.cat h2 {
margin: 0; font-size: 11.5px; font-weight: 800; letter-spacing: .1em;
text-transform: uppercase; color: var(--bt-muted);
}
.cat .line { flex: 1; height: 1px; background: var(--bt-line); }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 14px; }
.tool {
display: flex; flex-direction: column; gap: 10px;
background: var(--bt-surface); border: 1px solid var(--bt-line);
border-radius: var(--bt-r-lg); padding: 16px; box-shadow: var(--bt-shadow);
color: inherit; transition: border-color .15s, transform .1s, box-shadow .15s;
position: relative; overflow: hidden;
}
.tool::before {
content: ""; position: absolute; inset: 0 auto 0 0; width: 3px;
background: var(--bt-accent); opacity: 0; transition: opacity .15s;
}
.tool:hover { text-decoration: none; border-color: var(--bt-accent); transform: translateY(-2px); box-shadow: var(--bt-shadow-lg); }
.tool:hover::before { opacity: 1; }
.tool.broken { border-style: dashed; opacity: .85; }
.tool.broken:hover { transform: none; border-color: var(--bt-err); }
.tool-head { display: flex; align-items: flex-start; gap: 11px; }
.tool-ico {
width: 40px; height: 40px; flex: none; border-radius: 11px;
display: grid; place-items: center; font-size: 20px;
background: var(--bt-accent-soft); border: 1px solid var(--bt-line-soft);
}
.tool-head h3 { margin: 0; font-size: 15.5px; font-weight: 750; letter-spacing: -.01em; }
.tool-head .ver { font-size: 11px; color: var(--bt-muted); font-weight: 600; }
.tool p.desc { margin: 0; font-size: 13px; color: var(--bt-ink-soft); line-height: 1.5; }
.tool ul {
margin: 0; padding: 0; list-style: none;
display: flex; flex-direction: column; gap: 4px;
}
.tool ul li {
font-size: 12px; color: var(--bt-muted); padding-left: 15px; position: relative;
}
.tool ul li::before {
content: ""; position: absolute; left: 3px; color: var(--bt-accent); font-weight: 800;
}
.tool-foot {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
margin-top: auto; padding-top: 4px;
}
.tool-foot .go { margin-left: auto; font-size: 12.5px; font-weight: 700; color: var(--bt-accent); }
</style>
</head>
<body data-bt-title="Dashboard" data-bt-icon="▦">
<main class="bt-main">
<div class="hero">
<div>
<h1>Werkzeugkasten</h1>
<p>Alle Tools laufen unter einem Dach und teilen sich Zugänge, Design und
Protokolle. Jedes Tool bleibt dabei eigenständig — es lässt sich einzeln
starten, aktualisieren oder entfernen.</p>
</div>
<span class="bt-spacer"></span>
<div class="hero-side">
<div class="side-btns">
<button class="bt-btn" id="importPlugin" title="Plugin als ZIP hochladen">
⤓ Plugin importieren</button>
<button class="bt-btn" id="reloadPlugins" title="plugins/ neu einlesen — ohne Neustart">
⟳ Plugins neu laden</button>
</div>
<input type="file" id="pluginFile" accept=".zip,application/zip" hidden>
<div class="health" id="health"></div>
</div>
</div>
<div id="cats"></div>
<div class="bt-notice info" style="margin-top:28px">
<b>Baukasten:</b> Ein neues Tool wird zum Plugin, indem sein Ordner unter
<code>plugins/&lt;name&gt;/</code> abgelegt wird — mit einer <code>plugin.json</code>
und einer <code>backend.py</code>, die <code>create_app(ctx)</code> zurückgibt.
Mit <b>„Plugins neu laden“</b> erscheint es sofort hier — ohne Neustart.
Details stehen in <code>plugins/README.md</code>.
</div>
</main>
<script src="/shared/boehmi.js"></script>
<script>
const catsEl = document.getElementById("cats");
const healthEl = document.getElementById("health");
function chip(label, ok, hint) {
return `<span class="bt-badge ${ok ? "ok" : "warn"}" title="${BT.escape(hint || "")}">
<span class="bt-dot"></span>${BT.escape(label)}</span>`;
}
function card(p) {
const broken = !p.ok;
const href = broken ? "#" : p.mount + "/";
return `
<a class="tool ${broken ? "broken" : ""}" href="${href}" ${broken ? 'onclick="return false"' : ""}>
<div class="tool-head">
<span class="tool-ico">${BT.escape(p.icon)}</span>
<div>
<h3>${BT.escape(p.name)}</h3>
<span class="ver">v${BT.escape(p.version)} · ${BT.escape(p.mount)}</span>
</div>
</div>
<p class="desc">${BT.escape(p.description || p.summary)}</p>
${p.features && p.features.length
? `<ul>${p.features.slice(0, 4).map((f) => `<li>${BT.escape(f)}</li>`).join("")}</ul>` : ""}
<div class="tool-foot">
${(p.requires || []).map((r) => `<span class="bt-badge">${BT.escape(r)}</span>`).join("")}
${broken
? `<span class="bt-badge err">Ladefehler</span>`
: `<span class="go">Öffnen →</span>`}
</div>
${broken ? `<div class="bt-notice err" style="margin:0">${BT.escape(p.error)}</div>` : ""}
</a>`;
}
async function render() {
const [{ plugins }, health] = await Promise.all([
fetch("/api/plugins").then((r) => r.json()),
fetch("/api/health").then((r) => r.json()).catch(() => ({})),
]);
healthEl.innerHTML = [
chip("Tandoor", health.tandoor_configured, "URL und Token in den Einstellungen"),
chip("OpenAI", health.openai_configured, "API-Key und Modell in den Einstellungen"),
health.authentication_enabled
? chip("Zugangsschutz", true, "APP_PASSWORD ist gesetzt")
: chip("Kein Zugangsschutz", false, "APP_PASSWORD setzen, wenn erreichbar"),
].join("");
const cats = [...new Set(plugins.map((p) => p.category))];
catsEl.innerHTML = cats.map((c) => `
<div class="cat"><h2>${BT.escape(c)}</h2><span class="line"></span></div>
<div class="cards">${plugins.filter((p) => p.category === c).map(card).join("")}</div>
`).join("");
if (!plugins.length) {
catsEl.innerHTML = `<div class="bt-empty">Noch keine Plugins unter <code>plugins/</code>.</div>`;
}
}
const reloadBtn = document.getElementById("reloadPlugins");
reloadBtn.addEventListener("click", async () => {
const alt = reloadBtn.textContent;
reloadBtn.disabled = true;
reloadBtn.textContent = "⟳ lädt …";
try {
const r = await fetch("/api/plugins/reload", { method: "POST" });
if (!r.ok) throw new Error("HTTP " + r.status);
const data = await r.json();
await render();
const fehler = (data.fehler || []).length;
BT.toast(
`${data.geladen}/${data.gesamt} Plugins geladen`
+ (fehler ? `, ${fehler} mit Fehler` : ""),
fehler ? "err" : "ok",
);
} catch (err) {
BT.toast("Neu laden fehlgeschlagen: " + err.message, "err");
} finally {
reloadBtn.disabled = false;
reloadBtn.textContent = alt;
}
});
const importBtn = document.getElementById("importPlugin");
const fileInput = document.getElementById("pluginFile");
importBtn.addEventListener("click", () => fileInput.click());
async function sendePlugin(datei, { nurPruefen = false, force = false } = {}) {
const form = new FormData();
form.append("file", datei);
if (nurPruefen) form.append("pruefen_only", "true");
if (force) form.append("force", "true");
const r = await fetch("/api/plugins/import", { method: "POST", body: form });
let daten = {};
try { daten = await r.json(); } catch (_) {}
if (!r.ok) throw new Error(daten.detail || `HTTP ${r.status}`);
return daten;
}
fileInput.addEventListener("change", async () => {
const datei = fileInput.files && fileInput.files[0];
fileInput.value = ""; // erneutes Wählen derselben Datei
if (!datei) return;
const alt = importBtn.textContent;
importBtn.disabled = true;
importBtn.textContent = "⤓ prüft …";
try {
// Erst schauen, was passieren würde — geschrieben wird noch nichts.
const { vorschau: v } = await sendePlugin(datei, { nurPruefen: true });
let force = false;
if (v.bestaetigung_noetig) {
const frage = `${v.warnung}\n\n` +
`Plugin: ${v.name} (${v.id})\n` +
`Installiert: ${v.version_alt} → Archiv: ${v.version}\n\n` +
`Fortfahren? Dateien werden ergänzt und überschrieben, nichts ` +
`gelöscht. Der Datenordner bleibt unberührt.`;
if (!confirm(frage)) { BT.toast("Abgebrochen.", ""); return; }
force = true;
} else if (v.art === "aktualisierung") {
const frage = `${v.name} (${v.id})\n` +
`Aktualisierung ${v.version_alt}${v.version}\n\n` +
`Der bisherige Stand wird gesichert, Daten bleiben erhalten.`;
if (!confirm(frage)) { BT.toast("Abgebrochen.", ""); return; }
}
importBtn.textContent = "⤓ lädt hoch …";
const b = await sendePlugin(datei, { force });
await render();
if (b.aktiv) {
const wort = b.art === "neu" ? "installiert"
: b.art === "aelter" ? "zurückgesetzt" : "aktualisiert";
BT.toast(`${b.name} ${b.version} ${wort} · ${b.geschrieben} Dateien`, "ok");
} else {
BT.toast(`${b.name} geschrieben, lädt aber nicht: ${b.ladefehler || "?"}`, "err");
}
} catch (err) {
BT.toast("Import fehlgeschlagen: " + err.message, "err");
} finally {
importBtn.disabled = false;
importBtn.textContent = alt;
}
});
render();
</script>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>boehmitools · Läufe</title>
<link rel="stylesheet" href="/shared/boehmi.css">
</head>
<body data-bt-title="Läufe" data-bt-icon="↻">
<main class="bt-main">
<div class="bt-pagehead">
<h1>Läufe</h1>
<p>Alle Ausführungen der Kommandozeilen-Tools dieser Sitzung.</p>
</div>
<div class="bt-card bt-card-flush">
<table class="bt-table">
<thead><tr><th>Tool</th><th>Aufgabe</th><th>Status</th><th>Dauer</th><th></th></tr></thead>
<tbody id="rows"></tbody>
</table>
</div>
<div id="empty" class="bt-empty bt-hidden">Noch keine Läufe in dieser Sitzung.</div>
</main>
<script src="/shared/boehmi.js"></script>
<script>
const badge = (s) => ({
running: '<span class="bt-badge info"><span class="bt-dot"></span>läuft</span>',
done: '<span class="bt-badge ok">erfolgreich</span>',
failed: '<span class="bt-badge err">fehlgeschlagen</span>',
cancelled: '<span class="bt-badge warn">abgebrochen</span>',
}[s] || s);
async function load() {
const { jobs } = await fetch("/api/jobs").then((r) => r.json());
document.getElementById("empty").classList.toggle("bt-hidden", jobs.length > 0);
document.getElementById("rows").innerHTML = jobs.map((j) => `
<tr>
<td><b>${BT.escape(j.plugin_name)}</b></td>
<td>${BT.escape(j.label)}<div class="bt-muted bt-mono" style="font-size:11px">${BT.escape(j.command)}</div></td>
<td>${badge(j.status)}</td>
<td class="bt-muted">${j.duration}s</td>
<td><a class="bt-btn mini" href="/plugins/${BT.escape(j.plugin)}/?job=${BT.escape(j.id)}">Protokoll</a></td>
</tr>`).join("");
}
load();
setInterval(load, 4000);
</script>
</body>
</html>
+107
View File
@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>boehmitools · Einstellungen</title>
<link rel="stylesheet" href="/shared/boehmi.css">
<style>
.field { margin-bottom: 12px; }
.field .help { font-size: 11.5px; color: var(--bt-muted); margin-top: 4px; }
.sticky-actions {
position: sticky; bottom: 0; margin-top: 18px; padding: 12px 0;
background: linear-gradient(transparent, var(--bt-bg) 40%);
display: flex; gap: 8px; align-items: center;
}
</style>
</head>
<body data-bt-title="Einstellungen" data-bt-icon="⚙">
<main class="bt-main narrow">
<div class="bt-pagehead">
<h1>Einstellungen</h1>
<p>Diese Zugänge gelten für alle Tools der Suite. Sie liegen in
<code>data/settings.json</code> und werden den Tools als Umgebungsvariablen
übergeben — genau so, wie sie es als Einzeltool erwarten.</p>
</div>
<div class="bt-notice info">
Tokens und Schlüssel bleiben im lokalen Backend. Bereits gespeicherte
Geheimnisse werden hier nur maskiert angezeigt; ein leeres Feld lässt den
bestehenden Wert unverändert.
</div>
<div id="groups"></div>
<div class="sticky-actions">
<button class="primary" id="save">Speichern</button>
<span id="state" class="bt-muted"></span>
</div>
</main>
<script src="/shared/boehmi.js"></script>
<script>
const groupsEl = document.getElementById("groups");
const stateEl = document.getElementById("state");
let schema = [];
function fieldHtml(f, value) {
const id = "f_" + f.key;
let control;
if (f.kind === "bool") {
control = `<label class="bt-check"><input type="checkbox" id="${id}"
${String(value).toLowerCase() === "true" ? "checked" : ""}> aktiv</label>`;
} else if (f.kind === "select") {
control = `<select id="${id}">${f.options.map((o) =>
`<option ${o === value ? "selected" : ""}>${BT.escape(o)}</option>`).join("")}</select>`;
} else if (f.kind === "secret") {
control = `<input type="password" id="${id}" value="${BT.escape(value)}"
placeholder="${value ? "unverändert" : "nicht gesetzt"}" autocomplete="new-password">`;
} else {
control = `<input type="${f.kind === "number" ? "number" : "text"}" id="${id}"
value="${BT.escape(value)}" placeholder="${BT.escape(f.placeholder || f.default || "")}">`;
}
return `<div class="field">
<label for="${id}">${BT.escape(f.label)}</label>
${control}
${f.help ? `<div class="help">${BT.escape(f.help)}</div>` : ""}
</div>`;
}
async function load() {
const data = await fetch("/api/settings").then((r) => r.json());
schema = data.schema;
groupsEl.innerHTML = schema.map((g) => `
<section class="bt-card">
<h2>${BT.escape(g.group)}</h2>
<p class="hint">${g.group === "Tandoor"
? "Wird vom AI-Webimport, vom Rezeptimport und vom Vereinheitlichen genutzt."
: g.group === "OpenAI" ? "Nur für den AI-Webimport nötig."
: "Schutz beim Abruf externer Rezeptseiten."}</p>
${g.fields.map((f) => fieldHtml(f, data.values[f.key] ?? "")).join("")}
</section>`).join("");
}
document.getElementById("save").addEventListener("click", async () => {
const payload = {};
schema.forEach((g) => g.fields.forEach((f) => {
const el = document.getElementById("f_" + f.key);
if (!el) return;
payload[f.key] = f.kind === "bool" ? String(el.checked) : el.value;
}));
try {
stateEl.textContent = "Speichern …";
await BT.api("/api/settings", { method: "POST", body: JSON.stringify(payload) });
stateEl.textContent = "";
BT.toast("Einstellungen gespeichert.", "ok");
load();
} catch (e) {
stateEl.textContent = "";
BT.toast(e.message, "err");
}
});
load();
</script>
</body>
</html>