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
+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>