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
+179
View File
@@ -0,0 +1,179 @@
/* ==========================================================================
boehmitools · gemeinsame Shell
Stellt App-Bar, Tool-Umschalter, Theme und kleine Helfer für alle Plugins.
Einbindung in einem Plugin (siehe plugins/README.md):
<link rel="stylesheet" href="/shared/boehmi.css">
<script src="/shared/boehmi.js" defer></script>
<body data-bt-title="Mein Tool" data-bt-icon="🔧"> ... </body>
Der Mount-Pfad wird automatisch aus der URL abgeleitet, ein Plugin muss
ihn nicht kennen. API-Aufrufe laufen über BT.api("/api/...").
========================================================================== */
(function () {
"use strict";
const ROOT_META = document.querySelector('meta[name="bt-root"]');
const pathMatch = location.pathname.match(/^(.*?)(\/plugins\/[^/]+)/);
const BT = {
/** Präfix der gesamten Suite (nur bei Reverse-Proxy-Unterpfad gesetzt). */
root: ROOT_META ? ROOT_META.content.replace(/\/$/, "") : (pathMatch ? pathMatch[1] : ""),
/** Mount-Präfix des aktuellen Plugins, z. B. "/plugins/trainingsplan". */
base: pathMatch ? pathMatch[1] + pathMatch[2] : "",
/** ID des aktuellen Plugins oder null (Dashboard). */
plugin: pathMatch ? pathMatch[2].split("/").pop() : null,
plugins: [],
};
window.BT = BT;
/* ------------------------------------------------------------- Helfer */
/** Plugin-eigener Pfad → absolute URL. */
BT.url = (path) => BT.base + path;
/** Suite-weiter Pfad → absolute URL. */
BT.rootUrl = (path) => BT.root + path;
/** fetch mit JSON-Handling, relativ zum Plugin-Mount. */
BT.api = async function (path, options = {}) {
const opts = { ...options };
if (opts.body !== undefined && !(opts.body instanceof FormData)) {
opts.headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
}
const res = await fetch(BT.url(path), opts);
const text = await res.text();
let data = {};
try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; }
if (!res.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`);
return data;
};
BT.toast = function (message, kind = "") {
let host = document.getElementById("bt-toaster");
if (!host) {
host = document.createElement("div");
host.id = "bt-toaster";
document.body.appendChild(host);
}
const el = document.createElement("div");
el.className = "bt-toast " + kind;
el.textContent = message;
host.appendChild(el);
requestAnimationFrame(() => el.classList.add("show"));
setTimeout(() => {
el.classList.remove("show");
setTimeout(() => el.remove(), 250);
}, kind === "err" ? 5200 : 2600);
};
BT.escape = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]
));
/* -------------------------------------------------------------- Theme */
const THEME_KEY = "boehmitools.theme";
BT.setTheme = function (mode) {
const resolved = mode === "auto"
? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
: mode;
document.documentElement.setAttribute("data-bt-theme", resolved);
try { localStorage.setItem(THEME_KEY, mode); } catch (_) {}
document.querySelectorAll("[data-bt-theme-toggle]").forEach((b) => {
b.textContent = resolved === "dark" ? "☀︎" : "☾";
b.title = resolved === "dark" ? "Helles Design" : "Dunkles Design";
});
};
BT.currentTheme = function () {
try { return localStorage.getItem(THEME_KEY) || "auto"; } catch (_) { return "auto"; }
};
// so früh wie möglich anwenden (verhindert Aufblitzen)
BT.setTheme(BT.currentTheme());
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
if (BT.currentTheme() === "auto") BT.setTheme("auto");
});
/* ------------------------------------------------------------ App-Bar */
function onBody(fn) {
if (document.body) fn();
else document.addEventListener("DOMContentLoaded", fn, { once: true });
}
function buildAppbar(plugins) {
const me = plugins.find((p) => p.id === BT.plugin);
const title = document.body.dataset.btTitle || (me ? me.name : "Dashboard");
const icon = document.body.dataset.btIcon || (me ? me.icon : "▦");
const bar = document.createElement("header");
bar.className = "bt-appbar";
bar.innerHTML = `
<a class="bt-brand" href="${BT.rootUrl("/")}" title="Zum Dashboard">
<span class="bt-brand-mark">B</span>
<span>boehmitools</span>
</a>
${BT.plugin ? `
<span class="bt-appbar-sep"></span>
<span class="bt-crumb"><span class="bt-crumb-ico">${BT.escape(icon)}</span>
<span>${BT.escape(title)}</span></span>` : ""}
<span class="bt-appbar-spacer"></span>
<span class="bt-appbar-actions">
<span class="bt-switch">
<button class="mini ghost" id="bt-switch-btn" aria-haspopup="true" aria-expanded="false">
Tools ▾
</button>
<div class="bt-switch-menu" id="bt-switch-menu" role="menu">
<div class="bt-switch-head">Tools</div>
${plugins.map((p) => `
<a class="bt-switch-item ${p.id === BT.plugin ? "active" : ""}" href="${BT.rootUrl(p.mount)}/">
<span class="ico">${BT.escape(p.icon)}</span>
<span><b>${BT.escape(p.name)}</b><i>${BT.escape(p.summary || "")}</i></span>
</a>`).join("")}
<div class="bt-switch-head">Suite</div>
<a class="bt-switch-item" href="${BT.rootUrl("/")}"><span class="ico">▦</span>
<span><b>Dashboard</b><i>Übersicht aller Tools</i></span></a>
<a class="bt-switch-item" href="${BT.rootUrl("/jobs")}"><span class="ico">↻</span>
<span><b>Läufe</b><i>Protokolle der Kommandozeilen-Tools</i></span></a>
<a class="bt-switch-item" href="${BT.rootUrl("/settings")}"><span class="ico">⚙</span>
<span><b>Einstellungen</b><i>Tandoor- und OpenAI-Zugänge</i></span></a>
</div>
</span>
<button class="mini ghost icon" data-bt-theme-toggle title="Design wechseln">☾</button>
</span>`;
const anchor = document.getElementById("bt-appbar") || document.body.firstChild;
if (anchor && anchor.id === "bt-appbar") anchor.replaceWith(bar);
else document.body.insertBefore(bar, document.body.firstChild);
const btn = document.getElementById("bt-switch-btn");
const menu = document.getElementById("bt-switch-menu");
btn.addEventListener("click", (e) => {
e.stopPropagation();
const open = menu.classList.toggle("open");
btn.setAttribute("aria-expanded", String(open));
});
document.addEventListener("click", () => menu.classList.remove("open"));
document.addEventListener("keydown", (e) => { if (e.key === "Escape") menu.classList.remove("open"); });
bar.querySelector("[data-bt-theme-toggle]").addEventListener("click", () => {
const now = document.documentElement.getAttribute("data-bt-theme");
BT.setTheme(now === "dark" ? "light" : "dark");
});
BT.setTheme(BT.currentTheme());
}
BT.ready = fetch(BT.rootUrl("/api/plugins"))
.then((r) => (r.ok ? r.json() : { plugins: [] }))
.catch(() => ({ plugins: [] }))
.then((d) => new Promise((resolve) => {
BT.plugins = d.plugins || [];
onBody(() => {
if (document.body.dataset.btShell !== "off") buildAppbar(BT.plugins);
document.dispatchEvent(new CustomEvent("bt:ready", { detail: BT }));
resolve(BT);
});
}));
})();