Files
bahmcloud_store/custom_components/bahmcloud_store/panel/panel.js

825 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class BahmcloudStorePanel extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this._hass = null;
this._view = "store";
this._data = null;
this._loading = true;
this._error = null;
this._customAddUrl = "";
this._customAddName = "";
this._search = "";
this._category = "all";
this._detailRepoId = null;
this._detailRepo = null;
this._readmeLoading = false;
this._readmeText = null;
this._readmeError = null;
}
set hass(hass) {
this._hass = hass;
if (!this._rendered) {
this._rendered = true;
this._render();
this._load();
}
}
async _load() {
if (!this._hass) return;
this._loading = true;
this._error = null;
this._update();
try {
const data = await this._hass.callApi("get", "bcs");
this._data = data;
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
} finally {
this._loading = false;
this._update();
}
}
_isDesktop() {
return window.matchMedia && window.matchMedia("(min-width: 1024px)").matches;
}
_toggleMenu() {
if (this._isDesktop()) return;
try {
const ev = new Event("hass-toggle-menu", { bubbles: true, composed: true });
this.dispatchEvent(ev);
return;
} catch (_) {}
try {
const host = this.shadowRoot?.host;
if (host) {
const ev2 = new Event("hass-toggle-menu", { bubbles: true, composed: true });
host.dispatchEvent(ev2);
return;
}
} catch (_) {}
this._error = "Unable to open the sidebar on this client. Use the back button.";
this._update();
}
_goBack() {
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 (_) {
window.location.href = "/";
}
}
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);
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" && resp.readme.trim()) {
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;
root.innerHTML = `
<style>
:host { display:block; min-height:100%; --bcs-accent:#1E88E5; }
.mobilebar{
position:sticky; top:0; z-index:50;
display:flex; align-items:center; justify-content:space-between;
gap:8px; 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; }
.iconbtn{
width:40px; height:40px; border-radius:12px;
border:1px solid var(--divider-color);
background: color-mix(in srgb, var(--card-background-color) 80%, 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;
}
@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 .s{ font-size:12px; color:var(--secondary-text-color); margin-top:2px; }
.wrap{
padding:16px; max-width:1100px; margin:0 auto;
font-family: system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
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:700; 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:9px 12px; border-radius:12px;
border:1px solid var(--divider-color);
background:var(--card-background-color);
color:var(--primary-text-color);
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;
}
.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: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; }
.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, textarea{
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, select:focus, textarea: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{
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: 800; }
</style>
<div class="mobilebar">
<div class="left">
<div class="iconbtn menu" id="menuBtn" title="Menu">☰</div>
<div class="iconbtn" id="backBtn" title="Back">←</div>
<div class="brandtitle">
<div class="t">Bahmcloud Store</div>
<div class="s" id="subtitle">BCS — loading…</div>
</div>
</div>
<div class="right">
<button id="refreshBtn">Refresh</button>
</div>
</div>
<div class="wrap">
<div class="tabs" id="tabs">
<div class="tab" data-view="store">Store</div>
<div class="tab" data-view="manage">Manage repositories</div>
<div class="tab" data-view="about">Settings / About</div>
</div>
<div id="content"></div>
<div id="error" class="error"></div>
</div>
<div id="fabs"></div>
`;
root.getElementById("refreshBtn").addEventListener("click", () => this._load());
root.getElementById("menuBtn").addEventListener("click", () => this._toggleMenu());
root.getElementById("backBtn").addEventListener("click", () => this._goBack());
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 panel inputs
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();
}
};
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;
const state = {
id: ae.id,
value: ae.value,
selectionStart: typeof ae.selectionStart === "number" ? ae.selectionStart : null,
selectionEnd: typeof ae.selectionEnd === "number" ? ae.selectionEnd : null,
};
return state;
}
_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 = `<div class="card">Loading…</div>`;
this._restoreFocusState(focusState);
return;
}
if (!this._data) {
content.innerHTML = `<div class="card">No data.</div>`;
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 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 options = [
`<option value="all"${this._category === "all" ? " selected" : ""}>All categories</option>`,
...categories.map((c) => `<option value="${this._esc(c)}"${this._category === c ? " selected" : ""}>${this._esc(c)}</option>`),
].join("");
const rows = filtered.map((r) => {
const badge = 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 cat = r.category ? `Category: ${r.category}` : null;
const metaSrc = r.meta_source ? `Meta: ${r.meta_source}` : null;
const latest = r.latest_version ? `Latest: ${r.latest_version}` : "Latest: unknown";
const lineBits = [creator, latest, cat, metaSrc].filter(Boolean);
return `
<div class="card clickable" data-repo="${this._esc(r.id)}">
<div class="row">
<div>
<div><strong>${this._esc(r.name)}</strong></div>
<div class="muted">${this._esc(desc)}</div>
<div class="muted small">${this._esc(lineBits.join(" · "))}</div>
</div>
${badge}
</div>
</div>
`;
}).join("");
return `
<div class="filters">
<input id="searchInput" placeholder="Search repositories…" value="${this._esc(this._search)}" />
<select id="categorySelect">
${options}
</select>
</div>
${rows || `<div class="card">No repositories configured.</div>`}
`;
}
_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 `<div class="card">Repository not found.</div>`;
const badge = r.source === "custom"
? `<span class="badge custom">Custom</span>`
: `<span class="badge">Index</span>`;
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
? `<div class="card">Loading README…</div>`
: this._readmeText
? `
<div class="card">
<div><strong>README</strong></div>
<div id="readmeContainer" style="margin-top:12px;"></div>
<details>
<summary>Show raw Markdown</summary>
<div style="margin-top:10px;">
<pre class="readme">${this._esc(this._readmeText)}</pre>
</div>
</details>
</div>
`
: `
<div class="card">
<div><strong>README</strong></div>
<div class="muted">${this._esc(this._readmeError || "README not found.")}</div>
</div>
`;
return `
<div class="grid2">
<div>
<div class="card">
<div class="row">
<div>
<div><strong style="font-size:16px;">${this._esc(r.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>
</div>
</div>
${badge}
</div>
</div>
${readmeBlock}
</div>
<div>
<div class="card">
<div><strong>Installation & Updates</strong></div>
<div class="muted">
Installation & updates will be implemented via the official Home Assistant APIs.
In v0.4.0, the buttons are UI-only (coming soon).
</div>
<div class="muted small" style="margin-top:10px;">
Updates remain manual (like HACS).
</div>
</div>
</div>
</div>
`;
}
_wireDetail() {
const root = this.shadowRoot;
const container = root.getElementById("readmeContainer");
if (!container) return;
container.innerHTML = "";
if (!this._readmeText) return;
// Only attempt ha-markdown if the custom element exists
const hasHaMarkdown = typeof customElements !== "undefined" && !!customElements.get("ha-markdown");
if (hasHaMarkdown) {
try {
const el = document.createElement("ha-markdown");
try { el.hass = this._hass; } catch (_) {}
el.content = this._readmeText;
container.appendChild(el);
return;
} catch (_) {
// fall through to raw fallback (details already exists)
}
}
// If ha-markdown is missing, show a minimal rendered area:
const pre = document.createElement("pre");
pre.className = "readme";
pre.textContent = this._readmeText;
container.appendChild(pre);
}
_renderFabs() {
const r = this._detailRepo;
if (!r) return "";
return `
<div class="fabs">
<div class="fab primary" id="fabOpen" title="Open repository">↗</div>
<div class="fab" id="fabReload" title="Reload README">⟳</div>
<div class="fab" id="fabInstall" title="Install (coming soon)" disabled></div>
<div class="fab" id="fabUpdate" title="Update (coming soon)" disabled>↑</div>
</div>
`;
}
_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 `
<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>
</div>
</div>
</div>
`;
}).join("");
return `
<div class="card">
<div><strong>Manage repositories</strong></div>
<div class="muted">Add public repositories from any git provider.</div>
<div style="display:grid; gap:10px; margin-top:12px;">
<div>
<div class="muted small">Repository URL</div>
<input id="addUrl" placeholder="https://github.com/user/repo" value="${this._esc(this._customAddUrl)}" />
</div>
<div>
<div class="muted small">Display name (optional)</div>
<input id="addName" placeholder="My Integration" value="${this._esc(this._customAddName)}" />
</div>
<div>
<button id="addBtn" class="primary">Add repository</button>
</div>
</div>
</div>
${list || `<div class="card">No custom repositories added yet.</div>`}
`;
}
_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 `
<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>
`;
}
_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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
}
customElements.define("bahmcloud-store-panel", BahmcloudStorePanel);