Files
bahmcloud_store/custom_components/bahmcloud_store/panel/app.js
2026-01-17 08:13:22 +00:00

1034 lines
33 KiB
JavaScript
Raw Blame History

class BahmcloudStorePanel extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this._hass = null;
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";
this._provider = "all"; // all|github|gitea|gitlab|other|custom
this._detailRepoId = null;
this._detailRepo = null;
this._readmeLoading = false;
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = null;
this._refreshing = false;
this._installingRepoId = null;
this._updatingRepoId = null;
this._uninstallingRepoId = null;
this._restartRequired = false;
this._lastActionMsg = 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;
if (this._view === "detail" && this._detailRepoId && Array.isArray(data?.repos)) {
const fresh = data.repos.find((r) => this._safeId(r?.id) === this._detailRepoId);
if (fresh) this._detailRepo = fresh;
}
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
} finally {
this._loading = false;
this._update();
}
}
async _refreshAll() {
if (!this._hass) return;
if (this._refreshing) return;
this._refreshing = true;
this._error = null;
this._loading = true;
this._update();
try {
const resp = await this._hass.callApi("post", "bcs?action=refresh", {});
if (!resp?.ok) {
const msg = this._safeText(resp?.message) || "Refresh failed.";
this._error = msg;
}
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
} finally {
this._refreshing = false;
}
await this._load();
}
async _installRepo(repoId) {
if (!this._hass) return;
if (!repoId) return;
if (this._installingRepoId || this._updatingRepoId) return;
this._installingRepoId = repoId;
this._error = null;
this._lastActionMsg = null;
this._update();
try {
const resp = await this._hass.callApi("post", `bcs/install?repo_id=${encodeURIComponent(repoId)}`, {});
if (!resp?.ok) {
this._error = this._safeText(resp?.message) || "Install failed.";
} else {
this._restartRequired = !!resp.restart_required;
this._lastActionMsg = "Installation finished. Restart required.";
}
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
} finally {
this._installingRepoId = null;
await this._load();
}
}
async _updateRepo(repoId) {
if (!this._hass) return;
if (!repoId) return;
if (this._installingRepoId || this._updatingRepoId) return;
this._updatingRepoId = repoId;
this._error = null;
this._lastActionMsg = null;
this._update();
try {
const resp = await this._hass.callApi("post", `bcs/update?repo_id=${encodeURIComponent(repoId)}`, {});
if (!resp?.ok) {
this._error = this._safeText(resp?.message) || "Update failed.";
} else {
this._restartRequired = !!resp.restart_required;
this._lastActionMsg = "Update finished. Restart required.";
}
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
} finally {
this._updatingRepoId = null;
await this._load();
}
}
async _uninstallRepo(repoId) {
if (!repoId) return;
if (this._installingRepoId || this._updatingRepoId || this._uninstallingRepoId) return;
const r = this._repoById(repoId);
const name = this._safeText(r?.name) || repoId;
const ok = window.confirm(`Uninstall "${name}"?\n\nThis will delete the integration folder(s) from /config/custom_components. A restart will be required.`);
if (!ok) return;
this._uninstallingRepoId = repoId;
this._lastActionMsg = null;
this.requestUpdate();
try {
const resp = await this._hass.callApi("post", `bcs/uninstall?repo_id=${encodeURIComponent(repoId)}`, {});
if (resp && resp.ok) {
this._restartRequired = !!resp.restart_required;
this._lastActionMsg = "Uninstalled. Restart required.";
} else {
this._lastActionMsg = (resp && resp.message) ? String(resp.message) : "Uninstall failed.";
}
} catch (e) {
this._lastActionMsg = e && e.message ? e.message : "Uninstall failed.";
} finally {
this._uninstallingRepoId = null;
await this._load();
}
}
async _restartHA() {
if (!this._hass) return;
try {
await this._hass.callApi("post", "bcs/restart", {});
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
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);
} catch (_) {}
}
_goBack() {
if (this._view === "detail") {
this._view = "store";
this._detailRepoId = null;
this._detailRepo = null;
this._readmeText = null;
this._readmeHtml = 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) => 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._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;
this._readmeHtml = typeof resp.html === "string" && resp.html.trim() ? resp.html : null;
} else {
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = this._safeText(resp?.message) || "README not found.";
}
} catch (e) {
this._readmeText = null;
this._readmeHtml = 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: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:10px; }
.iconbtn{
width:40px; height:40px; border-radius:14px;
display:flex; align-items:center; justify-content:center;
border:1px solid var(--divider-color);
background: var(--card-background-color);
cursor:pointer; user-select:none;
}
.iconbtn:hover{ filter:brightness(0.98); }
.wrap{ max-width:1200px; margin:0 auto; padding:16px; }
.tabs{ display:flex; gap:10px; flex-wrap:wrap; margin:8px 0 16px; }
.tab{
padding:10px 14px; border-radius:999px;
border:1px solid var(--divider-color);
cursor:pointer; user-select:none;
background: var(--card-background-color);
}
.tab.active{ border-color: var(--bcs-accent); box-shadow: 0 0 0 2px rgba(30,136,229,.15); }
.grid{ display:grid; gap:12px; grid-template-columns: repeat(1, minmax(0, 1fr)); }
@media (min-width: 900px){ .grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.grid2{ display:grid; gap:12px; grid-template-columns: 1fr; }
@media (min-width: 1024px){ .grid2{ grid-template-columns: 1.2fr .8fr; } }
.card{
padding:14px 14px;
border-radius:18px;
background: var(--card-background-color);
border:1px solid var(--divider-color);
box-shadow: 0 1px 0 rgba(0,0,0,.04);
}
.row{ display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.muted{ color: var(--secondary-text-color); }
.small{ font-size: 12px; }
.badge{
padding:6px 10px;
border-radius:999px;
border:1px solid var(--divider-color);
background: rgba(30,136,229,.06);
color: var(--primary-text-color);
font-size: 12px;
white-space:nowrap;
}
.filters{ display:flex; gap:10px; flex-wrap:wrap; margin-bottom:12px; }
input, select{
padding:10px 12px;
border-radius:14px;
border:1px solid var(--divider-color);
background: var(--card-background-color);
color: var(--primary-text-color);
outline:none;
}
input:focus, select:focus{
border-color: var(--bcs-accent);
box-shadow: 0 0 0 2px rgba(30,136,229,.15);
}
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;
}
button.primary{
border-color: rgba(30,136,229,.35);
background: rgba(30,136,229,.08);
}
button:disabled{ opacity: .55; cursor: not-allowed; }
.err{
margin:12px 0;
padding:12px 14px;
border-radius:14px;
border:1px solid rgba(255, 82, 82, .35);
background: rgba(255, 82, 82, .08);
}
.fabs{
position: fixed;
right: 16px;
bottom: 16px;
display:flex;
flex-direction:column;
gap:10px;
z-index: 60;
}
.fabbtn{
width:54px; height:54px;
border-radius:18px;
border:1px solid var(--divider-color);
background: var(--card-background-color);
display:flex; align-items:center; justify-content:center;
cursor:pointer;
box-shadow: 0 8px 18px rgba(0,0,0,.12);
user-select:none;
font-size: 18px;
padding: 0;
}
.fabbtn.primary{
border-color: rgba(30,136,229,.35);
background: rgba(30,136,229,.10);
}
.fabbtn:disabled{
opacity: .55;
cursor: not-allowed;
}
pre.readme{
padding: 12px;
border-radius: 14px;
border: 1px solid var(--divider-color);
background: rgba(0,0,0,.04);
overflow:auto;
font-size: 12px;
line-height: 1.4;
}
.md :is(h1,h2,h3){ margin-top: 12px; }
.md code{
padding: 2px 5px;
border-radius: 8px;
border: 1px solid var(--divider-color);
background: rgba(0,0,0,.04);
}
.md pre{
padding: 12px;
border-radius: 14px;
border: 1px solid var(--divider-color);
background: rgba(0,0,0,.04);
overflow:auto;
}
.md table{
width:100%;
border-collapse: collapse;
overflow:auto;
display:block;
}
.md th, .md td{
border: 1px solid var(--divider-color);
padding: 8px;
text-align:left;
}
</style>
<div class="mobilebar">
<div class="left">
<div class="iconbtn" id="menuBtn" title="Menu">鈽<></div>
<div class="iconbtn" id="backBtn" title="Back">鈫<></div>
<div>
<div style="font-weight:700;">Bahmcloud Store</div>
<div class="muted small" id="subtitle">Store</div>
</div>
</div>
<div class="right">
<div class="iconbtn" id="refreshBtn" title="Refresh">鉄<></div>
</div>
</div>
<div class="wrap">
<div class="tabs">
<div class="tab" id="tabStore">Store</div>
<div class="tab" id="tabManage">Manage</div>
<div class="tab" id="tabAbout">About</div>
</div>
<div id="content"></div>
</div>
<div id="fabs"></div>
`;
root.getElementById("menuBtn").addEventListener("click", () => this._toggleMenu());
root.getElementById("backBtn").addEventListener("click", () => this._goBack());
root.getElementById("refreshBtn").addEventListener("click", () => this._refreshAll());
root.getElementById("tabStore").addEventListener("click", () => {
this._view = "store";
this._update();
});
root.getElementById("tabManage").addEventListener("click", () => {
this._view = "manage";
this._update();
});
root.getElementById("tabAbout").addEventListener("click", () => {
this._view = "about";
this._update();
});
this._update();
}
_update() {
const root = this.shadowRoot;
if (!root) return;
const subtitle = root.getElementById("subtitle");
if (subtitle) subtitle.textContent = this._view === "detail" ? "Details" : this._view[0].toUpperCase() + this._view.slice(1);
const setActive = (id, on) => {
const el = root.getElementById(id);
if (!el) return;
el.classList.toggle("active", !!on);
};
setActive("tabStore", this._view === "store" || this._view === "detail");
setActive("tabManage", this._view === "manage");
setActive("tabAbout", this._view === "about");
const content = root.getElementById("content");
const fabs = root.getElementById("fabs");
if (!content || !fabs) return;
const err = this._error
? `<div class="err"><strong>Error:</strong> ${this._esc(this._error)}</div>`
: "";
if (this._loading) {
content.innerHTML = `${err}<div class="card">Loading鈥<67></div>`;
fabs.innerHTML = "";
return;
}
if (!this._data?.ok) {
content.innerHTML = `${err}<div class="card">No data. Please refresh.</div>`;
fabs.innerHTML = "";
return;
}
let html = "";
if (this._view === "store") html = this._renderStore();
else if (this._view === "manage") html = this._renderManage();
else if (this._view === "about") html = this._renderAbout();
else if (this._view === "detail") html = this._renderDetail();
content.innerHTML = `${err}${html}`;
fabs.innerHTML = this._view === "detail" ? this._renderFabs() : "";
if (this._view === "store") this._wireStore();
if (this._view === "manage") this._wireManage();
if (this._view === "detail") {
this._wireDetail(); // now always wires buttons
this._wireFabs();
}
}
_safeText(v) {
if (v === null || v === undefined) return "";
return String(v);
}
_safeId(v) {
return this._safeText(v).trim();
}
_esc(s) {
return this._safeText(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
_asBoolStrict(v) {
return v === true;
}
_renderStore() {
const repos = Array.isArray(this._data.repos) ? this._data.repos : [];
const filtered = repos
.filter((r) => {
const name = (this._safeText(r?.name) || "").toLowerCase();
const url = (this._safeText(r?.url) || "").toLowerCase();
const desc = (this._safeText(r?.description) || "").toLowerCase();
const q = (this._search || "").trim().toLowerCase();
if (q && !(name.includes(q) || url.includes(q) || desc.includes(q))) return false;
const cat = this._safeText(r?.category) || "";
if (this._category !== "all" && this._category !== cat) return false;
const prov = this._safeText(r?.provider) || "other";
if (this._provider !== "all") {
if (this._provider === "custom" && r?.source !== "custom") return false;
if (this._provider !== "custom" && prov !== this._provider) return false;
}
return true;
})
.sort((a, b) => {
const an = (this._safeText(a?.name) || "").toLowerCase();
const bn = (this._safeText(b?.name) || "").toLowerCase();
return an.localeCompare(bn);
});
const categories = Array.from(
new Set(repos.map((r) => this._safeText(r?.category)).filter((c) => !!c))
).sort();
const providers = ["github", "gitlab", "gitea", "other"];
const cards = filtered
.map((r) => {
const id = this._safeId(r?.id);
const name = this._safeText(r?.name) || "Unnamed repository";
const url = this._safeText(r?.url) || "";
const desc = this._safeText(r?.description) || "";
const latest = this._safeText(r?.latest_version);
const installed = this._asBoolStrict(r?.installed);
const installedVersion = this._safeText(r?.installed_version);
const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion);
const badges = [];
if (r?.source === "custom") badges.push("Custom");
if (installed) badges.push("Installed");
if (updateAvailable) badges.push("Update");
const badgeHtml = badges.length
? `<div class="badge">${this._esc(badges.join(" 路 "))}</div>`
: `<div class="badge">${this._esc(this._safeText(r?.provider || "repo"))}</div>`;
return `
<div class="card" data-open="${this._esc(id)}">
<div class="row">
<div>
<div><strong>${this._esc(name)}</strong></div>
<div class="muted">${this._esc(desc)}</div>
<div class="muted small" style="margin-top:8px;">${this._esc(url)}</div>
</div>
${badgeHtml}
</div>
</div>
`;
})
.join("");
return `
<div class="filters">
<input id="q" placeholder="Search鈥<68>" value="${this._esc(this._search)}" />
<select id="cat">
<option value="all">All categories</option>
${categories.map((c) => `<option value="${this._esc(c)}" ${this._category === c ? "selected" : ""}>${this._esc(c)}</option>`).join("")}
</select>
<select id="prov">
<option value="all">All providers</option>
<option value="custom" ${this._provider === "custom" ? "selected" : ""}>Custom</option>
${providers.map((p) => `<option value="${p}" ${this._provider === p ? "selected" : ""}>${p.toUpperCase()}</option>`).join("")}
</select>
</div>
<div class="muted small">Version: ${this._esc(this._data.version || "-")} 路 Repositories: ${repos.length}</div>
<div class="grid" style="margin-top:12px;">
${cards || `<div class="card">No repositories found.</div>`}
</div>
`;
}
_wireStore() {
const root = this.shadowRoot;
const q = root.getElementById("q");
const cat = root.getElementById("cat");
const prov = root.getElementById("prov");
if (q) {
q.addEventListener("input", (e) => {
this._search = e?.target?.value || "";
this._update();
});
}
if (cat) {
cat.addEventListener("change", (e) => {
this._category = e?.target?.value || "all";
this._update();
});
}
if (prov) {
prov.addEventListener("change", (e) => {
this._provider = e?.target?.value || "all";
this._update();
});
}
root.querySelectorAll("[data-open]").forEach((el) => {
const id = el.getAttribute("data-open");
el.addEventListener("click", () => this._openRepoDetail(id));
});
}
_renderAbout() {
return `
<div class="card">
<div><strong>About</strong></div>
<div class="muted" style="margin-top:10px;">
Bahmcloud Store is a provider-neutral repository index and UI for Home Assistant.
</div>
<div class="muted small" style="margin-top:10px;">
Current integration version: <strong>${this._esc(this._data?.version || "-")}</strong>
</div>
</div>
`;
}
_renderDetail() {
const r = this._detailRepo;
if (!r) return `<div class="card">No repository selected.</div>`;
const name = this._safeText(r?.name) || "Unnamed repository";
const url = this._safeText(r?.url) || "";
const desc = this._safeText(r?.description) || "";
const infoBits = [
this._safeText(r?.owner) ? `Creator: ${this._safeText(r?.owner)}` : "Creator: -",
this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: -",
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
? `<div class="card">Loading README鈥<45></div>`
: this._readmeText
? `
<div class="card">
<div class="row" style="align-items:center;">
<div><strong>README</strong></div>
<div class="muted small">Rendered Markdown</div>
</div>
<div id="readmePretty" class="md" 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>
`;
const repoId = this._safeId(r?.id);
const installed = this._asBoolStrict(r?.installed);
const installedVersion = this._safeText(r?.installed_version);
const installedDomains = Array.isArray(r?.installed_domains) ? r.installed_domains : [];
const latestVersion = this._safeText(r?.latest_version);
const busyInstall = this._installingRepoId === repoId;
const busyUpdate = this._updatingRepoId === repoId;
const busyUninstall = this._uninstallingRepoId === repoId;
const busy = busyInstall || busyUpdate || busyUninstall;
const updateAvailable = installed && !!latestVersion && (!installedVersion || latestVersion !== installedVersion);
const installBtn = `<button class="primary" id="btnInstall" ${installed || busy ? "disabled" : ""}>${busyInstall ? "Installing鈥<67>" : installed ? "Installed" : "Install"}</button>`;
const updateBtn = `<button class="primary" id="btnUpdate" ${!updateAvailable || busy ? "disabled" : ""}>${busyUpdate ? "Updating鈥<67>" : updateAvailable ? "Update" : "Up to date"}</button>`;
const uninstallBtn = `<button id="btnUninstall" ${!installed || busy ? "disabled" : ""}>${busyUninstall ? "Uninstalling鈥<67>" : "Uninstall"}</button>`;
const restartHint = this._restartRequired
? `
<div class="card" style="margin-top:12px; background: rgba(255, 193, 7, 0.12); border: 1px solid rgba(255, 193, 7, 0.35);">
<div><strong>Restart required</strong></div>
<div class="muted" style="margin-top:6px;">Home Assistant must be restarted to load the installed integration.</div>
<div class="row" style="margin-top:12px;">
<button class="primary" id="btnRestart">Restart now</button>
</div>
</div>
`
: (this._safeText(this._lastActionMsg)
? `<div class="muted small" style="margin-top:12px;">${this._esc(this._lastActionMsg)}</div>`
: "");
return `
<div class="grid2">
<div>
<div class="card">
<div class="row">
<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(url)}" target="_blank" rel="noreferrer">Open repository</a>
</div>
</div>
<div class="badge">${this._esc(this._safeText(r?.provider || "repo"))}</div>
</div>
</div>
${readmeBlock}
</div>
<div>
<div class="card">
<div class="row" style="align-items:center;">
<div><strong>Installation & Updates</strong></div>
<div class="muted small">${installed ? "Installed" : "Not installed"}</div>
</div>
<div class="muted" style="margin-top:10px;">
<div><strong>Installed version:</strong> ${this._esc(installedVersion || "-")}</div>
<div><strong>Latest version:</strong> ${this._esc(latestVersion || "-")}</div>
<div style="margin-top:6px;"><strong>Domains:</strong> ${installedDomains.length ? this._esc(installedDomains.join(", ")) : "-"}</div>
</div>
<div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;">
${installBtn}
${updateBtn}
${uninstallBtn}
</div>
${restartHint}
</div>
</div>
</div>
`;
}
_wireDetail() {
const root = this.shadowRoot;
// Always wire action buttons (even if README is already loaded)
const btnInstall = root.getElementById("btnInstall");
const btnUpdate = root.getElementById("btnUpdate");
const btnUninstall = root.getElementById("btnUninstall");
const btnRestart = root.getElementById("btnRestart");
if (btnInstall) {
btnInstall.addEventListener("click", () => {
if (btnInstall.disabled) return;
if (this._detailRepoId) this._installRepo(this._detailRepoId);
});
}
if (btnUpdate) {
btnUpdate.addEventListener("click", () => {
if (btnUpdate.disabled) return;
if (this._detailRepoId) this._updateRepo(this._detailRepoId);
});
}
if (btnUninstall) {
btnUninstall.addEventListener("click", () => {
if (btnUninstall.disabled) return;
if (this._detailRepoId) this._uninstallRepo(this._detailRepoId);
});
}
if (btnRestart) {
btnRestart.addEventListener("click", () => this._restartHA());
}
const mount = root.getElementById("readmePretty");
if (!mount) return;
if (this._readmeText) {
if (this._readmeHtml) {
mount.innerHTML = this._readmeHtml;
this._postprocessRenderedMarkdown(mount);
} else {
mount.innerHTML = `<div class="muted">Rendered HTML not available. Use 鈥淪how raw Markdown鈥<6E>.</div>`;
}
} else {
mount.innerHTML = "";
}
}
_postprocessRenderedMarkdown(container) {
if (!container) return;
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 "";
const repoId = this._safeId(r?.id);
const installed = this._asBoolStrict(r?.installed);
const latest = this._safeText(r?.latest_version);
const installedVersion = this._safeText(r?.installed_version);
const busy = this._installingRepoId === repoId || this._updatingRepoId === repoId;
const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion);
const installDisabled = installed || busy;
const updateDisabled = !updateAvailable || busy;
return `
<div class="fabs">
<button class="fabbtn primary" id="fabOpen" title="Open repository">鈫<></button>
<button class="fabbtn" id="fabReload" title="Reload README">鉄<></button>
<button class="fabbtn" id="fabInstall" title="${installDisabled ? (installed ? "Already installed" : "Installing鈥<67>") : "Install"}" ${installDisabled ? "disabled" : ""}>锛<></button>
<button class="fabbtn" id="fabUpdate" title="${updateDisabled ? (!installed ? "Not installed" : "No update available") : "Update"}" ${updateDisabled ? "disabled" : ""}>鈫<></button>
<button class="fabbtn" id="fabInfo" title="About">i</button>
</div>
`;
}
_wireFabs() {
const root = this.shadowRoot;
const r = this._detailRepo;
if (!r) return;
const url = this._safeText(r?.url);
const repoId = this._safeId(r?.id);
const open = root.getElementById("fabOpen");
const reload = root.getElementById("fabReload");
const install = root.getElementById("fabInstall");
const update = root.getElementById("fabUpdate");
const info = root.getElementById("fabInfo");
if (open) open.addEventListener("click", () => url && window.open(url, "_blank", "noreferrer"));
if (reload) reload.addEventListener("click", () => this._detailRepoId && this._loadReadme(this._detailRepoId));
if (install) {
install.addEventListener("click", () => {
if (install.disabled) return;
this._installRepo(repoId);
});
}
if (update) {
update.addEventListener("click", () => {
if (update.disabled) return;
this._updateRepo(repoId);
});
}
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 `
<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>
`;
})
.join("");
return `
<div class="card">
<div><strong>Custom Repositories</strong></div>
<div class="muted" style="margin-top:10px;">
Add any public Git repository that contains a Home Assistant integration.
</div>
<div class="filters" style="margin-top:12px;">
<input id="customUrl" placeholder="Repository URL" value="${this._esc(this._customAddUrl)}" />
<input id="customName" placeholder="Optional name" value="${this._esc(this._customAddName)}" />
<button class="primary" id="addCustom">Add</button>
</div>
</div>
<div class="grid" style="margin-top:12px;">
${list || `<div class="card">No custom repositories added.</div>`}
</div>
`;
}
_wireManage() {
const root = this.shadowRoot;
const url = root.getElementById("customUrl");
const name = root.getElementById("customName");
const add = root.getElementById("addCustom");
if (url) url.addEventListener("input", (e) => { this._customAddUrl = e?.target?.value || ""; });
if (name) name.addEventListener("input", (e) => { this._customAddName = e?.target?.value || ""; });
if (add) add.addEventListener("click", () => this._addCustomRepo());
root.querySelectorAll("[data-remove]").forEach((el) => {
const id = el.getAttribute("data-remove");
el.addEventListener("click", () => this._removeCustomRepo(id));
});
}
}
customElements.define("bahmcloud-store-panel", BahmcloudStorePanel);