diff --git a/custom_components/bahmcloud_store/panel/app.js b/custom_components/bahmcloud_store/panel/app.js index b3fa062..75147d7 100644 --- a/custom_components/bahmcloud_store/panel/app.js +++ b/custom_components/bahmcloud_store/panel/app.js @@ -1,101 +1,984 @@ -async function apiGet() { - const r = await fetch("/api/bcs", { credentials: "same-origin" }); - return await r.json(); -} +class BahmcloudStorePanel extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); -async function apiRefresh() { - const r = await fetch("/api/bcs?action=refresh", { - method: "POST", - credentials: "same-origin", - }); - return await r.json(); -} + this._hass = null; -function el(tag, attrs = {}, children = []) { - const n = document.createElement(tag); - for (const [k, v] of Object.entries(attrs)) { - if (k === "class") n.className = v; - else if (k === "onclick") n.onclick = v; - else n.setAttribute(k, v); + 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._provider = "all"; // all|github|gitea|gitlab|other|custom + + this._detailRepoId = null; + this._detailRepo = null; + + this._readmeLoading = false; + this._readmeText = null; + this._readmeHtml = null; + this._readmeError = null; + + this._refreshing = false; } - for (const c of children) { - n.appendChild(typeof c === "string" ? document.createTextNode(c) : c); + + set hass(hass) { + this._hass = hass; + if (!this._rendered) { + this._rendered = true; + this._render(); + this._load(); + } } - return n; -} -function card(repo) { - const title = el("div", {}, [ - el("strong", {}, [repo.name]), - el("div", { class: "muted" }, [repo.url]), - ]); + async _load() { + if (!this._hass) return; - const provider = el("div", { class: "muted" }, [ - `Provider: ${repo.provider || "-"}` - ]); + this._loading = true; + this._error = null; + this._update(); - const version = el("div", { class: "muted" }, [ - `Latest: ${repo.latest_version || "-"}` - ]); + try { + const data = await this._hass.callApi("get", "bcs"); + this._data = data; + } catch (e) { + this._error = e?.message ? String(e.message) : String(e); + } finally { + this._loading = false; + this._update(); + } + } - const sourceBadge = el( - "span", - { class: "badge" }, - [repo.source === "custom" ? "Custom" : "Index"] - ); + async _refreshAll() { + if (!this._hass) return; + if (this._refreshing) return; - return el("div", { class: "card" }, [ - el("div", { class: "row" }, [title, sourceBadge]), - provider, - version - ]); -} + this._refreshing = true; + this._error = null; -async function load() { - const status = document.getElementById("status"); - const list = document.getElementById("list"); + // Show a loading state immediately + this._loading = true; + this._update(); - status.textContent = "Loading..."; - list.innerHTML = ""; + try { + // IMPORTANT: This hits POST /api/bcs?action=refresh + 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; + } - try { - const data = await apiGet(); + // Always reload data after refresh attempt (even on failure) + await this._load(); + } - if (!data.ok) { - status.textContent = "Failed to load store data"; + _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._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; } - status.textContent = `BCS ${data.version} – ${data.repos.length} repositories`; + this._error = null; + this._update(); - for (const repo of data.repos) { - list.appendChild(card(repo)); + 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(); } - } catch (e) { - console.error("BCS load failed:", e); - status.textContent = "Error loading store data"; + } + + 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._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; + } else { + this._readmeText = null; + this._readmeHtml = null; + this._readmeError = this._safeText(resp?.message) || "README not found."; + } + } catch (e) { + this._readmeText = null; + this._readmeHtml = null; + this._readmeError = e?.message ? String(e.message) : String(e); + } finally { + this._readmeLoading = false; + this._update(); + } + } + + _render() { + const root = this.shadowRoot; + + root.innerHTML = ` + + +
+
+ +
+
+
Bahmcloud Store
+
BCS — loading…
+
+
+
+ +
+
+ +
+
+
Store
+
Manage repositories
+
Settings / 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()); + + for (const tab of root.querySelectorAll(".tab")) { + tab.addEventListener("click", () => { + this._view = tab.getAttribute("data-view"); + 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"); + + // 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(); + + 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); + } + + // --- The rest of your file is unchanged --- + // (Store rendering, detail rendering, manage, helpers) + + _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 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 lineBits = [creator, latest, cat, 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?.category) ? `Category: ${this._safeText(r?.category)}` : null, + this._safeText(r?.meta_author) ? `Author: ${this._safeText(r?.meta_author)}` : null, + this._safeText(r?.meta_maintainer) ? `Maintainer: ${this._safeText(r?.meta_maintainer)}` : null, + this._safeText(r?.meta_source) ? `Meta: ${this._safeText(r?.meta_source)}` : null, + ].filter(Boolean); + + const readmeBlock = this._readmeLoading + ? `
Loading README…
` + : this._readmeText + ? ` +
+
+
README
+
Rendered Markdown
+
+ +
+ +
+ 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) { + if (this._readmeHtml) { + 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(); + } + + // --- 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("'", "'"); } } -const refreshBtn = document.getElementById("refresh"); - -refreshBtn.onclick = async () => { - refreshBtn.disabled = true; - const oldText = refreshBtn.textContent; - refreshBtn.textContent = "Refreshing..."; - - try { - const r = await apiRefresh(); - if (!r.ok) { - console.error("BCS refresh failed:", r); - } - await load(); - } catch (e) { - console.error("BCS refresh error:", e); - } finally { - refreshBtn.disabled = false; - refreshBtn.textContent = oldText; - } -}; - -load(); +customElements.define("bahmcloud-store-panel", BahmcloudStorePanel);