diff --git a/custom_components/bahmcloud_store/panel/app.js b/custom_components/bahmcloud_store/panel/app.js
index b3fa062..75147d7 100644
--- a/custom_components/bahmcloud_store/panel/app.js
+++ b/custom_components/bahmcloud_store/panel/app.js
@@ -1,101 +1,984 @@
-async function apiGet() {
- const r = await fetch("/api/bcs", { credentials: "same-origin" });
- return await r.json();
-}
+class BahmcloudStorePanel extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
-async function apiRefresh() {
- const r = await fetch("/api/bcs?action=refresh", {
- method: "POST",
- credentials: "same-origin",
- });
- return await r.json();
-}
+ this._hass = null;
-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);
+ 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;
+
+ this._refreshing = false;
}
- for (const c of children) {
- n.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
+
+ set hass(hass) {
+ this._hass = hass;
+ if (!this._rendered) {
+ this._rendered = true;
+ this._render();
+ this._load();
+ }
}
- return n;
-}
-function card(repo) {
- const title = el("div", {}, [
- el("strong", {}, [repo.name]),
- el("div", { class: "muted" }, [repo.url]),
- ]);
+ async _load() {
+ if (!this._hass) return;
- const provider = el("div", { class: "muted" }, [
- `Provider: ${repo.provider || "-"}`
- ]);
+ this._loading = true;
+ this._error = null;
+ this._update();
- const version = el("div", { class: "muted" }, [
- `Latest: ${repo.latest_version || "-"}`
- ]);
+ 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();
+ }
+ }
- const sourceBadge = el(
- "span",
- { class: "badge" },
- [repo.source === "custom" ? "Custom" : "Index"]
- );
+ async _refreshAll() {
+ if (!this._hass) return;
+ if (this._refreshing) return;
- return el("div", { class: "card" }, [
- el("div", { class: "row" }, [title, sourceBadge]),
- provider,
- version
- ]);
-}
+ this._refreshing = true;
+ this._error = null;
-async function load() {
- const status = document.getElementById("status");
- const list = document.getElementById("list");
+ // Show a loading state immediately
+ this._loading = true;
+ this._update();
- status.textContent = "Loading...";
- list.innerHTML = "";
+ try {
+ // IMPORTANT: This hits POST /api/bcs?action=refresh
+ const resp = await this._hass.callApi("post", "bcs?action=refresh", {});
+ if (!resp?.ok) {
+ const msg = this._safeText(resp?.message) || "Refresh failed.";
+ this._error = msg;
+ }
+ } catch (e) {
+ this._error = e?.message ? String(e.message) : String(e);
+ } finally {
+ this._refreshing = false;
+ }
- try {
- const data = await apiGet();
+ // Always reload data after refresh attempt (even on failure)
+ await this._load();
+ }
- if (!data.ok) {
- status.textContent = "Failed to load store data";
+ _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;
}
- status.textContent = `BCS ${data.version} – ${data.repos.length} repositories`;
+ this._error = null;
+ this._update();
- for (const repo of data.repos) {
- list.appendChild(card(repo));
+ 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();
}
- } catch (e) {
- console.error("BCS load failed:", e);
- status.textContent = "Error loading store data";
+ }
+
+ 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…
+
+
+
+ Refresh
+
+
+
+
+
+
Store
+
Manage repositories
+
Settings / About
+
+
+
+
+
+
+
+ `;
+
+ // IMPORTANT: refresh button now triggers full backend refresh + reload
+ root.getElementById("refreshBtn").addEventListener("click", () => this._refreshAll());
+
+ 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 refreshBtn = root.getElementById("refreshBtn");
+
+ // Keep refresh button state in sync
+ if (refreshBtn) {
+ refreshBtn.disabled = !!this._refreshing;
+ refreshBtn.textContent = this._refreshing ? "Refreshing…" : "Refresh";
+ }
+
+ 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);
+ }
+
+ // --- The rest of your file is unchanged ---
+ // (Store rendering, detail rendering, manage, helpers)
+
+ _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 = [
+ `All categories `,
+ ...categories.map(
+ (c) =>
+ `${this._esc(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}
+
+
+
+
+ ${options}
+
+
+ ${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) {
+ 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 `
+
+ `;
+ }
+
+ _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)}
+
+
+ Remove
+
+
+
+ `;
+ })
+ .join("");
+
+ return `
+
+
Manage repositories
+
Add public repositories from any git provider.
+
+
+
+
+
+
Display name (optional)
+
+
+
+
+ Add repository
+
+
+
+
+ ${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 ---
+ _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 "";
+ }
+
+ _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("'", "'");
}
}
-const refreshBtn = document.getElementById("refresh");
-
-refreshBtn.onclick = async () => {
- refreshBtn.disabled = true;
- const oldText = refreshBtn.textContent;
- refreshBtn.textContent = "Refreshing...";
-
- try {
- const r = await apiRefresh();
- if (!r.ok) {
- console.error("BCS refresh failed:", r);
- }
- await load();
- } catch (e) {
- console.error("BCS refresh error:", e);
- } finally {
- refreshBtn.disabled = false;
- refreshBtn.textContent = oldText;
- }
-};
-
-load();
+customElements.define("bahmcloud-store-panel", BahmcloudStorePanel);