diff --git a/custom_components/bahmcloud_store/panel/panel.js b/custom_components/bahmcloud_store/panel/panel.js index 5d18e5b..8193dc2 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"; + this._view = "store"; // store | manage | about | detail this._data = null; this._loading = true; this._error = null; @@ -15,7 +15,7 @@ class BahmcloudStorePanel extends HTMLElement { this._search = ""; this._category = "all"; - this._provider = "all"; + this._provider = "all"; // all|github|gitea|gitlab|other|custom this._detailRepoId = null; this._detailRepo = null; @@ -35,14 +35,16 @@ class BahmcloudStorePanel extends HTMLElement { } } - async _load() { + async _load(refresh = false) { if (!this._hass) return; + this._loading = true; this._error = null; this._update(); try { - const data = await this._hass.callApi("get", "bcs"); + const path = refresh ? "bcs?refresh=1" : "bcs"; + const data = await this._hass.callApi("get", path); this._data = data; } catch (e) { this._error = this._toErrString(e); @@ -53,29 +55,8 @@ 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(); - } + // “old-school” refresh that actually forces backend reload + await this._load(true); } _isDesktop() { @@ -108,11 +89,107 @@ 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); + } + + 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)}`); + + 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 { + const msg = + resp && typeof resp.message === "string" && resp.message.trim() + ? resp.message.trim() + : "README not found."; + this._readmeError = msg; + } + } catch (e) { + this._readmeError = this._toErrString(e) || "README not found."; + } finally { + this._readmeLoading = false; + this._update(); + } + } + _render() { const root = this.shadowRoot; + root.innerHTML = `
@@ -187,28 +387,69 @@ class BahmcloudStorePanel extends HTMLElement {
+ +
`; - root.getElementById("refreshBtn").addEventListener("click", () => this._refreshNow()); - root.getElementById("menuBtn").addEventListener("click", () => this._toggleMenu()); - root.getElementById("backBtn").addEventListener("click", () => this._goBack()); + this.shadowRoot.getElementById("refreshBtn").addEventListener("click", () => this._refreshNow()); + this.shadowRoot.getElementById("menuBtn").addEventListener("click", () => this._toggleMenu()); + this.shadowRoot.getElementById("backBtn").addEventListener("click", () => this._goBack()); - for (const tab of root.querySelectorAll(".tab")) { + for (const tab of this.shadowRoot.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(); + }; + this.shadowRoot.addEventListener("keydown", stopIfFormField, true); + this.shadowRoot.addEventListener("keyup", stopIfFormField, true); + this.shadowRoot.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, + 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 refreshBtn = root.getElementById("refreshBtn"); - - refreshBtn.disabled = !!this._loading; + const fabs = root.getElementById("fabs"); const v = this._safeText(this._data?.version); subtitle.textContent = v ? `BCS ${v}` : "BCS — loading…"; @@ -219,14 +460,407 @@ 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; } - // 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}
`; + 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); + } + + _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) => (this._category === "all" ? true : 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) { + 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) { + 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(); } _safeText(v) { @@ -237,15 +871,29 @@ 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; if (typeof e?.message === "string") return e.message; - try { - return JSON.stringify(e); - } catch (_) { - return "Unknown error"; - } + try { return JSON.stringify(e); } catch (_) { return "Unknown error"; } + } + + _esc(s) { + return String(s ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); } }