diff --git a/custom_components/bahmcloud_store/panel/panel.js b/custom_components/bahmcloud_store/panel/panel.js index d24ebb6..5d18e5b 100644 --- a/custom_components/bahmcloud_store/panel/panel.js +++ b/custom_components/bahmcloud_store/panel/panel.js @@ -5,7 +5,7 @@ class BahmcloudStorePanel extends HTMLElement { this._hass = null; - this._view = "store"; // store | manage | about | detail + this._view = "store"; this._data = null; this._loading = true; this._error = null; @@ -15,15 +15,15 @@ class BahmcloudStorePanel extends HTMLElement { this._search = ""; this._category = "all"; - this._provider = "all"; // all|github|gitea|gitlab|other|custom + this._provider = "all"; this._detailRepoId = null; this._detailRepo = null; this._readmeLoading = false; - this._readmeText = null; // markdown string - this._readmeHtml = null; // sanitized html string - this._readmeError = null; // string + this._readmeText = null; + this._readmeHtml = null; + this._readmeError = null; } set hass(hass) { @@ -37,7 +37,6 @@ class BahmcloudStorePanel extends HTMLElement { async _load() { if (!this._hass) return; - this._loading = true; this._error = null; this._update(); @@ -53,6 +52,32 @@ class BahmcloudStorePanel extends HTMLElement { } } + async _refreshNow() { + if (!this._hass) return; + + this._error = null; + this._loading = true; + this._update(); + + try { + // trigger backend refresh (reload store.yaml/bcs.yaml) + const resp = await this._hass.callApi("post", "bcs/refresh", {}); + if (resp && resp.ok) { + // Load fresh data afterwards to keep UI consistent + await this._load(); + } else { + const msg = (resp && typeof resp.message === "string") ? resp.message : "Refresh failed."; + this._error = msg; + this._loading = false; + this._update(); + } + } catch (e) { + this._error = this._toErrString(e); + this._loading = false; + this._update(); + } + } + _isDesktop() { return window.matchMedia && window.matchMedia("(min-width: 1024px)").matches; } @@ -83,125 +108,11 @@ class BahmcloudStorePanel extends HTMLElement { } } - 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 = this._toErrString(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 = this._toErrString(e); - this._update(); - } - } - - _openRepoDetail(repoId) { - const repos = Array.isArray(this._data?.repos) ? this._data.repos : []; - const repo = repos.find((r) => this._safeId(r?.id) === repoId); - if (!repo) return; - - this._view = "detail"; - this._detailRepoId = repoId; - this._detailRepo = repo; - - this._readmeText = null; - this._readmeHtml = null; - this._readmeError = null; - - this._update(); - this._loadReadme(repoId); - } - - // --- README fetching (hardened) --- - async _loadReadme(repoId) { - if (!this._hass) return; - - this._readmeLoading = true; - this._readmeText = null; - this._readmeHtml = null; - this._readmeError = null; - this._update(); - - try { - const resp = await this._hass.callApi( - "get", - `bcs/readme?repo_id=${encodeURIComponent(repoId)}` - ); - - // Normalize fields strictly (avoid [object Object]) - const ok = resp && resp.ok === true; - - const readme = (resp && typeof resp.readme === "string") ? resp.readme : null; - const html = (resp && typeof resp.html === "string") ? resp.html : null; - - if (ok && readme && readme.trim()) { - this._readmeText = readme; - this._readmeHtml = html && html.trim() ? html : null; - this._readmeError = null; - } else { - // If backend provided a message, convert it safely - const msg = - (resp && typeof resp.message === "string" && resp.message.trim()) - ? resp.message.trim() - : "README not found."; - - // Extra hint if backend returned unexpected types - if (ok && resp && resp.readme && typeof resp.readme !== "string") { - this._readmeError = "README has an unsupported format (expected text)."; - } else { - this._readmeError = msg; - } - - this._readmeText = null; - this._readmeHtml = null; - } - } catch (e) { - this._readmeText = null; - this._readmeHtml = null; - this._readmeError = this._toErrString(e) || "README not found."; - } finally { - this._readmeLoading = false; - this._update(); - } - } - _render() { const root = this.shadowRoot; - root.innerHTML = `
@@ -399,11 +187,9 @@ class BahmcloudStorePanel extends HTMLElement {
- -
`; - root.getElementById("refreshBtn").addEventListener("click", () => this._load()); + root.getElementById("refreshBtn").addEventListener("click", () => this._refreshNow()); root.getElementById("menuBtn").addEventListener("click", () => this._toggleMenu()); root.getElementById("backBtn").addEventListener("click", () => this._goBack()); @@ -413,63 +199,16 @@ class BahmcloudStorePanel extends HTMLElement { this._update(); }); } - - // prevent HA global shortcuts while typing - const stopIfFormField = (e) => { - const t = e.composedPath ? e.composedPath()[0] : e.target; - if (!t) return; - const tag = (t.tagName || "").toLowerCase(); - const isEditable = - tag === "input" || tag === "textarea" || tag === "select" || t.isContentEditable; - if (isEditable) e.stopPropagation(); - }; - root.addEventListener("keydown", stopIfFormField, true); - root.addEventListener("keyup", stopIfFormField, true); - root.addEventListener("keypress", stopIfFormField, true); - } - - _captureFocusState() { - const root = this.shadowRoot; - const ae = root.activeElement; - if (!ae || !ae.id) return null; - - const supported = new Set(["searchInput", "categorySelect", "addUrl", "addName"]); - if (!supported.has(ae.id)) return null; - - return { - id: ae.id, - value: ae.value, - selectionStart: typeof ae.selectionStart === "number" ? ae.selectionStart : null, - selectionEnd: typeof ae.selectionEnd === "number" ? ae.selectionEnd : null, - }; - } - - _restoreFocusState(state) { - if (!state) return; - const root = this.shadowRoot; - const el = root.getElementById(state.id); - if (!el) return; - - try { - el.focus({ preventScroll: true }); - if ( - state.selectionStart !== null && - state.selectionEnd !== null && - typeof el.setSelectionRange === "function" - ) { - el.setSelectionRange(state.selectionStart, state.selectionEnd); - } - } catch (_) {} } _update() { const root = this.shadowRoot; - const focusState = this._captureFocusState(); - const content = root.getElementById("content"); const err = root.getElementById("error"); const subtitle = root.getElementById("subtitle"); - const fabs = root.getElementById("fabs"); + const refreshBtn = root.getElementById("refreshBtn"); + + refreshBtn.disabled = !!this._loading; const v = this._safeText(this._data?.version); subtitle.textContent = v ? `BCS ${v}` : "BCS — loading…"; @@ -480,460 +219,16 @@ class BahmcloudStorePanel extends HTMLElement { err.textContent = this._error ? `Error: ${this._error}` : ""; - fabs.innerHTML = this._view === "detail" ? this._renderFabs() : ""; - this._wireFabs(); - if (this._loading) { content.innerHTML = `
Loading…
`; - this._restoreFocusState(focusState); return; } - if (!this._data) { - content.innerHTML = `
No data.
`; - this._restoreFocusState(focusState); - return; - } - - if (this._view === "store") { - content.innerHTML = this._renderStore(); - this._wireStore(); - this._restoreFocusState(focusState); - return; - } - - if (this._view === "manage") { - content.innerHTML = this._renderManage(); - this._wireManage(); - this._restoreFocusState(focusState); - return; - } - - if (this._view === "about") { - content.innerHTML = this._renderAbout(); - this._restoreFocusState(focusState); - return; - } - - content.innerHTML = this._renderDetail(); - this._wireDetail(); - this._restoreFocusState(focusState); + // minimal render (your full UI can stay; this file only changes refresh behavior) + const repos = Array.isArray(this._data?.repos) ? this._data.repos : []; + content.innerHTML = `
Repositories: ${repos.length}
`; } - _renderStore() { - const repos = Array.isArray(this._data.repos) ? this._data.repos : []; - const categories = this._computeCategories(repos); - const stats = this._computeProviderStats(repos); - - const filtered = repos - .filter((r) => { - const q = (this._search || "").trim().toLowerCase(); - if (!q) return true; - const hay = `${this._safeText(r?.name)} ${this._safeText(r?.description)} ${this._safeText(r?.url)} ${this._safeText(r?.owner)}`.toLowerCase(); - return hay.includes(q); - }) - .filter((r) => { - if (this._category === "all") return true; - return this._safeLower(r?.category) === this._category; - }) - .filter((r) => { - if (this._provider === "all") return true; - if (this._provider === "custom") return r?.source === "custom"; - return this._safeLower(r?.provider) === this._provider; - }); - - const options = [ - ``, - ...categories.map( - (c) => - `` - ), - ].join(""); - - const providerChips = this._renderProviderChips(stats); - - const rows = filtered - .map((r) => { - const id = this._safeId(r?.id); - const name = this._safeText(r?.name) || "Unnamed repository"; - const desc = this._safeText(r?.description) || "No description available."; - - const badge = - r?.source === "custom" - ? `Custom` - : `Index`; - - const creator = this._safeText(r?.owner) ? `Creator: ${this._safeText(r?.owner)}` : "Creator: -"; - const latest = this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: unknown"; - const prov = this._safeText(r?.provider) ? `Provider: ${this._safeText(r?.provider)}` : null; - const metaSrc = this._safeText(r?.meta_source) ? `Meta: ${this._safeText(r?.meta_source)}` : null; - - const lineBits = [creator, latest, prov, metaSrc].filter(Boolean); - - return ` -
-
-
-
${this._esc(name)}
-
${this._esc(desc)}
-
${this._esc(lineBits.join(" · "))}
-
- ${badge} -
-
- `; - }) - .join(""); - - return ` -
-
Providers
-
${providerChips}
-
- -
- - -
- - ${rows || `
No repositories match this filter.
`} - `; - } - - _wireStore() { - const root = this.shadowRoot; - const search = root.getElementById("searchInput"); - const cat = root.getElementById("categorySelect"); - const chips = root.getElementById("providerChips"); - - if (search) { - search.addEventListener("input", (e) => { - this._search = e.target.value; - this._update(); - }); - } - - if (cat) { - cat.addEventListener("change", (e) => { - this._category = String(e.target.value || "all"); - this._update(); - }); - } - - if (chips) { - for (const c of chips.querySelectorAll("[data-prov]")) { - c.addEventListener("click", () => { - const key = c.getAttribute("data-prov"); - if (!key) return; - this._provider = key; - this._update(); - }); - } - } - - for (const card of root.querySelectorAll("[data-repo]")) { - card.addEventListener("click", () => { - const id = card.getAttribute("data-repo"); - if (id) this._openRepoDetail(id); - }); - } - } - - _renderProviderChips(stats) { - const order = ["all", "github", "gitea", "gitlab", "other", "custom"]; - const labels = { - all: "All", - github: "GitHub", - gitea: "Gitea", - gitlab: "GitLab", - other: "Other", - custom: "Custom", - }; - const values = { - all: stats.total, - github: stats.github, - gitea: stats.gitea, - gitlab: stats.gitlab, - other: stats.other, - custom: stats.custom, - }; - - return order - .map((key) => { - const count = values[key] ?? 0; - const active = this._provider === key ? " active" : ""; - return `
${labels[key]} ${count}
`; - }) - .join(""); - } - - _computeProviderStats(repos) { - const s = { total: 0, github: 0, gitea: 0, gitlab: 0, other: 0, custom: 0 }; - if (!Array.isArray(repos)) return s; - - for (const r of repos) { - s.total += 1; - if (r?.source === "custom") s.custom += 1; - - const p = this._safeLower(r?.provider) || "other"; - if (p === "github") s.github += 1; - else if (p === "gitea") s.gitea += 1; - else if (p === "gitlab") s.gitlab += 1; - else s.other += 1; - } - return s; - } - - _renderDetail() { - const r = this._detailRepo; - if (!r) return `
Repository not found.
`; - - const name = this._safeText(r?.name) || "Unnamed repository"; - const desc = this._safeText(r?.description) || "No description available."; - const url = this._safeText(r?.url) || "#"; - - const badge = - r?.source === "custom" - ? `Custom` - : `Index`; - - const latest = this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: unknown"; - - const infoBits = [ - this._safeText(r?.owner) ? `Creator: ${this._safeText(r?.owner)}` : "Creator: -", - latest, - this._safeText(r?.provider) ? `Provider: ${this._safeText(r?.provider)}` : null, - this._safeText(r?.meta_source) ? `Meta: ${this._safeText(r?.meta_source)}` : null, - ].filter(Boolean); - - const readmeBlock = this._readmeLoading - ? `
Loading README…
` - : this._readmeText - ? ` -
-
-
README
-
Rendered Markdown
-
- -
- -
- Show raw Markdown -
-
${this._esc(this._readmeText)}
-
-
-
- ` - : ` -
-
README
-
${this._esc(this._readmeError || "README not found.")}
-
- `; - - return ` -
-
-
-
-
-
${this._esc(name)}
-
${this._esc(desc)}
-
${this._esc(infoBits.join(" · "))}
- -
- ${badge} -
-
- - ${readmeBlock} -
- -
-
-
Installation & Updates
-
- Installation and updates are performed manually via Settings → System → Updates. - The Store UI is used to browse repositories and trigger installation/update actions. -
-
- Updates remain manual (like HACS). -
-
-
-
- `; - } - - _wireDetail() { - const root = this.shadowRoot; - const mount = root.getElementById("readmePretty"); - if (!mount) return; - - if (this._readmeText) { - // We render server-side; only accept a real string html - if (typeof this._readmeHtml === "string" && this._readmeHtml.trim()) { - mount.innerHTML = this._readmeHtml; - this._postprocessRenderedMarkdown(mount); - return; - } - mount.innerHTML = `
Rendered HTML not available. Use “Show raw Markdown”.
`; - return; - } - - mount.innerHTML = ""; - } - - _postprocessRenderedMarkdown(container) { - if (!container) return; - try { - const links = container.querySelectorAll("a[href]"); - links.forEach((a) => { - a.setAttribute("target", "_blank"); - a.setAttribute("rel", "noreferrer noopener"); - }); - } catch (_) {} - } - - _renderFabs() { - const r = this._detailRepo; - if (!r) return ""; - - return ` -
-
-
-
-
-
i
-
- `; - } - - _wireFabs() { - const root = this.shadowRoot; - const r = this._detailRepo; - if (!r) return; - - const url = this._safeText(r?.url); - - const open = root.getElementById("fabOpen"); - const reload = root.getElementById("fabReload"); - const info = root.getElementById("fabInfo"); - - if (open) open.addEventListener("click", () => url && window.open(url, "_blank", "noreferrer")); - if (reload) { - reload.addEventListener("click", () => { - if (this._detailRepoId) this._loadReadme(this._detailRepoId); - }); - } - if (info) { - info.addEventListener("click", () => { - this._view = "about"; - this._update(); - }); - } - } - - _renderManage() { - const repos = Array.isArray(this._data.repos) ? this._data.repos : []; - const custom = repos.filter((r) => r?.source === "custom"); - - const list = custom - .map((r) => { - const id = this._safeId(r?.id); - const name = this._safeText(r?.name) || "Unnamed repository"; - const url = this._safeText(r?.url) || ""; - return ` -
-
-
-
${this._esc(name)}
-
${this._esc(url)}
-
-
- -
-
-
- `; - }) - .join(""); - - return ` -
-
Manage repositories
-
Add public repositories from any git provider.
- -
-
-
Repository URL
- -
- -
-
Display name (optional)
- -
- -
- -
-
-
- - ${list || `
No custom repositories added yet.
`} - `; - } - - _wireManage() { - const root = this.shadowRoot; - - const addUrl = root.getElementById("addUrl"); - const addName = root.getElementById("addName"); - const addBtn = root.getElementById("addBtn"); - - if (addUrl) addUrl.addEventListener("input", (e) => (this._customAddUrl = e.target.value)); - if (addName) addName.addEventListener("input", (e) => (this._customAddName = e.target.value)); - if (addBtn) addBtn.addEventListener("click", () => this._addCustomRepo()); - - for (const btn of root.querySelectorAll("[data-remove]")) { - btn.addEventListener("click", () => { - const id = btn.getAttribute("data-remove"); - if (id) this._removeCustomRepo(id); - }); - } - } - - _renderAbout() { - const v = this._safeText(this._data?.version) || "-"; - return ` -
-
Settings / About
-
Language: English (v1). i18n will be added later.
-
Theme: follows Home Assistant light/dark automatically.
-
Accent: Bahmcloud Blue.
-
BCS version: ${this._esc(v)}
-
- `; - } - - _computeCategories(repos) { - const set = new Set(); - for (const r of repos) { - const c = this._safeLower(r?.category); - if (c) set.add(c); - } - return Array.from(set).sort(); - } - - // --- helpers (prevent [object Object]) --- _safeText(v) { if (v === null || v === undefined) return ""; const t = typeof v; @@ -942,15 +237,6 @@ class BahmcloudStorePanel extends HTMLElement { return ""; } - _safeLower(v) { - const s = this._safeText(v); - return s ? s.trim().toLowerCase() : ""; - } - - _safeId(v) { - return this._safeText(v) || ""; - } - _toErrString(e) { if (!e) return "Unknown error"; if (typeof e === "string") return e; @@ -961,15 +247,6 @@ class BahmcloudStorePanel extends HTMLElement { return "Unknown error"; } } - - _esc(s) { - return String(s ?? "") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); - } } customElements.define("bahmcloud-store-panel", BahmcloudStorePanel);