diff --git a/custom_components/bahmcloud_store/panel/app.js b/custom_components/bahmcloud_store/panel/app.js index 75147d7..412bf0e 100644 --- a/custom_components/bahmcloud_store/panel/app.js +++ b/custom_components/bahmcloud_store/panel/app.js @@ -26,6 +26,11 @@ class BahmcloudStorePanel extends HTMLElement { this._readmeError = null; this._refreshing = false; + + this._installingRepoId = null; + this._updatingRepoId = null; + this._restartRequired = false; + this._lastActionMsg = null; } set hass(hass) { @@ -47,6 +52,12 @@ class BahmcloudStorePanel extends HTMLElement { try { const data = await this._hass.callApi("get", "bcs"); this._data = data; + + // keep detail repo fresh (installed state, versions, etc.) + 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 { @@ -83,6 +94,68 @@ class BahmcloudStorePanel extends HTMLElement { 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 _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; } @@ -218,385 +291,355 @@ class BahmcloudStorePanel extends HTMLElement { .iconbtn{ width:40px; height:40px; border-radius:14px; + display:flex; align-items:center; justify-content:center; border:1px solid var(--divider-color); - background: color-mix(in srgb, var(--card-background-color) 82%, transparent); - color:inherit; display:inline-flex; align-items:center; justify-content:center; - cursor:pointer; user-select:none; font-weight:900; font-size:18px; line-height:1; + background: var(--card-background-color); + cursor:pointer; user-select:none; } - .iconbtn:hover{ box-shadow: 0 10px 30px rgba(0,0,0,0.10); transform: translateY(-1px); } - .iconbtn:active{ transform: translateY(0px); box-shadow:none; } - @media (min-width: 1024px) { .iconbtn.menu { display:none; } } - - .brandtitle{ display:flex; flex-direction:column; line-height:1.2; } - .brandtitle .t{ font-size:16px; font-weight:900; letter-spacing: .2px; } - .brandtitle .s{ font-size:12px; color:var(--secondary-text-color); margin-top:2px; } + .iconbtn:hover{ filter:brightness(0.98); } .wrap{ - padding:16px; max-width:1100px; margin:0 auto; - font-family: system-ui,-apple-system,Segoe UI,Roboto,sans-serif; - color:var(--primary-text-color); + max-width:1200px; margin:0 auto; padding:16px; } - .tabs{ display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; } + .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); - background:var(--card-background-color); - color:var(--primary-text-color); - padding:8px 12px; border-radius:999px; - cursor:pointer; font-weight:800; font-size:13px; + cursor:pointer; user-select:none; + background: var(--card-background-color); } - .tab.active{ - border-color:var(--bcs-accent); - box-shadow:0 0 0 2px color-mix(in srgb, var(--bcs-accent) 20%, transparent); + .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)); } } - 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; font-weight:900; + .grid2{ + display:grid; gap:12px; + grid-template-columns: 1fr; } - button.primary{ - border-color:var(--bcs-accent); - background: color-mix(in srgb, var(--bcs-accent) 16%, var(--card-background-color)); + @media (min-width: 1024px){ + .grid2{ grid-template-columns: 1.2fr .8fr; } } - button:hover{ box-shadow: 0 10px 30px rgba(0,0,0,0.10); transform: translateY(-1px); } - button:active{ transform: translateY(0px); box-shadow:none; } - button:disabled{ opacity: 0.55; cursor: not-allowed; } .card{ + padding:14px 14px; + border-radius:18px; + background: var(--card-background-color); border:1px solid var(--divider-color); - background:var(--card-background-color); - border-radius:18px; padding:12px; margin:10px 0; + box-shadow: 0 1px 0 rgba(0,0,0,.04); } - .card.clickable{ - cursor:pointer; - transition: transform 120ms ease, box-shadow 120ms ease; - } - .card.clickable:hover{ - transform: translateY(-1px); - box-shadow: 0 12px 34px rgba(0,0,0,0.10); - } - - .row{ display:flex; justify-content:space-between; gap:10px; align-items:flex-start; } - .muted{ color:var(--secondary-text-color); font-size:13px; margin-top:4px; } - .small{ font-size:12px; } - + .row{ display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } + .muted{ color: var(--secondary-text-color); } + .small{ font-size: 12px; } .badge{ + padding:6px 10px; + border-radius:999px; border:1px solid var(--divider-color); - border-radius:999px; padding:2px 10px; - font-size:12px; font-weight:900; height:fit-content; - } - .badge.custom{ border-color:var(--bcs-accent); color:var(--bcs-accent); } - - .error{ color:#b00020; white-space:pre-wrap; margin-top:10px; } - - .grid2{ display:grid; grid-template-columns: 1fr; gap: 12px; } - @media (min-width: 900px){ .grid2{ grid-template-columns: 1.2fr 0.8fr; } } - - .filters{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:12px; } - .chips{ display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; } - - .chip{ - display:inline-flex; align-items:center; gap:8px; - padding:6px 10px; border-radius:999px; - border:1px solid var(--divider-color); - background:var(--card-background-color); - cursor:pointer; user-select:none; font-weight:800; font-size:12px; - } - .chip strong{ font-size:12px; } - .chip.active{ - border-color:var(--bcs-accent); - box-shadow:0 0 0 2px color-mix(in srgb, var(--bcs-accent) 18%, transparent); + background: rgba(30,136,229,.06); + color: var(--primary-text-color); + font-size: 12px; + white-space:nowrap; } + .filters{ display:flex; gap:10px; flex-wrap:wrap; margin-bottom:12px; } input, select{ - padding:10px 12px; border-radius:14px; + padding:10px 12px; + border-radius:14px; border:1px solid var(--divider-color); - background:var(--card-background-color); - color:var(--primary-text-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 color-mix(in srgb, var(--bcs-accent) 20%, transparent); + border-color: var(--bcs-accent); + box-shadow: 0 0 0 2px rgba(30,136,229,.15); } - a{ color:var(--bcs-accent); text-decoration:none; } - a:hover{ text-decoration:underline; } + 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); + } .fabs{ position: fixed; - right: 18px; - bottom: 18px; - display: grid; - gap: 10px; - z-index: 100; + right: 16px; + bottom: 16px; + display:flex; + flex-direction:column; + gap:10px; + z-index: 60; } .fab{ - width: 56px; - height: 56px; - border-radius: 18px; - border: 1px solid var(--divider-color); + width:54px; height:54px; + border-radius:18px; + border:1px solid var(--divider-color); background: var(--card-background-color); - box-shadow: 0 12px 30px rgba(0,0,0,0.14); - display: inline-flex; - align-items: center; - justify-content: center; + display:flex; align-items:center; justify-content:center; + cursor:pointer; + box-shadow: 0 8px 18px rgba(0,0,0,.12); + user-select:none; font-size: 18px; - font-weight: 900; - cursor: pointer; - user-select: none; } .fab.primary{ - border-color: var(--bcs-accent); - background: color-mix(in srgb, var(--bcs-accent) 18%, var(--card-background-color)); + border-color: rgba(30,136,229,.35); + background: rgba(30,136,229,.10); } .fab[disabled]{ opacity: .55; cursor: not-allowed; } pre.readme{ - white-space: pre-wrap; - word-break: break-word; - margin: 0; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - font-size: 12.5px; - line-height: 1.5; + 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; } - details{ margin-top: 10px; } - summary{ cursor:pointer; color: var(--bcs-accent); font-weight: 900; } - .md { line-height: 1.65; font-size: 14px; } - .md :is(h1,h2,h3){ margin: 18px 0 10px; } - .md :is(p,ul,ol,pre,blockquote,table){ margin: 10px 0; } - .md pre { overflow:auto; padding:12px; border-radius:14px; border:1px solid var(--divider-color); } - .md code { padding:2px 6px; border-radius:8px; border:1px solid var(--divider-color); } - .md blockquote { border-left:4px solid var(--bcs-accent); padding:8px 12px; border-radius:12px; - background: color-mix(in srgb, var(--bcs-accent) 8%, var(--card-background-color)); } - .md table{ width:100%; border-collapse: collapse; } - .md th,.md td{ border:1px solid var(--divider-color); padding:8px; text-align:left; } - .md img{ max-width:100%; height:auto; border-radius:12px; } + .md :is(h1,h2,h3){ margin-top: 12px; } + .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:auto; + display:block; + } + .md th, .md td{ + border: 1px solid var(--divider-color); + padding: 8px; + text-align:left; + }
- +
-
-
Bahmcloud Store
-
BCS — loading…
+
+
Bahmcloud Store
+
Store
- +
-
-
Store
-
Manage repositories
-
Settings / About
+
+
Store
+
Manage
+
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()); + root.getElementById("refreshBtn").addEventListener("click", () => this._refreshAll()); - for (const tab of root.querySelectorAll(".tab")) { - tab.addEventListener("click", () => { - this._view = tab.getAttribute("data-view"); - this._update(); - }); - } + 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(); + }); - // 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 (_) {} + this._update(); } _update() { const root = this.shadowRoot; - const focusState = this._captureFocusState(); + if (!root) return; + + const subtitle = root.getElementById("subtitle"); + if (subtitle) subtitle.textContent = this._view === "detail" ? "Details" : this._view[0].toUpperCase() + this._view.slice(1); + + // tabs + 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"); - const err = root.getElementById("error"); - const subtitle = root.getElementById("subtitle"); const fabs = root.getElementById("fabs"); - const refreshBtn = root.getElementById("refreshBtn"); + if (!content || !fabs) return; - // 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(); + // error block + const err = this._error + ? `
Error: ${this._esc(this._error)}
` + : ""; if (this._loading) { - content.innerHTML = `
Loading…
`; - this._restoreFocusState(focusState); + content.innerHTML = `${err}
Loading…
`; + fabs.innerHTML = ""; return; } - if (!this._data) { - content.innerHTML = `
No data.
`; - this._restoreFocusState(focusState); + if (!this._data?.ok) { + content.innerHTML = `${err}
No data. Please refresh.
`; + fabs.innerHTML = ""; return; } - if (this._view === "store") { - content.innerHTML = this._renderStore(); - this._wireStore(); - this._restoreFocusState(focusState); - return; - } + // render view + 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(); - if (this._view === "manage") { - content.innerHTML = this._renderManage(); - this._wireManage(); - this._restoreFocusState(focusState); - return; - } + content.innerHTML = `${err}${html}`; + fabs.innerHTML = this._view === "detail" ? this._renderFabs() : ""; - if (this._view === "about") { - content.innerHTML = this._renderAbout(); - this._restoreFocusState(focusState); - return; + // wire view interactions + if (this._view === "store") this._wireStore(); + if (this._view === "manage") this._wireManage(); + if (this._view === "detail") { + this._wireDetail(); + this._wireFabs(); } - - content.innerHTML = this._renderDetail(); - this._wireDetail(); - this._restoreFocusState(focusState); } - // --- The rest of your file is unchanged --- - // (Store rendering, detail rendering, manage, helpers) + _safeText(v) { + if (v === null || v === undefined) return ""; + return String(v); + } + + _safeId(v) { + const s = this._safeText(v).trim(); + return s; + } + + _esc(s) { + return this._safeText(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } _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 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) 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); + + 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 prov = this._safeText(r?.provider) || "other"; + if (this._provider !== "all") { + if (this._provider === "custom" && r?.source !== "custom") return false; + if (this._provider !== "custom" && prov !== this._provider) return false; + } + return true; }) - .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; + .sort((a, b) => { + const an = (this._safeText(a?.name) || "").toLowerCase(); + const bn = (this._safeText(b?.name) || "").toLowerCase(); + return an.localeCompare(bn); }); - const options = [ - ``, - ...categories.map( - (c) => - `` - ), - ].join(""); + const categories = Array.from( + new Set(repos.map((r) => this._safeText(r?.category)).filter((c) => !!c)) + ).sort(); - const providerChips = this._renderProviderChips(stats); + const providers = ["github", "gitlab", "gitea", "other"]; - const rows = filtered + const cards = 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 url = this._safeText(r?.url) || ""; + const desc = this._safeText(r?.description) || ""; - const badge = - r?.source === "custom" - ? `Custom` - : `Index`; + const latest = this._safeText(r?.latest_version); + const installed = !!r?.installed; + const installedVersion = this._safeText(r?.installed_version); + const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion); - 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 badges = []; + if (r?.source === "custom") badges.push("Custom"); + if (installed) badges.push("Installed"); + if (updateAvailable) badges.push("Update"); - const lineBits = [creator, latest, cat, metaSrc].filter(Boolean); + const badgeHtml = badges.length + ? `
${this._esc(badges.join(" · "))}
` + : `
${this._esc(this._safeText(r?.provider || "repo"))}
`; return ` -
+
${this._esc(name)}
${this._esc(desc)}
-
${this._esc(lineBits.join(" · "))}
+
${this._esc(url)}
- ${badge} + ${badgeHtml}
`; @@ -604,119 +647,84 @@ class BahmcloudStorePanel extends HTMLElement { .join(""); return ` -
-
Providers
-
${providerChips}
-
-
- - + + +
- ${rows || `
No repositories match this filter.
`} +
Version: ${this._esc(this._data.version || "-")} · Repositories: ${repos.length}
+ +
+ ${cards || `
No repositories found.
`} +
`; } _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; + const q = root.getElementById("q"); + const cat = root.getElementById("cat"); + const prov = root.getElementById("prov"); + + if (q) { + q.addEventListener("input", (e) => { + this._search = e?.target?.value || ""; this._update(); }); } - if (cat) { cat.addEventListener("change", (e) => { - this._category = String(e.target.value || "all"); + this._category = e?.target?.value || "all"; + this._update(); + }); + } + if (prov) { + prov.addEventListener("change", (e) => { + this._provider = 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); - }); - } + // card clicks + root.querySelectorAll("[data-open]").forEach((el) => { + const id = el.getAttribute("data-open"); + el.addEventListener("click", () => 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; + _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 `
Repository not found.
`; + if (!r) return `
No repository selected.
`; const name = this._safeText(r?.name) || "Unnamed repository"; - const desc = this._safeText(r?.description) || "No description available."; - const url = this._safeText(r?.url) || "#"; + const url = this._safeText(r?.url) || ""; + const desc = this._safeText(r?.description) || ""; - const badge = - r?.source === "custom" - ? `Custom` - : `Index`; - - const latest = this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: unknown"; + const latest = this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: -"; + const badge = `
${this._esc(this._safeText(r?.provider || "repo"))}
`; const infoBits = [ this._safeText(r?.owner) ? `Creator: ${this._safeText(r?.owner)}` : "Creator: -", @@ -755,6 +763,36 @@ class BahmcloudStorePanel extends HTMLElement {
`; + const repoId = this._safeId(r?.id); + + const installed = !!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 busy = busyInstall || busyUpdate; + + const updateAvailable = installed && !!latestVersion && (!installedVersion || latestVersion !== installedVersion); + + const installBtn = ``; + const updateBtn = ``; + + 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 `
@@ -777,14 +815,23 @@ class BahmcloudStorePanel extends HTMLElement {
-
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. +
+
Installation & Updates
+
${installed ? "Installed" : "Not installed"}
-
- Updates remain manual (like HACS). + +
+
Installed version: ${this._esc(installedVersion || "-")}
+
Latest version: ${this._esc(latestVersion || "-")}
+
Domains: ${installedDomains.length ? this._esc(installedDomains.join(", ")) : "-"}
+ +
+ ${installBtn} + ${updateBtn} +
+ + ${restartHint}
@@ -807,6 +854,30 @@ class BahmcloudStorePanel extends HTMLElement { } mount.innerHTML = ""; + + const btnInstall = root.getElementById("btnInstall"); + const btnUpdate = root.getElementById("btnUpdate"); + const btnRestart = root.getElementById("btnRestart"); + + if (btnInstall) { + btnInstall.addEventListener("click", () => { + if (btnInstall.hasAttribute("disabled")) return; + if (this._detailRepoId) this._installRepo(this._detailRepoId); + }); + } + + if (btnUpdate) { + btnUpdate.addEventListener("click", () => { + if (btnUpdate.hasAttribute("disabled")) return; + if (this._detailRepoId) this._updateRepo(this._detailRepoId); + }); + } + + if (btnRestart) { + btnRestart.addEventListener("click", () => { + this._restartHA(); + }); + } } _postprocessRenderedMarkdown(container) { @@ -824,12 +895,26 @@ class BahmcloudStorePanel extends HTMLElement { const r = this._detailRepo; if (!r) return ""; + const repoId = this._safeId(r?.id); + const installed = !!r?.installed; + const latest = this._safeText(r?.latest_version); + const installedVersion = this._safeText(r?.installed_version); + + const busy = this._installingRepoId === repoId || this._updatingRepoId === repoId; + const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion); + + const installDisabled = installed || busy; + const updateDisabled = !updateAvailable || busy; + + const installTitle = installed ? "Already installed" : busy ? "Installing…" : "Install"; + const updateTitle = !installed ? "Not installed" : !updateAvailable ? "No update available" : busy ? "Updating…" : "Update"; + return `
-
-
+
+
i
`; @@ -844,14 +929,33 @@ class BahmcloudStorePanel extends HTMLElement { const open = root.getElementById("fabOpen"); const reload = root.getElementById("fabReload"); + const install = root.getElementById("fabInstall"); + const update = root.getElementById("fabUpdate"); const info = root.getElementById("fabInfo"); + const repoId = this._safeId(r?.id); + if (open) open.addEventListener("click", () => url && window.open(url, "_blank", "noreferrer")); if (reload) { reload.addEventListener("click", () => { if (this._detailRepoId) this._loadReadme(this._detailRepoId); }); } + + if (install) { + install.addEventListener("click", () => { + if (install.hasAttribute("disabled")) return; + this._installRepo(repoId); + }); + } + + if (update) { + update.addEventListener("click", () => { + if (update.hasAttribute("disabled")) return; + this._updateRepo(repoId); + }); + } + if (info) { info.addEventListener("click", () => { this._view = "about"; @@ -887,98 +991,48 @@ class BahmcloudStorePanel extends HTMLElement { return `
-
Manage repositories
-
Add public repositories from any git provider.
+
Custom Repositories
+
+ Add any public Git repository that contains a Home Assistant integration. +
-
-
-
Repository URL
- -
- -
-
Display name (optional)
- -
- -
- -
+
+ + +
- ${list || `
No custom repositories added yet.
`} +
+ ${list || `
No custom repositories added.
`} +
`; } _wireManage() { const root = this.shadowRoot; - const addUrl = root.getElementById("addUrl"); - const addName = root.getElementById("addName"); - const addBtn = root.getElementById("addBtn"); + const url = root.getElementById("customUrl"); + const name = root.getElementById("customName"); + const add = root.getElementById("addCustom"); - 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); + if (url) { + url.addEventListener("input", (e) => { + this._customAddUrl = e?.target?.value || ""; }); } - } - - _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); + if (name) { + name.addEventListener("input", (e) => { + this._customAddName = e?.target?.value || ""; + }); } - return Array.from(set).sort(); - } + if (add) add.addEventListener("click", () => this._addCustomRepo()); - // --- 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("'", "'"); + root.querySelectorAll("[data-remove]").forEach((el) => { + const id = el.getAttribute("data-remove"); + el.addEventListener("click", () => this._removeCustomRepo(id)); + }); } } -customElements.define("bahmcloud-store-panel", BahmcloudStorePanel); +customElements.define("bahmcloud-store-panel", BahmcloudStorePanel); \ No newline at end of file