custom_components/bahmcloud_store/panel/panel.js aktualisiert

This commit is contained in:
2026-01-15 12:34:44 +00:00
parent c4d9f7b393
commit 597d1556ff

View File

@@ -15,14 +15,14 @@ class BahmcloudStorePanel extends HTMLElement {
this._search = "";
this._category = "all";
this._provider = "all"; // NEW: provider filter (all|github|gitea|gitlab|other|custom)
this._provider = "all"; // all|github|gitea|gitlab|other|custom
this._detailRepoId = null;
this._detailRepo = null;
this._readmeLoading = false;
this._readmeText = null;
this._readmeHtml = null; // backend may provide; client renderer does not rely on it
this._readmeHtml = null;
this._readmeError = null;
}
@@ -127,7 +127,7 @@ class BahmcloudStorePanel extends HTMLElement {
_openRepoDetail(repoId) {
const repos = Array.isArray(this._data?.repos) ? this._data.repos : [];
const repo = repos.find((r) => r.id === repoId);
const repo = repos.find((r) => this._safeId(r?.id) === repoId);
if (!repo) return;
this._view = "detail";
@@ -160,7 +160,7 @@ class BahmcloudStorePanel extends HTMLElement {
} else {
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = resp?.message || "README not found.";
this._readmeError = this._safeText(resp?.message) || "README not found.";
}
} catch (e) {
this._readmeText = null;
@@ -340,7 +340,6 @@ class BahmcloudStorePanel extends HTMLElement {
details{ margin-top: 10px; }
summary{ cursor:pointer; color: var(--bcs-accent); font-weight: 900; }
/* Pretty Markdown container */
.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; }
@@ -392,7 +391,7 @@ class BahmcloudStorePanel extends HTMLElement {
});
}
// Prevent HA global shortcuts while typing inside the panel
// prevent HA global shortcuts while typing
const stopIfFormField = (e) => {
const t = e.composedPath ? e.composedPath()[0] : e.target;
if (!t) return;
@@ -401,7 +400,6 @@ class BahmcloudStorePanel extends HTMLElement {
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);
@@ -450,7 +448,7 @@ class BahmcloudStorePanel extends HTMLElement {
const subtitle = root.getElementById("subtitle");
const fabs = root.getElementById("fabs");
const v = this._data?.version ? String(this._data.version) : null;
const v = this._safeText(this._data?.version);
subtitle.textContent = v ? `BCS ${v}` : "BCS — loading…";
for (const tab of root.querySelectorAll(".tab")) {
@@ -502,26 +500,23 @@ class BahmcloudStorePanel extends HTMLElement {
_renderStore() {
const repos = Array.isArray(this._data.repos) ? this._data.repos : [];
const categories = this._computeCategories(repos);
// Provider stats (for overview)
const stats = this._computeProviderStats(repos);
const filtered = repos
.filter((r) => {
const q = (this._search || "").trim().toLowerCase();
if (!q) return true;
const hay = `${r.name || ""} ${r.description || ""} ${r.url || ""} ${r.owner || ""}`.toLowerCase();
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 (r.category || "").toLowerCase() === this._category;
return this._safeLower(r?.category) === this._category;
})
.filter((r) => {
if (this._provider === "all") return true;
if (this._provider === "custom") return r.source === "custom";
const pv = (r.provider || "other").toLowerCase();
return pv === this._provider;
if (this._provider === "custom") return r?.source === "custom";
return this._safeLower(r?.provider) === this._provider;
});
const options = [
@@ -538,24 +533,27 @@ class BahmcloudStorePanel extends HTMLElement {
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"
r?.source === "custom"
? `<span class="badge custom">Custom</span>`
: `<span class="badge">Index</span>`;
const desc = r.description || "No description available.";
const creator = r.owner ? `Creator: ${r.owner}` : "Creator: -";
const latest = r.latest_version ? `Latest: ${r.latest_version}` : "Latest: unknown";
const cat = r.category ? `Category: ${r.category}` : null;
const metaSrc = r.meta_source ? `Meta: ${r.meta_source}` : null;
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 `
<div class="card clickable" data-repo="${this._esc(r.id)}">
<div class="card clickable" data-repo="${this._esc(id)}">
<div class="row">
<div>
<div><strong>${this._esc(r.name)}</strong></div>
<div><strong>${this._esc(name)}</strong></div>
<div class="muted">${this._esc(desc)}</div>
<div class="muted small">${this._esc(lineBits.join(" · "))}</div>
</div>
@@ -581,6 +579,45 @@ class BahmcloudStorePanel extends HTMLElement {
`;
}
_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 = {
@@ -615,9 +652,10 @@ class BahmcloudStorePanel extends HTMLElement {
for (const r of repos) {
s.total += 1;
if (r.source === "custom") s.custom += 1;
const p = (r.provider || "other").toLowerCase();
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;
@@ -626,63 +664,29 @@ class BahmcloudStorePanel extends HTMLElement {
return s;
}
_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; // set filter
this._update();
});
}
}
for (const card of root.querySelectorAll("[data-repo]")) {
card.addEventListener("click", () => {
const id = card.getAttribute("data-repo");
if (id) this._openRepoDetail(id);
});
}
}
_renderDetail() {
const r = this._detailRepo;
if (!r) return `<div class="card">Repository not found.</div>`;
const badge =
r.source === "custom" ? `<span class="badge custom">Custom</span>` : `<span class="badge">Index</span>`;
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 = r.description || "No description available.";
const latest = r.latest_version ? `Latest: ${r.latest_version}` : "Latest: unknown";
const badge =
r?.source === "custom"
? `<span class="badge custom">Custom</span>`
: `<span class="badge">Index</span>`;
const latest = this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: unknown";
const infoBits = [
r.owner ? `Creator: ${r.owner}` : "Creator: -",
this._safeText(r?.owner) ? `Creator: ${this._safeText(r?.owner)}` : "Creator: -",
latest,
r.provider ? `Provider: ${r.provider}` : null,
r.category ? `Category: ${r.category}` : null,
r.meta_author ? `Author: ${r.meta_author}` : null,
r.meta_maintainer ? `Maintainer: ${r.meta_maintainer}` : null,
r.meta_source ? `Meta: ${r.meta_source}` : null,
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
@@ -718,11 +722,11 @@ class BahmcloudStorePanel extends HTMLElement {
<div class="card">
<div class="row">
<div>
<div><strong style="font-size:16px;">${this._esc(r.name)}</strong></div>
<div><strong style="font-size:16px;">${this._esc(name)}</strong></div>
<div class="muted">${this._esc(desc)}</div>
<div class="muted small" style="margin-top:8px;">${this._esc(infoBits.join(" · "))}</div>
<div class="muted small" style="margin-top:8px;">
<a href="${this._esc(r.url)}" target="_blank" rel="noreferrer">Open repository</a>
<a href="${this._esc(url)}" target="_blank" rel="noreferrer">Open repository</a>
</div>
</div>
${badge}
@@ -754,57 +758,19 @@ class BahmcloudStorePanel extends HTMLElement {
if (!mount) return;
if (this._readmeText) {
const html = this._mdToHtml(this._readmeText);
if (html) {
mount.innerHTML = html;
this._postprocessRenderedMarkdown(mount);
return;
}
// Client renderer may be unavailable; prefer server-provided HTML
if (this._readmeHtml) {
mount.innerHTML = this._readmeHtml;
this._postprocessRenderedMarkdown(mount);
return;
}
mount.innerHTML = `<div class="muted">Markdown renderer not available on this client. Use “Show raw Markdown”.</div>`;
mount.innerHTML = `<div class="muted">Rendered HTML not available. Use “Show raw Markdown”.</div>`;
return;
}
mount.innerHTML = "";
}
_mdToHtml(markdown) {
const md = String(markdown || "");
if (!md.trim()) return "";
const markedObj = window.marked;
const domPurify = window.DOMPurify;
let html = "";
try {
if (markedObj && typeof markedObj.parse === "function") {
html = markedObj.parse(md, { breaks: true, gfm: true });
} else if (typeof markedObj === "function") {
html = markedObj(md);
} else {
return "";
}
} catch (_) {
return "";
}
try {
if (domPurify && typeof domPurify.sanitize === "function") {
html = domPurify.sanitize(html);
}
} catch (_) {}
return html;
}
_postprocessRenderedMarkdown(container) {
if (!container) return;
try {
@@ -836,18 +802,18 @@ class BahmcloudStorePanel extends HTMLElement {
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", () => window.open(r.url, "_blank", "noreferrer"));
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";
@@ -858,24 +824,27 @@ class BahmcloudStorePanel extends HTMLElement {
_renderManage() {
const repos = Array.isArray(this._data.repos) ? this._data.repos : [];
const custom = repos.filter((r) => r.source === "custom");
const custom = repos.filter((r) => r?.source === "custom");
const list = custom
.map(
(r) => `
<div class="card">
<div class="row">
<div>
<div><strong>${this._esc(r.name)}</strong></div>
<div class="muted">${this._esc(r.url)}</div>
</div>
<div>
<button class="primary" data-remove="${this._esc(r.id)}">Remove</button>
.map((r) => {
const id = this._safeId(r?.id);
const name = this._safeText(r?.name) || "Unnamed repository";
const url = this._safeText(r?.url) || "";
return `
<div class="card">
<div class="row">
<div>
<div><strong>${this._esc(name)}</strong></div>
<div class="muted">${this._esc(url)}</div>
</div>
<div>
<button class="primary" data-remove="${this._esc(id)}">Remove</button>
</div>
</div>
</div>
</div>
`
)
`;
})
.join("");
return `
@@ -924,13 +893,14 @@ class BahmcloudStorePanel extends HTMLElement {
}
_renderAbout() {
const v = this._safeText(this._data?.version) || "-";
return `
<div class="card">
<div><strong>Settings / About</strong></div>
<div class="muted">Language: English (v1). i18n will be added later.</div>
<div class="muted">Theme: follows Home Assistant light/dark automatically.</div>
<div class="muted">Accent: Bahmcloud Blue.</div>
<div class="muted small" style="margin-top: 10px;">BCS version: ${this._esc(this._data.version || "-")}</div>
<div class="muted small" style="margin-top: 10px;">BCS version: ${this._esc(v)}</div>
</div>
`;
}
@@ -938,12 +908,31 @@ class BahmcloudStorePanel extends HTMLElement {
_computeCategories(repos) {
const set = new Set();
for (const r of repos) {
const c = (r.category || "").trim().toLowerCase();
const c = this._safeLower(r?.category);
if (c) set.add(c);
}
return Array.from(set).sort();
}
// --- HARDENING HELPERS (fixes [object Object]) ---
_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 ""; // objects/arrays/functions => empty (prevents [object Object])
}
_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("&", "&amp;")