Files
bahmcloud_store/custom_components/bahmcloud_store/panel/panel.js
2026-01-17 20:47:39 +00:00

1102 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `
<style>
:host { display:block; min-height:100%; --bcs-accent:#1E88E5; max-width:100%; overflow-x:hidden; }
.mobilebar{
position:sticky; top:0; z-index:50;
display:flex; align-items:center; justify-content:space-between;
gap:10px; padding:10px 12px;
background: var(--app-header-background-color, var(--card-background-color));
color: var(--app-header-text-color, var(--primary-text-color));
border-bottom:1px solid var(--divider-color);
}
.mobilebar .left, .mobilebar .right { display:flex; align-items:center; gap:10px; }
.iconbtn{
width:40px; height:40px; border-radius:14px;
display:flex; align-items:center; justify-content:center;
/* Ensure icon visibility on app header (light & dark modes) */
color: var(--app-header-text-color, var(--primary-text-color));
border: 1px solid rgba(255,255,255,0.35);
background: rgba(255,255,255,0.16);
cursor:pointer; user-select:none;
}
.iconbtn:hover{ filter:brightness(0.98); }
.wrap{ max-width:1200px; margin:0 auto; padding:16px; overflow-x:hidden; }
.tabs{ display:flex; gap:10px; flex-wrap:wrap; margin:8px 0 16px; }
.tab{
padding:10px 14px; border-radius:999px;
border:1px solid var(--divider-color);
cursor:pointer; user-select:none;
background: var(--card-background-color);
}
.tab.active{ border-color: var(--bcs-accent); box-shadow: 0 0 0 2px rgba(30,136,229,.15); }
.grid{ display:grid; gap:12px; grid-template-columns: repeat(1, minmax(0, 1fr)); }
@media (min-width: 900px){ .grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* Prevent grid children from forcing horizontal overflow (mobile) */
.grid > *{ min-width:0; }
.grid2{ display:grid; gap:12px; grid-template-columns: 1fr; }
@media (min-width: 1024px){ .grid2{ grid-template-columns: 1.2fr .8fr; } }
.grid2 > *{ min-width:0; }
.card{
padding:14px 14px;
border-radius:18px;
background: var(--card-background-color);
border:1px solid var(--divider-color);
box-shadow: 0 1px 0 rgba(0,0,0,.04);
overflow:hidden;
}
.row{ display:flex; align-items:flex-start; justify-content:space-between; gap:12px; flex-wrap:wrap; }
.muted{ color: var(--secondary-text-color); overflow-wrap:anywhere; word-break:break-word; }
.small{ font-size: 12px; }
.badge{
padding:6px 10px;
border-radius:999px;
border:1px solid var(--divider-color);
background: rgba(30,136,229,.06);
color: var(--primary-text-color);
font-size: 12px;
white-space:normal;
max-width:55%;
overflow:hidden;
text-overflow:ellipsis;
}
.filters{ display:flex; gap:10px; flex-wrap:wrap; margin-bottom:12px; }
input, select{
padding:10px 12px;
border-radius:14px;
border:1px solid var(--divider-color);
background: var(--card-background-color);
color: var(--primary-text-color);
outline:none;
}
input:focus, select:focus{
border-color: var(--bcs-accent);
box-shadow: 0 0 0 2px rgba(30,136,229,.15);
}
button{
padding:10px 12px;
border-radius:14px;
border:1px solid var(--divider-color);
background: var(--card-background-color);
color: var(--primary-text-color);
cursor:pointer;
}
button.primary{
border-color: rgba(30,136,229,.35);
background: rgba(30,136,229,.08);
}
button:disabled{ opacity: .55; cursor: not-allowed; }
.err{
margin:12px 0;
padding:12px 14px;
border-radius:14px;
border:1px solid rgba(255, 82, 82, .35);
background: rgba(255, 82, 82, .08);
}
pre.readme{
padding: 12px;
border-radius: 14px;
border: 1px solid var(--divider-color);
background: rgba(0,0,0,.04);
overflow:auto;
font-size: 12px;
line-height: 1.4;
}
/* Markdown can contain very wide content (tables/images). Keep it within the viewport. */
.md{ overflow-wrap:anywhere; word-break:break-word; max-width:100%; min-width:0; overflow-x:auto; -webkit-overflow-scrolling:touch; }
.md :is(h1,h2,h3){ margin-top: 12px; }
.md img{ max-width:100%; height:auto; }
.md code{
padding: 2px 5px;
border-radius: 8px;
border: 1px solid var(--divider-color);
background: rgba(0,0,0,.04);
}
.md pre{
padding: 12px;
border-radius: 14px;
border: 1px solid var(--divider-color);
background: rgba(0,0,0,.04);
overflow:auto;
}
.md table{
width:100%;
border-collapse: collapse;
overflow-x:auto;
overflow-y:hidden;
-webkit-overflow-scrolling: touch;
display:block;
max-width:100%;
}
.readmeWrap{
margin-top:12px;
max-width:100%;
border:1px solid var(--divider-color);
border-radius:14px;
padding:12px;
background: rgba(0,0,0,.02);
position:relative;
overflow:hidden;
}
.readmeWrap.collapsed{
max-height:260px;
}
@media (min-width: 1024px){
.readmeWrap.collapsed{ max-height:340px; }
}
.readmeWrap.collapsed::after{
content:"";
position:absolute;
left:0; right:0; bottom:0;
height:64px;
background: linear-gradient(to bottom, rgba(0,0,0,0), var(--card-background-color));
pointer-events:none;
}
.readmeWrap.expanded{
max-height:70vh;
overflow:auto;
-webkit-overflow-scrolling:touch;
}
.readmeActions{
display:flex; justify-content:flex-end;
margin-top:10px;
}
button.link{
border:1px solid transparent;
background: transparent;
color: var(--bcs-accent);
padding:8px 10px;
}
button.link:hover{
border-color: rgba(30,136,229,.25);
background: rgba(30,136,229,.06);
}
.md th, .md td{
border: 1px solid var(--divider-color);
padding: 8px;
text-align:left;
max-width:100%;
overflow-wrap:anywhere;
word-break:break-word;
}
</style>
<div class="mobilebar">
<div class="left">
<div class="iconbtn" id="menuBtn" title="Menu">☰</div>
<div class="iconbtn" id="backBtn" title="Back">←</div>
<div>
<div style="font-weight:700;">Bahmcloud Store</div>
<div class="muted small" id="subtitle">Store</div>
</div>
</div>
<div class="right">
<div class="iconbtn" id="refreshBtn" title="Refresh">⟳</div>
</div>
</div>
<div class="wrap">
<div class="tabs">
<div class="tab" id="tabStore">Store</div>
<div class="tab" id="tabManage">Manage</div>
<div class="tab" id="tabAbout">About</div>
</div>
<div id="content"></div>
</div>
`;
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
? `<div class="err"><strong>Error:</strong> ${this._esc(this._error)}</div>`
: "";
if (this._loading) {
content.innerHTML = `${err}<div class="card">Loading…</div>`;
return;
}
if (!this._data?.ok) {
content.innerHTML = `${err}<div class="card">No data. Please refresh.</div>`;
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
_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
? `<div class="badge">${this._esc(badges.join(" · "))}</div>`
: `<div class="badge">${this._esc(this._safeText(r?.provider || "repo"))}</div>`;
return `
<div class="card" data-open="${this._esc(id)}">
<div class="row">
<div>
<div><strong>${this._esc(name)}</strong></div>
<div class="muted">${this._esc(desc)}</div>
<div class="muted small" style="margin-top:8px;">${this._esc(url)}</div>
</div>
${badgeHtml}
</div>
</div>
`;
})
.join("");
return `
<div class="filters">
<input id="q" placeholder="Search…" value="${this._esc(this._search)}" />
<select id="cat">
<option value="all">All categories</option>
${categories.map((c) => `<option value="${this._esc(c)}" ${this._category === c ? "selected" : ""}>${this._esc(c)}</option>`).join("")}
</select>
<select id="filter">
<option value="all" ${this._filter === "all" ? "selected" : ""}>All</option>
<option value="installed" ${this._filter === "installed" ? "selected" : ""}>Installed</option>
<option value="not_installed" ${this._filter === "not_installed" ? "selected" : ""}>Not installed</option>
<option value="updates" ${this._filter === "updates" ? "selected" : ""}>Updates available</option>
<option value="custom" ${this._filter === "custom" ? "selected" : ""}>Custom repos</option>
</select>
<select id="sort">
<option value="az" ${this._sort === "az" ? "selected" : ""}>AZ</option>
<option value="updates_first" ${this._sort === "updates_first" ? "selected" : ""}>Updates first</option>
<option value="installed_first" ${this._sort === "installed_first" ? "selected" : ""}>Installed first</option>
</select>
</div>
<div class="muted small">Version: ${this._esc(this._data.version || "-")} · Repositories: ${repos.length}</div>
<div class="grid" style="margin-top:12px;">
${cards || `<div class="card">No repositories found.</div>`}
</div>
`;
}
_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 `
<div class="card">
<div><strong>About</strong></div>
<div class="muted" style="margin-top:10px;">
Bahmcloud Store is a provider-neutral repository index and UI for Home Assistant.
</div>
<div class="muted small" style="margin-top:10px;">
Current integration version: <strong>${this._esc(this._data?.version || "-")}</strong>
</div>
</div>
`;
}
_renderDetail() {
const r = this._detailRepo;
if (!r) return `<div class="card">No repository selected.</div>`;
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
? `<div class="card">Loading README…</div>`
: this._readmeText
? `
<div class="card">
<div class="row" style="align-items:center;">
<div><strong>README</strong></div>
<div class="muted small">Rendered Markdown</div>
</div>
<div class="readmeWrap ${this._readmeExpanded ? "expanded" : "collapsed"}">
<div id="readmePretty" class="md"></div>
</div>
${this._readmeCanToggle ? `
<div class="readmeActions">
<button class="link" id="btnReadmeToggle">${this._readmeExpanded ? "Show less" : "Show more"}</button>
</div>
` : ``}
<details>
<summary>Show raw Markdown</summary>
<div style="margin-top:10px;">
<pre class="readme">${this._esc(this._readmeText)}</pre>
</div>
</details>
</div>
`
: `
<div class="card">
<div><strong>README</strong></div>
<div class="muted">${this._esc(this._readmeError || "README not found.")}</div>
</div>
`;
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 = `<button class="primary" id="btnInstall" ${installed || busy ? "disabled" : ""}>${busyInstall ? "Installing…" : installed ? "Installed" : "Install"}</button>`;
const updateBtn = `<button class="primary" id="btnUpdate" ${!updateAvailable || busy ? "disabled" : ""}>${busyUpdate ? "Updating…" : updateAvailable ? "Update" : "Up to date"}</button>`;
const uninstallBtn = `<button class="primary" id="btnUninstall" ${!installed || busy ? "disabled" : ""}>${busyUninstall ? "Uninstalling…" : "Uninstall"}</button>`;
const restartHint = this._restartRequired
? `
<div class="card" style="margin-top:12px; background: rgba(255, 193, 7, 0.12); border: 1px solid rgba(255, 193, 7, 0.35);">
<div><strong>Restart required</strong></div>
<div class="muted" style="margin-top:6px;">Home Assistant must be restarted to load the installed integration.</div>
<div class="row" style="margin-top:12px;">
<button class="primary" id="btnRestart">Restart now</button>
</div>
</div>
`
: (this._safeText(this._lastActionMsg)
? `<div class="muted small" style="margin-top:12px;">${this._esc(this._lastActionMsg)}</div>`
: "");
return `
<div class="grid2">
<div>
<div class="card">
<div class="row">
<div>
<div><strong style="font-size:16px;">${this._esc(name)}</strong></div>
<div class="muted">${this._esc(desc)}</div>
<div class="muted small" style="margin-top:8px;">${this._esc(infoBits.join(" · "))}</div>
<div class="muted small" style="margin-top:8px;">
<a href="${this._esc(url)}" target="_blank" rel="noreferrer">Open repository</a>
</div>
</div>
<div class="badge">${this._esc(this._safeText(r?.provider || "repo"))}</div>
</div>
</div>
${readmeBlock}
</div>
<div>
<div class="card">
<div class="row" style="align-items:center;">
<div><strong>Installation & Updates</strong></div>
<div class="muted small">${installed ? "Installed" : "Not installed"}</div>
</div>
<div class="muted" style="margin-top:10px;">
<div><strong>Installed version:</strong> ${this._esc(installedVersion || "-")}</div>
<div><strong>Latest version:</strong> ${this._esc(latestVersion || "-")}</div>
<div style="margin-top:6px;"><strong>Domains:</strong> ${installedDomains.length ? this._esc(installedDomains.join(", ")) : "-"}</div>
</div>
<div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;">
${installBtn}
${updateBtn}
${uninstallBtn}
</div>
${restartHint}
</div>
</div>
</div>
`;
}
_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 = `<div class="muted">Rendered HTML not available. Use “Show raw Markdown”.</div>`;
}
} 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 `
<div class="card">
<div class="row">
<div>
<div><strong>${this._esc(name)}</strong></div>
<div class="muted">${this._esc(url)}</div>
</div>
<div>
<button class="primary" data-remove="${this._esc(id)}">Remove</button>
</div>
</div>
</div>
`;
})
.join("");
return `
<div class="card">
<div><strong>Custom Repositories</strong></div>
<div class="muted" style="margin-top:10px;">
Add any public Git repository that contains a Home Assistant integration.
</div>
<div class="filters" style="margin-top:12px;">
<input id="customUrl" placeholder="Repository URL" value="${this._esc(this._customAddUrl)}" />
<input id="customName" placeholder="Optional name" value="${this._esc(this._customAddName)}" />
<button class="primary" id="addCustom">Add</button>
</div>
</div>
<div class="grid" style="margin-top:12px;">
${list || `<div class="card">No custom repositories added.</div>`}
</div>
`;
}
_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);