From 8ff5ab2e31753dc546f63581421bed83cce8ab34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Bachmann?= Date: Thu, 15 Jan 2026 14:20:58 +0000 Subject: [PATCH] Dateien nach "custom_components/bahmcloud_store/panel" hochladen --- .../bahmcloud_store/panel/app.js | 87 ++ .../bahmcloud_store/panel/index.html | 26 + .../bahmcloud_store/panel/panel.js | 946 ++++++++++++++++++ .../bahmcloud_store/panel/styles.css | 10 + 4 files changed, 1069 insertions(+) create mode 100644 custom_components/bahmcloud_store/panel/app.js create mode 100644 custom_components/bahmcloud_store/panel/index.html create mode 100644 custom_components/bahmcloud_store/panel/panel.js create mode 100644 custom_components/bahmcloud_store/panel/styles.css diff --git a/custom_components/bahmcloud_store/panel/app.js b/custom_components/bahmcloud_store/panel/app.js new file mode 100644 index 0000000..9054b81 --- /dev/null +++ b/custom_components/bahmcloud_store/panel/app.js @@ -0,0 +1,87 @@ +async function apiGet() { + const r = await fetch("/api/bahmcloud_store", { credentials: "same-origin" }); + return await r.json(); +} + +async function apiPost(payload) { + const r = await fetch("/api/bahmcloud_store", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify(payload), + }); + return await r.json(); +} + +function el(tag, attrs = {}, children = []) { + const n = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === "class") n.className = v; + else if (k === "onclick") n.onclick = v; + else n.setAttribute(k, v); + } + for (const c of children) { + n.appendChild(typeof c === "string" ? document.createTextNode(c) : c); + } + return n; +} + +function card(pkg) { + const installedBadge = el("span", { class: "badge" }, [pkg.installed ? "Installed" : "Not installed"]); + + const title = el("div", {}, [ + el("strong", {}, [pkg.name]), + el("div", { class: "muted" }, [pkg.repo]), + ]); + + const ver = el("div", { class: "muted" }, [ + `Installed: ${pkg.installed_version || "-"} | Latest: ${pkg.latest_version || "-"}` + ]); + + const btnInstall = el("button", { + onclick: async () => { + btnInstall.disabled = true; + btnInstall.textContent = "Working..."; + await apiPost({ op: "install", package_id: pkg.id }); + await load(); + } + }, [pkg.installed ? "Reinstall" : "Install"]); + + const btnUpdate = el("button", { + onclick: async () => { + btnUpdate.disabled = true; + btnUpdate.textContent = "Working..."; + await apiPost({ op: "update", package_id: pkg.id }); + await load(); + } + }, ["Update"]); + + // Update-Button nur wenn installiert + btnUpdate.disabled = !pkg.installed; + + const actions = el("div", { class: "actions" }, [btnInstall, btnUpdate]); + + return el("div", { class: "card" }, [ + el("div", { class: "row" }, [title, installedBadge]), + ver, + actions + ]); +} + +async function load() { + const status = document.getElementById("status"); + const list = document.getElementById("list"); + + status.textContent = "Loading..."; + list.innerHTML = ""; + + const data = await apiGet(); + status.textContent = `Store: ${data.store_url}`; + + for (const pkg of data.packages) { + list.appendChild(card(pkg)); + } +} + +document.getElementById("refresh").onclick = load; +load(); diff --git a/custom_components/bahmcloud_store/panel/index.html b/custom_components/bahmcloud_store/panel/index.html new file mode 100644 index 0000000..890eec2 --- /dev/null +++ b/custom_components/bahmcloud_store/panel/index.html @@ -0,0 +1,26 @@ + + + + + Bahmcloud Store + + + +
+

Bahmcloud Store

+

+ Installation erfolgt hier im Store (Buttons).
+ Updates erscheinen danach zusätzlich unter Einstellungen → System → Updates. +

+ +
+ +
+ +
+
+
+ + + + diff --git a/custom_components/bahmcloud_store/panel/panel.js b/custom_components/bahmcloud_store/panel/panel.js new file mode 100644 index 0000000..c2100ab --- /dev/null +++ b/custom_components/bahmcloud_store/panel/panel.js @@ -0,0 +1,946 @@ +class BahmcloudStorePanel extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + + this._hass = null; + + this._view = "store"; // store | manage | about | detail + this._data = null; + this._loading = true; + this._error = null; + + this._customAddUrl = ""; + this._customAddName = ""; + + this._search = ""; + this._category = "all"; + this._provider = "all"; // all|github|gitea|gitlab|other|custom + + this._detailRepoId = null; + this._detailRepo = null; + + this._readmeLoading = false; + this._readmeText = null; + this._readmeHtml = null; + this._readmeError = null; + } + + set hass(hass) { + this._hass = hass; + if (!this._rendered) { + this._rendered = true; + this._render(); + this._load(); + } + } + + async _load() { + if (!this._hass) return; + + this._loading = true; + this._error = null; + this._update(); + + try { + const data = await this._hass.callApi("get", "bcs"); + this._data = data; + } catch (e) { + this._error = e?.message ? String(e.message) : String(e); + } finally { + this._loading = false; + this._update(); + } + } + + _isDesktop() { + return window.matchMedia && window.matchMedia("(min-width: 1024px)").matches; + } + + _toggleMenu() { + if (this._isDesktop()) return; + try { + const ev = new Event("hass-toggle-menu", { bubbles: true, composed: true }); + this.dispatchEvent(ev); + } catch (_) {} + } + + _goBack() { + if (this._view === "detail") { + this._view = "store"; + this._detailRepoId = null; + this._detailRepo = null; + this._readmeText = null; + this._readmeHtml = null; + this._readmeError = null; + this._update(); + return; + } + try { + history.back(); + } catch (_) { + window.location.href = "/"; + } + } + + async _addCustomRepo() { + if (!this._hass) return; + + const url = (this._customAddUrl || "").trim(); + const name = (this._customAddName || "").trim() || null; + + if (!url) { + this._error = "Please enter a repository URL."; + this._update(); + return; + } + + this._error = null; + this._update(); + + try { + await this._hass.callApi("post", "bcs", { op: "add_custom_repo", url, name }); + this._customAddUrl = ""; + this._customAddName = ""; + await this._load(); + this._view = "manage"; + this._update(); + } catch (e) { + this._error = e?.message ? String(e.message) : String(e); + this._update(); + } + } + + async _removeCustomRepo(id) { + if (!this._hass) return; + + try { + await this._hass.callApi("delete", `bcs/custom_repo?id=${encodeURIComponent(id)}`); + await this._load(); + this._view = "manage"; + this._update(); + } catch (e) { + this._error = e?.message ? String(e.message) : String(e); + this._update(); + } + } + + _openRepoDetail(repoId) { + const repos = Array.isArray(this._data?.repos) ? this._data.repos : []; + const repo = repos.find((r) => this._safeId(r?.id) === repoId); + if (!repo) return; + + this._view = "detail"; + this._detailRepoId = repoId; + this._detailRepo = repo; + + this._readmeText = null; + this._readmeHtml = null; + this._readmeError = null; + + this._update(); + this._loadReadme(repoId); + } + + async _loadReadme(repoId) { + if (!this._hass) return; + this._readmeLoading = true; + this._readmeError = null; + this._update(); + + try { + const resp = await this._hass.callApi( + "get", + `bcs/readme?repo_id=${encodeURIComponent(repoId)}` + ); + + if (resp?.ok && typeof resp.readme === "string" && resp.readme.trim()) { + this._readmeText = resp.readme; + this._readmeHtml = typeof resp.html === "string" && resp.html.trim() ? resp.html : null; + } else { + this._readmeText = null; + this._readmeHtml = null; + this._readmeError = this._safeText(resp?.message) || "README not found."; + } + } catch (e) { + this._readmeText = null; + this._readmeHtml = null; + this._readmeError = e?.message ? String(e.message) : String(e); + } finally { + this._readmeLoading = false; + this._update(); + } + } + + _render() { + const root = this.shadowRoot; + + root.innerHTML = ` + + +
+
+ +
+
+
Bahmcloud Store
+
BCS — loading…
+
+
+
+ +
+
+ +
+
+
Store
+
Manage repositories
+
Settings / About
+
+ +
+
+
+ +
+ `; + + root.getElementById("refreshBtn").addEventListener("click", () => this._load()); + root.getElementById("menuBtn").addEventListener("click", () => this._toggleMenu()); + root.getElementById("backBtn").addEventListener("click", () => this._goBack()); + + for (const tab of root.querySelectorAll(".tab")) { + tab.addEventListener("click", () => { + this._view = tab.getAttribute("data-view"); + this._update(); + }); + } + + // prevent HA global shortcuts while typing + const stopIfFormField = (e) => { + const t = e.composedPath ? e.composedPath()[0] : e.target; + if (!t) return; + const tag = (t.tagName || "").toLowerCase(); + const isEditable = + tag === "input" || tag === "textarea" || tag === "select" || t.isContentEditable; + if (isEditable) e.stopPropagation(); + }; + root.addEventListener("keydown", stopIfFormField, true); + root.addEventListener("keyup", stopIfFormField, true); + root.addEventListener("keypress", stopIfFormField, true); + } + + _captureFocusState() { + const root = this.shadowRoot; + const ae = root.activeElement; + if (!ae || !ae.id) return null; + + const supported = new Set(["searchInput", "categorySelect", "addUrl", "addName"]); + if (!supported.has(ae.id)) return null; + + return { + id: ae.id, + value: ae.value, + selectionStart: typeof ae.selectionStart === "number" ? ae.selectionStart : null, + selectionEnd: typeof ae.selectionEnd === "number" ? ae.selectionEnd : null, + }; + } + + _restoreFocusState(state) { + if (!state) return; + const root = this.shadowRoot; + const el = root.getElementById(state.id); + if (!el) return; + + try { + el.focus({ preventScroll: true }); + if ( + state.selectionStart !== null && + state.selectionEnd !== null && + typeof el.setSelectionRange === "function" + ) { + el.setSelectionRange(state.selectionStart, state.selectionEnd); + } + } catch (_) {} + } + + _update() { + const root = this.shadowRoot; + const focusState = this._captureFocusState(); + + const content = root.getElementById("content"); + const err = root.getElementById("error"); + const subtitle = root.getElementById("subtitle"); + const fabs = root.getElementById("fabs"); + + const v = this._safeText(this._data?.version); + subtitle.textContent = v ? `BCS ${v}` : "BCS — loading…"; + + for (const tab of root.querySelectorAll(".tab")) { + tab.classList.toggle("active", tab.getAttribute("data-view") === this._view); + } + + err.textContent = this._error ? `Error: ${this._error}` : ""; + + fabs.innerHTML = this._view === "detail" ? this._renderFabs() : ""; + this._wireFabs(); + + if (this._loading) { + content.innerHTML = `
Loading…
`; + this._restoreFocusState(focusState); + return; + } + + if (!this._data) { + content.innerHTML = `
No data.
`; + this._restoreFocusState(focusState); + return; + } + + if (this._view === "store") { + content.innerHTML = this._renderStore(); + this._wireStore(); + this._restoreFocusState(focusState); + return; + } + + if (this._view === "manage") { + content.innerHTML = this._renderManage(); + this._wireManage(); + this._restoreFocusState(focusState); + return; + } + + if (this._view === "about") { + content.innerHTML = this._renderAbout(); + this._restoreFocusState(focusState); + return; + } + + content.innerHTML = this._renderDetail(); + this._wireDetail(); + this._restoreFocusState(focusState); + } + + _renderStore() { + const repos = Array.isArray(this._data.repos) ? this._data.repos : []; + const categories = this._computeCategories(repos); + const stats = this._computeProviderStats(repos); + + const filtered = repos + .filter((r) => { + const q = (this._search || "").trim().toLowerCase(); + if (!q) return true; + const hay = `${this._safeText(r?.name)} ${this._safeText(r?.description)} ${this._safeText(r?.url)} ${this._safeText(r?.owner)}`.toLowerCase(); + return hay.includes(q); + }) + .filter((r) => { + if (this._category === "all") return true; + return this._safeLower(r?.category) === this._category; + }) + .filter((r) => { + if (this._provider === "all") return true; + if (this._provider === "custom") return r?.source === "custom"; + return this._safeLower(r?.provider) === this._provider; + }); + + const options = [ + ``, + ...categories.map( + (c) => + `` + ), + ].join(""); + + const providerChips = this._renderProviderChips(stats); + + const rows = filtered + .map((r) => { + const id = this._safeId(r?.id); + const name = this._safeText(r?.name) || "Unnamed repository"; + const desc = this._safeText(r?.description) || "No description available."; + + const badge = + r?.source === "custom" + ? `Custom` + : `Index`; + + const creator = this._safeText(r?.owner) ? `Creator: ${this._safeText(r?.owner)}` : "Creator: -"; + const latest = this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: unknown"; + const cat = this._safeText(r?.category) ? `Category: ${this._safeText(r?.category)}` : null; + const metaSrc = this._safeText(r?.meta_source) ? `Meta: ${this._safeText(r?.meta_source)}` : null; + + const lineBits = [creator, latest, cat, metaSrc].filter(Boolean); + + return ` +
+
+
+
${this._esc(name)}
+
${this._esc(desc)}
+
${this._esc(lineBits.join(" · "))}
+
+ ${badge} +
+
+ `; + }) + .join(""); + + return ` +
+
Providers
+
${providerChips}
+
+ +
+ + +
+ + ${rows || `
No repositories match this filter.
`} + `; + } + + _wireStore() { + const root = this.shadowRoot; + const search = root.getElementById("searchInput"); + const cat = root.getElementById("categorySelect"); + const chips = root.getElementById("providerChips"); + + if (search) { + search.addEventListener("input", (e) => { + this._search = e.target.value; + this._update(); + }); + } + + if (cat) { + cat.addEventListener("change", (e) => { + this._category = String(e.target.value || "all"); + this._update(); + }); + } + + if (chips) { + for (const c of chips.querySelectorAll("[data-prov]")) { + c.addEventListener("click", () => { + const key = c.getAttribute("data-prov"); + if (!key) return; + this._provider = key; + this._update(); + }); + } + } + + for (const card of root.querySelectorAll("[data-repo]")) { + card.addEventListener("click", () => { + const id = card.getAttribute("data-repo"); + if (id) this._openRepoDetail(id); + }); + } + } + + _renderProviderChips(stats) { + const order = ["all", "github", "gitea", "gitlab", "other", "custom"]; + const labels = { + all: "All", + github: "GitHub", + gitea: "Gitea", + gitlab: "GitLab", + other: "Other", + custom: "Custom", + }; + const values = { + all: stats.total, + github: stats.github, + gitea: stats.gitea, + gitlab: stats.gitlab, + other: stats.other, + custom: stats.custom, + }; + + return order + .map((key) => { + const count = values[key] ?? 0; + const active = this._provider === key ? " active" : ""; + return `
${labels[key]} ${count}
`; + }) + .join(""); + } + + _computeProviderStats(repos) { + const s = { total: 0, github: 0, gitea: 0, gitlab: 0, other: 0, custom: 0 }; + if (!Array.isArray(repos)) return s; + + for (const r of repos) { + s.total += 1; + + if (r?.source === "custom") s.custom += 1; + + const p = this._safeLower(r?.provider) || "other"; + if (p === "github") s.github += 1; + else if (p === "gitea") s.gitea += 1; + else if (p === "gitlab") s.gitlab += 1; + else s.other += 1; + } + return s; + } + + _renderDetail() { + const r = this._detailRepo; + if (!r) return `
Repository not found.
`; + + const name = this._safeText(r?.name) || "Unnamed repository"; + const desc = this._safeText(r?.description) || "No description available."; + const url = this._safeText(r?.url) || "#"; + + const badge = + r?.source === "custom" + ? `Custom` + : `Index`; + + const latest = this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: unknown"; + + const infoBits = [ + this._safeText(r?.owner) ? `Creator: ${this._safeText(r?.owner)}` : "Creator: -", + latest, + this._safeText(r?.provider) ? `Provider: ${this._safeText(r?.provider)}` : null, + this._safeText(r?.category) ? `Category: ${this._safeText(r?.category)}` : null, + this._safeText(r?.meta_author) ? `Author: ${this._safeText(r?.meta_author)}` : null, + this._safeText(r?.meta_maintainer) ? `Maintainer: ${this._safeText(r?.meta_maintainer)}` : null, + this._safeText(r?.meta_source) ? `Meta: ${this._safeText(r?.meta_source)}` : null, + ].filter(Boolean); + + const readmeBlock = this._readmeLoading + ? `
Loading README…
` + : this._readmeText + ? ` +
+
+
README
+
Rendered Markdown
+
+ +
+ +
+ Show raw Markdown +
+
${this._esc(this._readmeText)}
+
+
+
+ ` + : ` +
+
README
+
${this._esc(this._readmeError || "README not found.")}
+
+ `; + + return ` +
+
+
+
+
+
${this._esc(name)}
+
${this._esc(desc)}
+
${this._esc(infoBits.join(" · "))}
+ +
+ ${badge} +
+
+ + ${readmeBlock} +
+ +
+
+
Installation & Updates
+
+ Installation and updates are performed manually via Settings → System → Updates. + The Store UI is used to browse repositories and trigger installation/update actions. +
+
+ Updates remain manual (like HACS). +
+
+
+
+ `; + } + + _wireDetail() { + const root = this.shadowRoot; + const mount = root.getElementById("readmePretty"); + if (!mount) return; + + if (this._readmeText) { + // Client renderer may be unavailable; prefer server-provided HTML + if (this._readmeHtml) { + mount.innerHTML = this._readmeHtml; + this._postprocessRenderedMarkdown(mount); + return; + } + mount.innerHTML = `
Rendered HTML not available. Use “Show raw Markdown”.
`; + return; + } + + mount.innerHTML = ""; + } + + _postprocessRenderedMarkdown(container) { + if (!container) return; + try { + const links = container.querySelectorAll("a[href]"); + links.forEach((a) => { + a.setAttribute("target", "_blank"); + a.setAttribute("rel", "noreferrer noopener"); + }); + } catch (_) {} + } + + _renderFabs() { + const r = this._detailRepo; + if (!r) return ""; + + return ` +
+
+
+
+
+
i
+
+ `; + } + + _wireFabs() { + const root = this.shadowRoot; + const r = this._detailRepo; + if (!r) return; + + const url = this._safeText(r?.url); + + const open = root.getElementById("fabOpen"); + const reload = root.getElementById("fabReload"); + const info = root.getElementById("fabInfo"); + + if (open) open.addEventListener("click", () => url && window.open(url, "_blank", "noreferrer")); + if (reload) { + reload.addEventListener("click", () => { + if (this._detailRepoId) this._loadReadme(this._detailRepoId); + }); + } + if (info) { + info.addEventListener("click", () => { + this._view = "about"; + this._update(); + }); + } + } + + _renderManage() { + const repos = Array.isArray(this._data.repos) ? this._data.repos : []; + const custom = repos.filter((r) => r?.source === "custom"); + + const list = custom + .map((r) => { + const id = this._safeId(r?.id); + const name = this._safeText(r?.name) || "Unnamed repository"; + const url = this._safeText(r?.url) || ""; + return ` +
+
+
+
${this._esc(name)}
+
${this._esc(url)}
+
+
+ +
+
+
+ `; + }) + .join(""); + + return ` +
+
Manage repositories
+
Add public repositories from any git provider.
+ +
+
+
Repository URL
+ +
+ +
+
Display name (optional)
+ +
+ +
+ +
+
+
+ + ${list || `
No custom repositories added yet.
`} + `; + } + + _wireManage() { + const root = this.shadowRoot; + + const addUrl = root.getElementById("addUrl"); + const addName = root.getElementById("addName"); + const addBtn = root.getElementById("addBtn"); + + if (addUrl) addUrl.addEventListener("input", (e) => (this._customAddUrl = e.target.value)); + if (addName) addName.addEventListener("input", (e) => (this._customAddName = e.target.value)); + if (addBtn) addBtn.addEventListener("click", () => this._addCustomRepo()); + + for (const btn of root.querySelectorAll("[data-remove]")) { + btn.addEventListener("click", () => { + const id = btn.getAttribute("data-remove"); + if (id) this._removeCustomRepo(id); + }); + } + } + + _renderAbout() { + const v = this._safeText(this._data?.version) || "-"; + return ` +
+
Settings / About
+
Language: English (v1). i18n will be added later.
+
Theme: follows Home Assistant light/dark automatically.
+
Accent: Bahmcloud Blue.
+
BCS version: ${this._esc(v)}
+
+ `; + } + + _computeCategories(repos) { + const set = new Set(); + for (const r of repos) { + const c = this._safeLower(r?.category); + if (c) set.add(c); + } + return Array.from(set).sort(); + } + + // --- HARDENING HELPERS (fixes [object Object]) --- + _safeText(v) { + if (v === null || v === undefined) return ""; + const t = typeof v; + if (t === "string") return v; + if (t === "number" || t === "boolean") return String(v); + return ""; // objects/arrays/functions => empty (prevents [object Object]) + } + + _safeLower(v) { + const s = this._safeText(v); + return s ? s.trim().toLowerCase() : ""; + } + + _safeId(v) { + const s = this._safeText(v); + return s || ""; + } + + _esc(s) { + return String(s ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } +} + +customElements.define("bahmcloud-store-panel", BahmcloudStorePanel); diff --git a/custom_components/bahmcloud_store/panel/styles.css b/custom_components/bahmcloud_store/panel/styles.css new file mode 100644 index 0000000..46c2faf --- /dev/null +++ b/custom_components/bahmcloud_store/panel/styles.css @@ -0,0 +1,10 @@ +body { font-family: system-ui, sans-serif; margin:0; } +.wrap { padding: 16px; max-width: 1000px; margin: 0 auto; } +.card { border: 1px solid #ddd; border-radius: 10px; padding: 12px; margin: 10px 0; } +.row { display:flex; justify-content:space-between; gap: 12px; align-items: flex-start; } +.badge { border: 1px solid #bbb; border-radius: 999px; padding: 2px 8px; font-size: 12px; height: fit-content; } +.muted { color: #666; font-size: 13px; margin-top: 4px; } +.actions { display:flex; gap: 8px; margin-top: 10px; } +button { padding: 8px 12px; cursor:pointer; } +button[disabled] { opacity: 0.6; cursor: not-allowed; } +a { color: inherit; }