diff --git a/custom_components/bahmcloud_store/panel/panel.js b/custom_components/bahmcloud_store/panel/panel.js index c7743a0..b1a1a27 100644 --- a/custom_components/bahmcloud_store/panel/panel.js +++ b/custom_components/bahmcloud_store/panel/panel.js @@ -5,11 +5,14 @@ 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; + this._customAddUrl = ""; + this._customAddName = ""; + this._search = ""; this._category = "all"; @@ -20,6 +23,8 @@ class BahmcloudStorePanel extends HTMLElement { this._readmeText = null; this._readmeHtml = null; this._readmeError = null; + + this._mdMountToken = 0; } set hass(hass) { @@ -56,7 +61,8 @@ class BahmcloudStorePanel extends HTMLElement { _toggleMenu() { if (this._isDesktop()) return; try { - this.dispatchEvent(new Event("hass-toggle-menu", { bubbles: true, composed: true })); + const ev = new Event("hass-toggle-menu", { bubbles: true, composed: true }); + this.dispatchEvent(ev); } catch (_) {} } @@ -78,6 +84,48 @@ 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 = e?.message ? String(e.message) : String(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 = 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) => r.id === repoId); @@ -135,24 +183,26 @@ class BahmcloudStorePanel extends HTMLElement { .mobilebar{ position:sticky; top:0; z-index:50; display:flex; align-items:center; justify-content:space-between; - gap:8px; padding:10px 12px; + gap:10px; padding:10px 12px; background: var(--app-header-background-color, var(--card-background-color)); color: var(--app-header-text-color, var(--primary-text-color)); border-bottom:1px solid var(--divider-color); } - .mobilebar .left, .mobilebar .right { display:flex; align-items:center; gap:8px; } + .mobilebar .left, .mobilebar .right { display:flex; align-items:center; gap:10px; } .iconbtn{ - width:40px; height:40px; border-radius:12px; + width:40px; height:40px; border-radius:14px; border:1px solid var(--divider-color); - background: color-mix(in srgb, var(--card-background-color) 80%, transparent); + background: color-mix(in srgb, var(--card-background-color) 82%, transparent); color:inherit; display:inline-flex; align-items:center; justify-content:center; cursor:pointer; user-select:none; font-weight:900; font-size:18px; line-height:1; } + .iconbtn:hover{ box-shadow: 0 10px 30px rgba(0,0,0,0.10); transform: translateY(-1px); } + .iconbtn:active{ transform: translateY(0px); box-shadow:none; } @media (min-width: 1024px) { .iconbtn.menu { display:none; } } .brandtitle{ display:flex; flex-direction:column; line-height:1.2; } - .brandtitle .t{ font-size:16px; font-weight:900; } + .brandtitle .t{ font-size:16px; font-weight:900; letter-spacing: .2px; } .brandtitle .s{ font-size:12px; color:var(--secondary-text-color); margin-top:2px; } .wrap{ @@ -161,12 +211,48 @@ class BahmcloudStorePanel extends HTMLElement { color:var(--primary-text-color); } + .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:800; font-size:13px; + } + .tab.active{ + border-color:var(--bcs-accent); + box-shadow:0 0 0 2px color-mix(in srgb, var(--bcs-accent) 20%, transparent); + } + + button{ + padding:10px 12px; border-radius:14px; + border:1px solid var(--divider-color); + background:var(--card-background-color); + color:var(--primary-text-color); + cursor:pointer; font-weight:900; + } + button.primary{ + border-color:var(--bcs-accent); + background: color-mix(in srgb, var(--bcs-accent) 16%, var(--card-background-color)); + } + button:hover{ box-shadow: 0 10px 30px rgba(0,0,0,0.10); transform: translateY(-1px); } + button:active{ transform: translateY(0px); box-shadow:none; } + 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; + border-radius:18px; padding:12px; margin:10px 0; + } + + .card.clickable{ + cursor:pointer; + transition: transform 120ms ease, box-shadow 120ms ease; + } + .card.clickable:hover{ + transform: translateY(-1px); + box-shadow: 0 12px 34px rgba(0,0,0,0.10); } - .card.clickable{ cursor:pointer; } .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; } @@ -175,14 +261,25 @@ class BahmcloudStorePanel extends HTMLElement { .badge{ border:1px solid var(--divider-color); border-radius:999px; padding:2px 10px; - font-size:12px; font-weight:800; height:fit-content; + font-size:12px; font-weight:900; height:fit-content; } .badge.custom{ border-color:var(--bcs-accent); color:var(--bcs-accent); } - .filters{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:12px; } + .error{ color:#b00020; white-space:pre-wrap; margin-top:10px; } + + .grid2{ display:grid; grid-template-columns: 1fr; gap: 12px; } + @media (min-width: 900px){ .grid2{ grid-template-columns: 1.2fr 0.8fr; } } + + .filters{ + display:flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; + margin-bottom: 12px; + } input, select{ - padding:10px 12px; border-radius:12px; + padding:10px 12px; border-radius:14px; border:1px solid var(--divider-color); background:var(--card-background-color); color:var(--primary-text-color); @@ -196,7 +293,47 @@ class BahmcloudStorePanel extends HTMLElement { a{ color:var(--bcs-accent); text-decoration:none; } a:hover{ text-decoration:underline; } - /* Pretty markdown styling for server-rendered HTML */ + .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; } + + pre.readme{ + white-space: pre-wrap; + word-break: break-word; + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12.5px; + line-height: 1.5; + } + details{ margin-top: 10px; } + summary{ cursor:pointer; color: var(--bcs-accent); font-weight: 900; } + + /* Pretty markdown (server rendered HTML) */ .md { line-height: 1.65; font-size: 14px; } .md h1,.md h2,.md h3{ margin: 18px 0 10px; } .md p{ margin: 10px 0; } @@ -214,18 +351,8 @@ class BahmcloudStorePanel extends HTMLElement { background: color-mix(in srgb, var(--bcs-accent) 8%, var(--card-background-color)); border-radius: 12px; } - - pre.readme{ - white-space: pre-wrap; - word-break: break-word; - margin: 0; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - font-size: 12.5px; - line-height: 1.5; - } - - details{ margin-top: 10px; } - summary{ cursor:pointer; color: var(--bcs-accent); font-weight: 800; } + .md table{ width:100%; border-collapse: collapse; } + .md th,.md td{ border: 1px solid var(--divider-color); padding: 8px; text-align: left; }
@@ -238,67 +365,145 @@ class BahmcloudStorePanel extends HTMLElement {
- +
+
+
Store
+
Manage repositories
+
Settings / About
+
+
-
+
+ +
`; root.getElementById("refreshBtn").addEventListener("click", () => this._load()); root.getElementById("menuBtn").addEventListener("click", () => this._toggleMenu()); root.getElementById("backBtn").addEventListener("click", () => this._goBack()); - // Prevent HA global shortcuts while typing inside panel inputs + for (const tab of root.querySelectorAll(".tab")) { + tab.addEventListener("click", () => { + this._view = tab.getAttribute("data-view"); + this._update(); + }); + } + + // Prevent HA global shortcuts while typing inside the panel const stopIfFormField = (e) => { const t = e.composedPath ? e.composedPath()[0] : e.target; if (!t) return; const tag = (t.tagName || "").toLowerCase(); - if (tag === "input" || tag === "textarea" || tag === "select" || t.isContentEditable) { - e.stopPropagation(); - } + 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 v = this._data?.version ? String(this._data.version) : null; 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 === "detail") { - content.innerHTML = this._renderDetail(); - this._wireDetail(); + if (this._view === "store") { + content.innerHTML = this._renderStore(); + this._wireStore(); + this._restoreFocusState(focusState); return; } - content.innerHTML = this._renderStore(); - this._wireStore(); + 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 = Array.from(new Set(repos.map(r => (r.category || "").toLowerCase()).filter(Boolean))).sort(); + const categories = this._computeCategories(repos); const filtered = repos .filter((r) => { @@ -314,37 +519,50 @@ class BahmcloudStorePanel extends HTMLElement { const options = [ ``, - ...categories.map((c) => ``), + ...categories.map( + (c) => + `` + ), ].join(""); - const rows = filtered.map((r) => { - const badge = r.source === "custom" - ? `Custom` - : `Index`; + const rows = filtered + .map((r) => { + const badge = + r.source === "custom" + ? `Custom` + : `Index`; - 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 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; - return ` -
-
-
-
${this._esc(r.name)}
-
${this._esc(desc)}
-
${this._esc(`${creator} · ${latest}`)}
+ const lineBits = [creator, latest, cat, metaSrc].filter(Boolean); + + return ` +
+
+
+
${this._esc(r.name)}
+
${this._esc(desc)}
+
${this._esc(lineBits.join(" · "))}
+
+ ${badge}
- ${badge}
-
- `; - }).join(""); + `; + }) + .join(""); return `
+ ${rows || `
No repositories configured.
`} `; } @@ -360,6 +578,7 @@ class BahmcloudStorePanel extends HTMLElement { this._update(); }); } + if (cat) { cat.addEventListener("change", (e) => { this._category = String(e.target.value || "all"); @@ -379,45 +598,82 @@ class BahmcloudStorePanel extends HTMLElement { const r = this._detailRepo; if (!r) return `
Repository not found.
`; + const badge = + r.source === "custom" ? `Custom` : `Index`; + const desc = r.description || "No description available."; + const latest = r.latest_version ? `Latest: ${r.latest_version}` : "Latest: unknown"; + + const infoBits = [ + r.owner ? `Creator: ${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, + ].filter(Boolean); const readmeBlock = this._readmeLoading ? `
Loading README…
` : this._readmeText - ? ` -
+ ? ` +
+
README
-
+
${this._readmeHtml ? "Rendered Markdown" : "Raw Markdown (renderer unavailable)"}
+
-
- Show raw Markdown -
-
${this._esc(this._readmeText)}
-
-
-
- ` - : ` -
-
README
-
${this._esc(this._readmeError || "README not found.")}
-
- `; +
+ +
+ Show raw Markdown +
+
${this._esc(this._readmeText)}
+
+
+
+ ` + : ` +
+
README
+
${this._esc(this._readmeError || "README not found.")}
+
+ `; return ` -
-
-
-
${this._esc(r.name)}
-
${this._esc(desc)}
-
- Open repository +
+
+
+
+
+
${this._esc(r.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).
- ${this._esc(r.source === "custom" ? "Custom" : "Index")}
- ${readmeBlock} `; } @@ -427,14 +683,140 @@ class BahmcloudStorePanel extends HTMLElement { if (!pretty) return; if (this._readmeHtml) { - // ✅ pretty HTML from backend pretty.innerHTML = this._readmeHtml; } else if (this._readmeText) { - // fallback: no renderer available in backend pretty.innerHTML = `
Markdown rendering is not available on this system. Use “Show raw Markdown”.
`; } } + _renderFabs() { + const r = this._detailRepo; + if (!r) return ""; + + return ` +
+
+
+
+
+
i
+
+ `; + } + + _wireFabs() { + const root = this.shadowRoot; + const r = this._detailRepo; + if (!r) return; + + 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 (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) => ` +
+
+
+
${this._esc(r.name)}
+
${this._esc(r.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() { + 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(this._data.version || "-")}
+
+ `; + } + + _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("&", "&")