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._filter = "all"; // all|installed|not_installed|updates|custom this._sort = "az"; // az|updates_first|installed_first this._detailRepoId = null; this._detailRepo = null; this._readmeLoading = false; this._readmeText = null; this._readmeHtml = null; this._readmeError = null; // README UX (E2) this._readmeExpanded = false; this._readmeCanToggle = false; this._refreshing = false; this._installingRepoId = null; this._updatingRepoId = null; this._uninstallingRepoId = null; this._restartRequired = false; this._lastActionMsg = 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; if (this._view === "detail" && this._detailRepoId && Array.isArray(data?.repos)) { const fresh = data.repos.find((r) => this._safeId(r?.id) === this._detailRepoId); if (fresh) this._detailRepo = fresh; } } catch (e) { this._error = e?.message ? String(e.message) : String(e); } finally { this._loading = false; this._update(); } } async _refreshAll() { if (!this._hass) return; if (this._refreshing) return; this._refreshing = true; this._error = null; this._loading = true; this._update(); try { 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; } await this._load(); } async _installRepo(repoId) { if (!this._hass) return; if (!repoId) return; if (this._installingRepoId || this._updatingRepoId) return; this._installingRepoId = repoId; this._error = null; this._lastActionMsg = null; this._update(); try { const resp = await this._hass.callApi("post", `bcs/install?repo_id=${encodeURIComponent(repoId)}`, {}); if (!resp?.ok) { this._error = this._safeText(resp?.message) || "Install failed."; } else { this._restartRequired = !!resp.restart_required; this._lastActionMsg = "Installation finished. Restart required."; } } catch (e) { this._error = e?.message ? String(e.message) : String(e); } finally { this._installingRepoId = null; await this._load(); } } async _updateRepo(repoId) { if (!this._hass) return; if (!repoId) return; if (this._installingRepoId || this._updatingRepoId) return; this._updatingRepoId = repoId; this._error = null; this._lastActionMsg = null; this._update(); try { const resp = await this._hass.callApi("post", `bcs/update?repo_id=${encodeURIComponent(repoId)}`, {}); if (!resp?.ok) { this._error = this._safeText(resp?.message) || "Update failed."; } else { this._restartRequired = !!resp.restart_required; this._lastActionMsg = "Update finished. Restart required."; } } catch (e) { this._error = e?.message ? String(e.message) : String(e); } finally { this._updatingRepoId = null; await this._load(); } } async _uninstallRepo(repoId) { if (!this._hass) return; if (!repoId) return; if (this._installingRepoId || this._updatingRepoId || this._uninstallingRepoId) return; const ok = window.confirm("Really uninstall this repository? This will remove its files from /config/custom_components and requires a restart."); if (!ok) return; this._uninstallingRepoId = repoId; this._error = null; this._lastActionMsg = null; this._update(); try { const resp = await this._hass.callApi("post", `bcs/uninstall?repo_id=${encodeURIComponent(repoId)}`, {}); if (!resp?.ok) { this._error = this._safeText(resp?.message) || "Uninstall failed."; } else { this._restartRequired = !!resp.restart_required; this._lastActionMsg = "Uninstall finished. Restart required."; } } catch (e) { this._error = e?.message ? String(e.message) : String(e); } finally { this._uninstallingRepoId = null; await this._load(); } } async _restartHA() { if (!this._hass) return; try { await this._hass.callApi("post", "bcs/restart", {}); } catch (e) { this._error = e?.message ? String(e.message) : String(e); 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._readmeExpanded = false; 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._readmeExpanded = false; this._readmeCanToggle = false; 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; const lines = resp.readme.split(/\r?\n/).length; this._readmeCanToggle = lines > 24 || resp.readme.length > 1600; this._readmeExpanded = !this._readmeCanToggle; } else { this._readmeText = null; this._readmeHtml = null; this._readmeError = this._safeText(resp?.message) || "README not found."; this._readmeCanToggle = false; this._readmeExpanded = false; } } catch (e) { this._readmeText = null; this._readmeHtml = null; this._readmeError = e?.message ? String(e.message) : String(e); this._readmeCanToggle = false; this._readmeExpanded = false; } finally { this._readmeLoading = false; this._update(); } } _render() { const root = this.shadowRoot; root.innerHTML = `
Bahmcloud Store
Store
Store
Manage
About
`; root.getElementById("menuBtn").addEventListener("click", () => this._toggleMenu()); root.getElementById("backBtn").addEventListener("click", () => this._goBack()); root.getElementById("refreshBtn").addEventListener("click", () => this._refreshAll()); root.getElementById("tabStore").addEventListener("click", () => { this._view = "store"; this._update(); }); root.getElementById("tabManage").addEventListener("click", () => { this._view = "manage"; this._update(); }); root.getElementById("tabAbout").addEventListener("click", () => { this._view = "about"; this._update(); }); this._update(); } _update() { const root = this.shadowRoot; if (!root) return; // Preserve focus & cursor position for inputs that trigger a re-render (e.g. search). // Without this, mobile browsers may drop focus after each keystroke. const active = root.activeElement; const restore = { id: active && active.id ? String(active.id) : null, start: null, end: null, }; try { if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) { if (typeof active.selectionStart === "number" && typeof active.selectionEnd === "number") { restore.start = active.selectionStart; restore.end = active.selectionEnd; } } } catch (_) { // ignore } const subtitle = root.getElementById("subtitle"); if (subtitle) subtitle.textContent = this._view === "detail" ? "Details" : this._view[0].toUpperCase() + this._view.slice(1); const setActive = (id, on) => { const el = root.getElementById(id); if (!el) return; el.classList.toggle("active", !!on); }; setActive("tabStore", this._view === "store" || this._view === "detail"); setActive("tabManage", this._view === "manage"); setActive("tabAbout", this._view === "about"); const content = root.getElementById("content"); if (!content) return; const err = this._error ? `
Error: ${this._esc(this._error)}
` : ""; if (this._loading) { content.innerHTML = `${err}
Loading…
`; return; } if (!this._data?.ok) { content.innerHTML = `${err}
No data. Please refresh.
`; return; } let html = ""; if (this._view === "store") html = this._renderStore(); else if (this._view === "manage") html = this._renderManage(); else if (this._view === "about") html = this._renderAbout(); else if (this._view === "detail") html = this._renderDetail(); content.innerHTML = `${err}${html}`; if (this._view === "store") this._wireStore(); if (this._view === "manage") this._wireManage(); if (this._view === "detail") { this._wireDetail(); // now always wires buttons } // Restore focus and cursor for the search field after re-render. if (restore.id && this._view === "store") { const el = root.getElementById(restore.id); if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) { try { el.focus({ preventScroll: true }); if (restore.start !== null && restore.end !== null && typeof el.setSelectionRange === "function") { el.setSelectionRange(restore.start, restore.end); } } catch (_) { // ignore } } } } _safeText(v) { if (v === null || v === undefined) return ""; return String(v); } _safeId(v) { return this._safeText(v).trim(); } _esc(s) { return this._safeText(s) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } _asBoolStrict(v) { return v === true; } _renderStore() { const repos = Array.isArray(this._data.repos) ? this._data.repos : []; const filtered = repos .filter((r) => { const name = (this._safeText(r?.name) || "").toLowerCase(); const url = (this._safeText(r?.url) || "").toLowerCase(); const desc = (this._safeText(r?.description) || "").toLowerCase(); const q = (this._search || "").trim().toLowerCase(); if (q && !(name.includes(q) || url.includes(q) || desc.includes(q))) return false; const cat = this._safeText(r?.category) || ""; if (this._category !== "all" && this._category !== cat) return false; const latest = this._safeText(r?.latest_version); const installed = this._asBoolStrict(r?.installed); const installedVersion = this._safeText(r?.installed_version); const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion); if (this._filter === "installed" && !installed) return false; if (this._filter === "not_installed" && installed) return false; if (this._filter === "updates" && !updateAvailable) return false; if (this._filter === "custom" && r?.source !== "custom") return false; return true; }) .sort((a, b) => { const an = (this._safeText(a?.name) || "").toLowerCase(); const bn = (this._safeText(b?.name) || "").toLowerCase(); const alatest = this._safeText(a?.latest_version); const ainstalled = this._asBoolStrict(a?.installed); const ainstalledVersion = this._safeText(a?.installed_version); const aupdate = ainstalled && !!alatest && (!ainstalledVersion || alatest !== ainstalledVersion); const blatest = this._safeText(b?.latest_version); const binstalled = this._asBoolStrict(b?.installed); const binstalledVersion = this._safeText(b?.installed_version); const bupdate = binstalled && !!blatest && (!binstalledVersion || blatest !== binstalledVersion); if (this._sort === "updates_first") { if (aupdate !== bupdate) return aupdate ? -1 : 1; return an.localeCompare(bn); } if (this._sort === "installed_first") { if (ainstalled !== binstalled) return ainstalled ? -1 : 1; return an.localeCompare(bn); } return an.localeCompare(bn); }); const categories = Array.from( new Set(repos.map((r) => this._safeText(r?.category)).filter((c) => !!c)) ).sort(); const cards = filtered .map((r) => { const id = this._safeId(r?.id); const name = this._safeText(r?.name) || "Unnamed repository"; const url = this._safeText(r?.url) || ""; const desc = this._safeText(r?.description) || ""; const latest = this._safeText(r?.latest_version); const installed = this._asBoolStrict(r?.installed); const installedVersion = this._safeText(r?.installed_version); const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion); const badges = []; if (r?.source === "custom") badges.push("Custom"); if (installed) badges.push("Installed"); if (updateAvailable) badges.push("Update"); const badgeHtml = badges.length ? `
${this._esc(badges.join(" · "))}
` : `
${this._esc(this._safeText(r?.provider || "repo"))}
`; return `
${this._esc(name)}
${this._esc(desc)}
${this._esc(url)}
${badgeHtml}
`; }) .join(""); return `
Version: ${this._esc(this._data.version || "-")} · Repositories: ${repos.length}
${cards || `
No repositories found.
`}
`; } _wireStore() { const root = this.shadowRoot; const q = root.getElementById("q"); const cat = root.getElementById("cat"); const filter = root.getElementById("filter"); const sort = root.getElementById("sort"); if (q) { q.addEventListener("input", (e) => { this._search = e?.target?.value || ""; this._update(); }); } if (cat) { cat.addEventListener("change", (e) => { this._category = e?.target?.value || "all"; this._update(); }); } if (filter) { filter.addEventListener("change", (e) => { this._filter = e?.target?.value || "all"; this._update(); }); } if (sort) { sort.addEventListener("change", (e) => { this._sort = e?.target?.value || "az"; this._update(); }); } root.querySelectorAll("[data-open]").forEach((el) => { const id = el.getAttribute("data-open"); el.addEventListener("click", () => this._openRepoDetail(id)); }); } _renderAbout() { return `
About
Bahmcloud Store is a provider-neutral repository index and UI for Home Assistant.
Current integration version: ${this._esc(this._data?.version || "-")}
`; } _renderDetail() { const r = this._detailRepo; if (!r) return `
No repository selected.
`; const name = this._safeText(r?.name) || "Unnamed repository"; const url = this._safeText(r?.url) || ""; const desc = this._safeText(r?.description) || ""; const infoBits = [ this._safeText(r?.owner) ? `Creator: ${this._safeText(r?.owner)}` : "Creator: -", this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "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
${this._readmeCanToggle ? `
` : ``}
Show raw Markdown
${this._esc(this._readmeText)}
` : `
README
${this._esc(this._readmeError || "README not found.")}
`; const repoId = this._safeId(r?.id); const installed = this._asBoolStrict(r?.installed); const installedVersion = this._safeText(r?.installed_version); const installedDomains = Array.isArray(r?.installed_domains) ? r.installed_domains : []; const latestVersion = this._safeText(r?.latest_version); const busyInstall = this._installingRepoId === repoId; const busyUpdate = this._updatingRepoId === repoId; const busyUninstall = this._uninstallingRepoId === repoId; const busy = busyInstall || busyUpdate || busyUninstall; const updateAvailable = installed && !!latestVersion && (!installedVersion || latestVersion !== installedVersion); const installBtn = ``; const updateBtn = ``; const uninstallBtn = ``; const restartHint = this._restartRequired ? `
Restart required
Home Assistant must be restarted to load the installed integration.
` : (this._safeText(this._lastActionMsg) ? `
${this._esc(this._lastActionMsg)}
` : ""); return `
${this._esc(name)}
${this._esc(desc)}
${this._esc(infoBits.join(" · "))}
${this._esc(this._safeText(r?.provider || "repo"))}
${readmeBlock}
Installation & Updates
${installed ? "Installed" : "Not installed"}
Installed version: ${this._esc(installedVersion || "-")}
Latest version: ${this._esc(latestVersion || "-")}
Domains: ${installedDomains.length ? this._esc(installedDomains.join(", ")) : "-"}
${installBtn} ${updateBtn} ${uninstallBtn}
${restartHint}
`; } _wireDetail() { const root = this.shadowRoot; // Always wire action buttons (even if README is already loaded) const btnInstall = root.getElementById("btnInstall"); const btnUpdate = root.getElementById("btnUpdate"); const btnUninstall = root.getElementById("btnUninstall"); const btnRestart = root.getElementById("btnRestart"); const btnReadmeToggle = root.getElementById("btnReadmeToggle"); if (btnInstall) { btnInstall.addEventListener("click", () => { if (btnInstall.disabled) return; if (this._detailRepoId) this._installRepo(this._detailRepoId); }); } if (btnUpdate) { btnUpdate.addEventListener("click", () => { if (btnUpdate.disabled) return; if (this._detailRepoId) this._updateRepo(this._detailRepoId); }); } if (btnUninstall) { btnUninstall.addEventListener("click", () => { if (btnUninstall.disabled) return; if (this._detailRepoId) this._uninstallRepo(this._detailRepoId); }); } if (btnRestart) { btnRestart.addEventListener("click", () => this._restartHA()); } if (btnReadmeToggle) { btnReadmeToggle.addEventListener("click", () => { this._readmeExpanded = !this._readmeExpanded; this._update(); }); } const mount = root.getElementById("readmePretty"); if (!mount) return; if (this._readmeText) { if (this._readmeHtml) { mount.innerHTML = this._readmeHtml; this._postprocessRenderedMarkdown(mount); } else { mount.innerHTML = `
Rendered HTML not available. Use “Show raw Markdown”.
`; } } else { 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 (_) {} } _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 `
Custom Repositories
Add any public Git repository that contains a Home Assistant integration.
${list || `
No custom repositories added.
`}
`; } _wireManage() { const root = this.shadowRoot; const url = root.getElementById("customUrl"); const name = root.getElementById("customName"); const add = root.getElementById("addCustom"); if (url) url.addEventListener("input", (e) => { this._customAddUrl = e?.target?.value || ""; }); if (name) name.addEventListener("input", (e) => { this._customAddName = e?.target?.value || ""; }); if (add) add.addEventListener("click", () => this._addCustomRepo()); root.querySelectorAll("[data-remove]").forEach((el) => { const id = el.getAttribute("data-remove"); el.addEventListener("click", () => this._removeCustomRepo(id)); }); } } customElements.define("bahmcloud-store-panel", BahmcloudStorePanel);