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 = `
`;
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
? `
${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 `
${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);