chore: initial import
This commit is contained in:
@@ -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) => (
|
||||
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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);
|
||||
});
|
||||
}));
|
||||
})();
|
||||
Reference in New Issue
Block a user