diff --git a/custom_components/bahmcloud_store/panel/panel.js b/custom_components/bahmcloud_store/panel/panel.js index 6b8c023..a065331 100644 --- a/custom_components/bahmcloud_store/panel/panel.js +++ b/custom_components/bahmcloud_store/panel/panel.js @@ -4,13 +4,27 @@ class BahmcloudStorePanel extends HTMLElement { this.attachShadow({ mode: "open" }); this._hass = null; - this._view = "store"; // store | manage | about + + // Views: store | manage | about | detail + this._view = "store"; + this._data = null; this._loading = true; this._error = null; this._customAddUrl = ""; this._customAddName = ""; + + // Store filtering + this._search = ""; + this._category = "all"; + + // Detail view state + this._detailRepoId = null; + this._detailRepo = null; + this._readmeLoading = false; + this._readmeText = null; + this._readmeError = null; } set hass(hass) { @@ -67,6 +81,17 @@ class BahmcloudStorePanel extends HTMLElement { } _goBack() { + // If we're in detail, go back to store list first. + if (this._view === "detail") { + this._view = "store"; + this._detailRepoId = null; + this._detailRepo = null; + this._readmeText = null; + this._readmeError = null; + this._update(); + return; + } + try { history.back(); } catch (_) { @@ -90,11 +115,7 @@ class BahmcloudStorePanel extends HTMLElement { this._update(); try { - await this._hass.callApi("post", "bcs", { - op: "add_custom_repo", - url, - name, - }); + await this._hass.callApi("post", "bcs", { op: "add_custom_repo", url, name }); this._customAddUrl = ""; this._customAddName = ""; await this._load(); @@ -120,6 +141,49 @@ class BahmcloudStorePanel extends HTMLElement { } } + _openRepoDetail(repoId) { + const repos = Array.isArray(this._data?.repos) ? this._data.repos : []; + const repo = repos.find((r) => r.id === repoId); + if (!repo) return; + + this._view = "detail"; + this._detailRepoId = repoId; + this._detailRepo = repo; + + this._readmeText = 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") { + this._readmeText = resp.readme; + } else { + this._readmeText = null; + this._readmeError = resp?.message || "README not found."; + } + } catch (e) { + this._readmeText = null; + this._readmeError = e?.message ? String(e.message) : String(e); + } finally { + this._readmeLoading = false; + this._update(); + } + } + _render() { const root = this.shadowRoot; @@ -156,13 +220,13 @@ class BahmcloudStorePanel extends HTMLElement { color:var(--primary-text-color); } - .tabs{ display:flex; gap:8px; flex-wrap:wrap; margin-bottom:6px; } + .tabs{ display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; } .tab{ border:1px solid var(--divider-color); background:var(--card-background-color); color:var(--primary-text-color); padding:8px 12px; border-radius:999px; - cursor:pointer; font-weight:600; font-size:13px; + cursor:pointer; font-weight:700; font-size:13px; } .tab.active{ border-color:var(--bcs-accent); @@ -174,49 +238,122 @@ class BahmcloudStorePanel extends HTMLElement { border:1px solid var(--divider-color); background:var(--card-background-color); color:var(--primary-text-color); - cursor:pointer; font-weight:700; + cursor:pointer; font-weight:800; } button.primary{ border-color:var(--bcs-accent); background: color-mix(in srgb, var(--bcs-accent) 16%, var(--card-background-color)); } + button:disabled{ + opacity: 0.55; + cursor: not-allowed; + } .card{ border:1px solid var(--divider-color); background:var(--card-background-color); border-radius:16px; padding:12px; margin:10px 0; } - .row{ display:flex; justify-content:space-between; gap:10px; align-items:flex-start; } + .card.clickable{ + cursor:pointer; + transition: transform 120ms ease, box-shadow 120ms ease; + } + .card.clickable:hover{ + transform: translateY(-1px); + box-shadow: 0 10px 30px rgba(0,0,0,0.08); + } + + .row{ display:flex; justify-content:space-between; gap:10px; align-items:flex-start; } .muted{ color:var(--secondary-text-color); font-size:13px; margin-top:4px; } .small{ font-size:12px; } .badge{ border:1px solid var(--divider-color); border-radius:999px; padding:2px 10px; - font-size:12px; font-weight:700; height:fit-content; + font-size:12px; font-weight:800; height:fit-content; } .badge.custom{ border-color:var(--bcs-accent); color:var(--bcs-accent); } .error{ color:#b00020; white-space:pre-wrap; margin-top:10px; } - .grid{ display:grid; grid-template-columns:1fr; gap:10px; } - .field{ display:grid; gap:6px; } + .grid2{ + display:grid; + grid-template-columns: 1fr; + gap: 12px; + } + @media (min-width: 900px){ + .grid2{ grid-template-columns: 1.2fr 0.8fr; } + } - input{ + .filters{ + display:flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; + margin-bottom: 12px; + } + + input, select{ padding:10px 12px; border-radius:12px; border:1px solid var(--divider-color); background:var(--card-background-color); color:var(--primary-text-color); outline:none; } - input:focus{ + input:focus, select:focus{ border-color:var(--bcs-accent); box-shadow:0 0 0 2px color-mix(in srgb, var(--bcs-accent) 20%, transparent); } a{ color:var(--bcs-accent); text-decoration:none; } a:hover{ text-decoration:underline; } + + /* FABs (detail view) */ + .fabs{ + position: fixed; + right: 18px; + bottom: 18px; + display: grid; + gap: 10px; + z-index: 100; + } + .fab{ + width: 56px; + height: 56px; + border-radius: 18px; + border: 1px solid var(--divider-color); + background: var(--card-background-color); + box-shadow: 0 12px 30px rgba(0,0,0,0.14); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 900; + cursor: pointer; + user-select: none; + } + .fab.primary{ + border-color: var(--bcs-accent); + background: color-mix(in srgb, var(--bcs-accent) 18%, var(--card-background-color)); + } + .fab[disabled]{ + opacity: .55; + cursor: not-allowed; + } + .fab-label{ + position: fixed; + right: 84px; + bottom: 18px; + display: none; + } + + .mdwrap ha-markdown{ + display: block; + } + .mdwrap{ + padding-top: 6px; + }
@@ -234,7 +371,7 @@ class BahmcloudStorePanel extends HTMLElement {
-
+
Store
Manage repositories
Settings / About
@@ -243,6 +380,8 @@ class BahmcloudStorePanel extends HTMLElement {
+ +
`; root.getElementById("refreshBtn").addEventListener("click", () => this._load()); @@ -262,16 +401,22 @@ class BahmcloudStorePanel extends HTMLElement { const content = root.getElementById("content"); const err = root.getElementById("error"); const subtitle = root.getElementById("subtitle"); + const fabs = root.getElementById("fabs"); const v = this._data?.version ? String(this._data.version) : null; subtitle.textContent = v ? `BCS ${v}` : "BCS — loading…"; + // Tabs active state (detail is not a tab) 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 only on detail view + fabs.innerHTML = this._view === "detail" ? this._renderFabs() : ""; + this._wireFabs(); + if (this._loading) { content.innerHTML = `
Loading…
`; return; @@ -284,6 +429,7 @@ class BahmcloudStorePanel extends HTMLElement { if (this._view === "store") { content.innerHTML = this._renderStore(); + this._wireStore(); return; } @@ -293,77 +439,256 @@ class BahmcloudStorePanel extends HTMLElement { return; } - content.innerHTML = this._renderAbout(); + if (this._view === "about") { + content.innerHTML = this._renderAbout(); + return; + } + + // detail + content.innerHTML = this._renderDetail(); + this._wireDetail(); } _renderStore() { const repos = Array.isArray(this._data.repos) ? this._data.repos : []; + const categories = this._computeCategories(repos); - const rows = repos.map((r) => { - const badge = r.source === "custom" - ? `Custom` - : `Index`; + 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(); + return hay.includes(q); + }) + .filter((r) => { + if (this._category === "all") return true; + return (r.category || "").toLowerCase() === this._category; + }); - const desc = r.meta_description || r.provider_description || "No description available."; + const options = [ + ``, + ...categories.map((c) => ``), + ].join(""); - const creator = r.owner ? `Creator: ${this._esc(r.owner)}` : "Creator: -"; - const author = r.meta_author ? `Author: ${this._esc(r.meta_author)}` : null; - const maint = r.meta_maintainer ? `Maintainer: ${this._esc(r.meta_maintainer)}` : null; - const metaSrc = r.meta_source ? `Meta: ${this._esc(r.meta_source)}` : null; + const rows = filtered + .map((r) => { + const badge = r.source === "custom" + ? `Custom` + : `Index`; - const lineBits = [creator, author, maint, metaSrc].filter(Boolean); + const desc = r.description || r.meta_description || r.provider_description || "No description available."; + const creator = r.owner ? `Creator: ${r.owner}` : "Creator: -"; + const cat = r.category ? `Category: ${r.category}` : null; + const metaSrc = r.meta_source ? `Meta: ${r.meta_source}` : null; + const lineBits = [creator, cat, metaSrc].filter(Boolean); - return ` -
-
-
-
${this._esc(r.name)}
-
${this._esc(desc)}
-
${lineBits.map(x => this._esc(x)).join(" · ")}
- + return ` +
+
+
+
${this._esc(r.name)}
+
${this._esc(desc)}
+
${this._esc(lineBits.join(" · "))}
+
+ ${badge} +
+
+ `; + }) + .join(""); + + return ` +
+ + +
+ + ${rows || `
No repositories configured.
`} + `; + } + + _wireStore() { + const root = this.shadowRoot; + + const search = root.getElementById("searchInput"); + const cat = root.getElementById("categorySelect"); + + 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(); + }); + } + + 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 `
Repository not found.
`; + + const badge = r.source === "custom" + ? `Custom` + : `Index`; + + const desc = r.description || r.meta_description || r.provider_description || "No description available."; + + const infoBits = [ + r.owner ? `Creator: ${r.owner}` : "Creator: -", + 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, + ].filter(Boolean); + + const readmeBlock = this._readmeLoading + ? `
Loading README…
` + : this._readmeText + ? ` +
+ +
+ ` + : ` +
+
README
+
${this._esc(this._readmeError || "README not found.")}
+
+ `; + + return ` +
+
+
+
+
+
${this._esc(r.name)}
+
${this._esc(desc)}
+
${this._esc(infoBits.join(" · "))}
+ +
+ ${badge} +
+
+ + ${readmeBlock} +
+ +
+
+
Installation & Updates
+
+ Installation & updates will be implemented via the official Home Assistant APIs. + In v0.4.0, the buttons are UI-only (coming soon). +
+
+ Updates remain manual (like HACS).
- ${badge}
- `; - }).join(""); +
+ `; + } - return rows || `
No repositories configured.
`; + _wireDetail() { + // Render markdown into ha-markdown after DOM exists + const root = this.shadowRoot; + const mdEl = root.getElementById("readmeMd"); + if (mdEl && this._readmeText) { + try { + mdEl.hass = this._hass; + } catch (_) {} + mdEl.content = this._readmeText; + } + } + + _renderFabs() { + const r = this._detailRepo; + if (!r) return ""; + + return ` +
+
+
+
+
+
+ `; + } + + _wireFabs() { + const root = this.shadowRoot; + const r = this._detailRepo; + if (!r) return; + + const open = root.getElementById("fabOpen"); + const reload = root.getElementById("fabReload"); + + if (open) { + open.addEventListener("click", () => { + window.open(r.url, "_blank", "noreferrer"); + }); + } + + if (reload) { + reload.addEventListener("click", () => { + if (this._detailRepoId) this._loadReadme(this._detailRepoId); + }); + } } _renderManage() { const repos = Array.isArray(this._data.repos) ? this._data.repos : []; const custom = repos.filter((r) => r.source === "custom"); - const list = custom.map((r) => { - return ` -
-
-
-
${this._esc(r.name)}
-
${this._esc(r.url)}
-
-
- + const list = custom + .map((r) => { + return ` +
+
+
+
${this._esc(r.name)}
+
${this._esc(r.url)}
+
+
+ +
-
- `; - }).join(""); + `; + }) + .join(""); return `
Manage repositories
Add public repositories from any git provider.
-
-
- +
+
+
Repository URL
-
- +
+
Display name (optional)
@@ -408,6 +733,15 @@ class BahmcloudStorePanel extends HTMLElement { `; } + _computeCategories(repos) { + const set = new Set(); + for (const r of repos) { + const c = (r.category || "").trim().toLowerCase(); + if (c) set.add(c); + } + return Array.from(set).sort(); + } + _esc(s) { return String(s ?? "") .replaceAll("&", "&")