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 = [
+ `All categories `,
+ ...categories.map((c) => `${this._esc(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}
+
+
+
+
+ ${options}
+
+
+ ${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 `
+
+ `;
+ }
+
+ _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)}
+
+
+ Remove
+
+
+
+ `;
+ })
+ .join("");
+
+ return `
+
+
Manage repositories
+
Add public repositories from any git provider.
+
+
+
+
+
+
Display name (optional)
+
+
+
+
+ Add repository
+
+
+
+
+ ${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("'", "'");
}
}