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

1727 lines
58 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"; // store | manage | about | detail
this._data = null;
this._loading = true;
this._error = null;
this._customAddUrl = "";
this._customAddName = "";
this._search = "";
this._category = "all";
this._filter = "all"; // all|installed|not_installed|updates|custom
this._sort = "az"; // az|updates_first|installed_first
// Source filter (all|bcs|hacs|custom)
this._sourceFilter = "all";
// HACS toggle (settings)
this._hacsEnabled = false;
this._favoriteRepoIds = [];
this._detailRepoId = null;
this._detailRepo = null;
this._readmeLoading = false;
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = null;
// README UX (E2)
this._readmeExpanded = false;
this._readmeCanToggle = false;
this._refreshing = false;
this._installingRepoId = null;
this._updatingRepoId = null;
this._uninstallingRepoId = null;
this._restartRequired = false;
this._lastActionMsg = null;
// Phase F2.1: restore from backups
this._restoreOpen = false;
this._restoreRepoId = null;
this._restoreLoading = false;
this._restoreOptions = [];
this._restoreSelected = "";
this._restoring = false;
this._restoreError = null;
// Phase C1: selectable install version
this._versionsCache = {}; // repo_id -> [{ref,label,source}, ...]
this._versionsLoadingRepoId = null;
this._selectedVersionByRepoId = {}; // repo_id -> ref ("" means latest)
this._releaseNotesLoading = false;
this._releaseNotesText = null;
this._releaseNotesHtml = null;
this._releaseNotesError = null;
// History handling (mobile back button should go back to list, not exit panel)
this._historyBound = false;
this._handlingPopstate = false;
}
set hass(hass) {
this._hass = hass;
if (!this._rendered) {
this._rendered = true;
this._render();
this._ensureHistory();
this._load();
}
}
_ensureHistory() {
if (this._historyBound) return;
this._historyBound = true;
try {
// Keep an internal history state for this panel.
const current = window.history.state || {};
if (!current || current.__bcs !== true) {
window.history.replaceState({ __bcs: true, view: "store" }, "");
}
} catch (e) {
// ignore
}
window.addEventListener("popstate", (ev) => {
const st = ev?.state;
if (!st || st.__bcs !== true) return;
this._handlingPopstate = true;
try {
const view = st.view || "store";
if (view === "detail" && st.repo_id) {
this._openRepoDetail(st.repo_id, false);
} else {
this._closeDetail(false);
}
} finally {
this._handlingPopstate = false;
}
});
}
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;
// Persistent settings (e.g. HACS toggle)
this._hacsEnabled = !!data?.settings?.hacs_enabled;
this._favoriteRepoIds = Array.isArray(data?.settings?.favorite_repo_ids)
? data.settings.favorite_repo_ids.map((x) => String(x))
: [];
// Sync settings from backend (e.g. HACS toggle)
this._hacsEnabled = !!data?.settings?.hacs_enabled;
this._favoriteRepoIds = Array.isArray(data?.settings?.favorite_repo_ids)
? data.settings.favorite_repo_ids.map((x) => String(x))
: [];
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 _setSettings(updates) {
if (!this._hass) return;
try {
const resp = await this._hass.callApi("post", "bcs/settings", updates || {});
if (resp?.ok) {
this._hacsEnabled = !!resp?.settings?.hacs_enabled;
this._favoriteRepoIds = Array.isArray(resp?.settings?.favorite_repo_ids)
? resp.settings.favorite_repo_ids.map((x) => String(x))
: [];
}
} catch (e) {
// Do not fail UI for settings.
this._error = e?.message ? String(e.message) : String(e);
}
}
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 sel = this._safeText(this._selectedVersionByRepoId?.[repoId] || "").trim();
const qv = sel ? `&version=${encodeURIComponent(sel)}` : "";
const resp = await this._hass.callApi(
"post",
`bcs/install?repo_id=${encodeURIComponent(repoId)}${qv}`,
{},
);
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 sel = this._safeText(this._selectedVersionByRepoId?.[repoId] || "").trim();
const qv = sel ? `&version=${encodeURIComponent(sel)}` : "";
const resp = await this._hass.callApi(
"post",
`bcs/update?repo_id=${encodeURIComponent(repoId)}${qv}`,
{},
);
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 (!this._hass) return;
if (!repoId) return;
if (this._installingRepoId || this._updatingRepoId || this._uninstallingRepoId) return;
const ok = window.confirm("Really uninstall this repository? This will remove its files from /config/custom_components and requires a restart.");
if (!ok) return;
this._uninstallingRepoId = repoId;
this._error = null;
this._lastActionMsg = null;
this._update();
try {
const resp = await this._hass.callApi("post", `bcs/uninstall?repo_id=${encodeURIComponent(repoId)}`, {});
if (!resp?.ok) {
this._error = this._safeText(resp?.message) || "Uninstall failed.";
} else {
this._restartRequired = !!resp.restart_required;
this._lastActionMsg = "Uninstall finished. Restart required.";
}
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
} finally {
this._uninstallingRepoId = null;
await this._load();
}
}
async _openRestore(repoId) {
if (!this._hass) return;
if (!repoId) return;
if (this._installingRepoId || this._updatingRepoId || this._uninstallingRepoId) return;
this._restoreRepoId = repoId;
this._restoreOpen = true;
this._restoreLoading = true;
this._restoreError = null;
this._restoreOptions = [];
this._restoreSelected = "";
this._update();
try {
const resp = await this._hass.callApi("get", `bcs/backups?repo_id=${encodeURIComponent(repoId)}`);
const list = Array.isArray(resp?.backups) ? resp.backups : [];
this._restoreOptions = list;
const firstComplete = list.find((x) => x && x.complete);
const firstAny = list[0];
const pick = firstComplete || firstAny;
if (pick && pick.id) this._restoreSelected = String(pick.id);
if (!list.length) {
this._restoreError = "No backups found for this repository.";
}
} catch (e) {
this._restoreError = e?.message ? String(e.message) : String(e);
} finally {
this._restoreLoading = false;
this._update();
}
}
_closeRestore() {
this._restoreOpen = false;
this._restoreRepoId = null;
this._restoreLoading = false;
this._restoreError = null;
this._restoreOptions = [];
this._restoreSelected = "";
this._restoring = false;
this._update();
}
async _restoreSelectedBackup() {
if (!this._hass) return;
if (!this._restoreRepoId) return;
const bid = String(this._restoreSelected || "").trim();
if (!bid) return;
if (this._restoring) return;
const chosen = (this._restoreOptions || []).find((x) => String(x?.id) === bid);
if (chosen && chosen.complete === false) {
this._restoreError = "Selected backup is not available for all domains of this repository.";
this._update();
return;
}
const ok = window.confirm("Restore selected backup? This will overwrite the installed files under /config/custom_components and requires a restart.");
if (!ok) return;
this._restoring = true;
this._restoreError = null;
this._update();
try {
const resp = await this._hass.callApi("post", `bcs/restore?repo_id=${encodeURIComponent(this._restoreRepoId)}&backup_id=${encodeURIComponent(bid)}`, {});
if (!resp?.ok) {
this._restoreError = this._safeText(resp?.message) || "Restore failed.";
} else {
this._restartRequired = !!resp.restart_required;
this._lastActionMsg = "Restore finished. Restart required.";
this._closeRestore();
}
} catch (e) {
this._restoreError = e?.message ? String(e.message) : String(e);
} finally {
this._restoring = false;
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() {
try {
// Prefer browser history so mobile back behaves as expected.
history.back();
} catch (_) {
if (this._view === "detail") {
this._closeDetail(true);
} else {
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, pushHistory = true) {
const repos = Array.isArray(this._data?.repos) ? this._data.repos : [];
const repo = repos.find((r) => this._safeId(r?.id) === repoId);
if (!repo) return;
if (pushHistory) {
this._pushHistory({ view: "detail", repo_id: repoId });
}
this._view = "detail";
this._detailRepoId = repoId;
this._detailRepo = repo;
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = null;
this._readmeExpanded = false;
this._readmeCanToggle = false;
this._releaseNotesLoading = false;
this._releaseNotesText = null;
this._releaseNotesHtml = null;
this._releaseNotesError = null;
// Versions dropdown
if (!(repoId in this._selectedVersionByRepoId)) {
this._selectedVersionByRepoId[repoId] = ""; // default = latest
}
this._update();
this._loadRepoDetails(repoId);
this._loadReadme(repoId);
this._loadVersions(repoId);
this._loadReleaseNotes(repoId);
}
async _loadRepoDetails(repoId) {
if (!this._hass || !repoId) return;
try {
const resp = await this._hass.callApi("get", `bcs/repo?repo_id=${encodeURIComponent(repoId)}`);
if (resp?.ok && resp.repo) {
this._detailRepo = resp.repo;
// Also update the cached list item if present
const repos = Array.isArray(this._data?.repos) ? this._data.repos : [];
const idx = repos.findIndex((r) => this._safeId(r?.id) === repoId);
if (idx >= 0) repos[idx] = resp.repo;
this._update();
}
} catch (e) {
// ignore: details are optional
}
}
async _loadVersions(repoId) {
if (!this._hass) return;
if (!repoId) return;
// Cache: avoid re-fetching repeatedly in the same session.
if (Array.isArray(this._versionsCache?.[repoId]) && this._versionsCache[repoId].length) {
return;
}
this._versionsLoadingRepoId = repoId;
this._update();
try {
const resp = await this._hass.callApi("get", `bcs/versions?repo_id=${encodeURIComponent(repoId)}`);
if (resp?.ok && Array.isArray(resp.versions)) {
this._versionsCache[repoId] = resp.versions;
} else {
this._versionsCache[repoId] = [];
}
} catch (e) {
this._versionsCache[repoId] = [];
} finally {
this._versionsLoadingRepoId = null;
this._update();
}
}
async _loadReleaseNotes(repoId) {
if (!this._hass || !repoId) return;
this._releaseNotesLoading = true;
this._releaseNotesText = null;
this._releaseNotesHtml = null;
this._releaseNotesError = null;
this._update();
try {
const sel = this._safeText(this._selectedVersionByRepoId?.[repoId] || "").trim();
const qv = sel ? `&ref=${encodeURIComponent(sel)}` : "";
const resp = await this._hass.callApi(
"get",
`bcs/release_notes?repo_id=${encodeURIComponent(repoId)}${qv}`,
);
if (resp?.ok && typeof resp.release_notes === "string" && resp.release_notes.trim()) {
this._releaseNotesText = resp.release_notes;
this._releaseNotesHtml =
typeof resp.html === "string" && resp.html.trim() ? resp.html : null;
} else {
this._releaseNotesError =
this._safeText(resp?.message) || "Release notes not available for this version.";
}
} catch (e) {
this._releaseNotesError = e?.message
? String(e.message)
: "Release notes not available for this version.";
} finally {
this._releaseNotesLoading = false;
this._update();
}
}
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;
const lines = resp.readme.split(/\r?\n/).length;
this._readmeCanToggle = lines > 24 || resp.readme.length > 1600;
this._readmeExpanded = !this._readmeCanToggle;
} else {
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = this._safeText(resp?.message) || "README not found.";
this._readmeCanToggle = false;
this._readmeExpanded = false;
}
} catch (e) {
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = e?.message ? String(e.message) : String(e);
this._readmeCanToggle = false;
this._readmeExpanded = false;
} finally {
this._readmeLoading = false;
this._update();
}
}
_render() {
const root = this.shadowRoot;
root.innerHTML = `
<style>
:host { display:block; min-height:100%; --bcs-accent:#1E88E5; max-width:100%; overflow-x:hidden; }
.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;
/* Ensure icon visibility on app header (light & dark modes) */
color: var(--app-header-text-color, var(--primary-text-color));
border: 1px solid rgba(255,255,255,0.35);
background: rgba(255,255,255,0.16);
cursor:pointer; user-select:none;
}
.iconbtn:hover{ filter:brightness(0.98); }
.wrap{ max-width:1200px; margin:0 auto; padding:16px; overflow-x:hidden; }
.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)); } }
/* Prevent grid children from forcing horizontal overflow (mobile) */
.grid > *{ min-width:0; }
.grid2{ display:grid; gap:12px; grid-template-columns: 1fr; }
@media (min-width: 1024px){ .grid2{ grid-template-columns: 1.2fr .8fr; } }
.grid2 > *{ min-width:0; }
.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);
overflow:hidden;
}
.row{ display:flex; align-items:flex-start; justify-content:space-between; gap:12px; flex-wrap:wrap; }
.muted{ color: var(--secondary-text-color); overflow-wrap:anywhere; word-break:break-word; }
.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:normal;
max-width:55%;
overflow:hidden;
text-overflow:ellipsis;
}
.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);
}
.toggle{
display:inline-flex;
align-items:center;
gap:8px;
padding:10px 12px;
border-radius:14px;
border:1px solid var(--divider-color);
background: var(--card-background-color);
color: var(--primary-text-color);
user-select:none;
cursor:pointer;
}
.toggle input{
margin:0;
width:18px;
height:18px;
}
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; }
.modalOverlay{
position:fixed; inset:0; z-index:999;
background: rgba(0,0,0,0.45);
display:flex; align-items:center; justify-content:center;
padding:16px;
}
.modal{
width: min(520px, 100%);
background: var(--card-background-color);
border:1px solid var(--divider-color);
border-radius:18px;
padding:16px;
box-shadow: 0 10px 30px rgba(0,0,0,0.25);
}
.modalTitle{ font-size:16px; font-weight:700; }
.err{
margin:12px 0;
padding:12px 14px;
border-radius:14px;
border:1px solid rgba(255, 82, 82, .35);
background: rgba(255, 82, 82, .08);
}
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;
}
/* Markdown can contain very wide content (tables/images). Keep it within the viewport. */
.md{ overflow-wrap:anywhere; word-break:break-word; max-width:100%; min-width:0; overflow-x:auto; -webkit-overflow-scrolling:touch; }
.md :is(h1,h2,h3){ margin-top: 12px; }
.md img{ max-width:100%; height:auto; }
.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-x:auto;
overflow-y:hidden;
-webkit-overflow-scrolling: touch;
display:block;
max-width:100%;
}
.readmeWrap{
margin-top:12px;
max-width:100%;
border:1px solid var(--divider-color);
border-radius:14px;
padding:12px;
background: rgba(0,0,0,.02);
position:relative;
overflow:hidden;
}
.readmeWrap.collapsed{
max-height:260px;
}
@media (min-width: 1024px){
.readmeWrap.collapsed{ max-height:340px; }
}
.readmeWrap.collapsed::after{
content:"";
position:absolute;
left:0; right:0; bottom:0;
height:64px;
background: linear-gradient(to bottom, rgba(0,0,0,0), var(--card-background-color));
pointer-events:none;
}
.readmeWrap.expanded{
max-height:70vh;
overflow:auto;
-webkit-overflow-scrolling:touch;
}
.readmeActions{
display:flex; justify-content:flex-end;
margin-top:10px;
}
button.link{
border:1px solid transparent;
background: transparent;
color: var(--bcs-accent);
padding:8px 10px;
}
button.link:hover{
border-color: rgba(30,136,229,.25);
background: rgba(30,136,229,.06);
}
.md th, .md td{
border: 1px solid var(--divider-color);
padding: 8px;
text-align:left;
max-width:100%;
overflow-wrap:anywhere;
word-break:break-word;
}
</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>
`;
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;
// Preserve focus & cursor position for inputs that trigger a re-render (e.g. search).
// Without this, mobile browsers may drop focus after each keystroke.
const active = root.activeElement;
const restore = {
id: active && active.id ? String(active.id) : null,
start: null,
end: null,
};
try {
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) {
if (typeof active.selectionStart === "number" && typeof active.selectionEnd === "number") {
restore.start = active.selectionStart;
restore.end = active.selectionEnd;
}
}
} catch (_) {
// ignore
}
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");
if (!content) 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…</div>`;
return;
}
if (!this._data?.ok) {
content.innerHTML = `${err}<div class="card">No data. Please refresh.</div>`;
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();
const modal = this._renderRestoreModal();
content.innerHTML = `${err}${html}${modal}`;
if (this._view === "store") this._wireStore();
if (this._view === "manage") this._wireManage();
if (this._view === "detail") {
this._wireDetail(); // now always wires buttons
}
this._wireRestoreModal();
// Restore focus and cursor for the search field after re-render.
if (restore.id && this._view === "store") {
const el = root.getElementById(restore.id);
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) {
try {
el.focus({ preventScroll: true });
if (restore.start !== null && restore.end !== null && typeof el.setSelectionRange === "function") {
el.setSelectionRange(restore.start, restore.end);
}
} catch (_) {
// ignore
}
}
}
}
_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;
}
_isFavoriteRepo(repoId) {
const id = this._safeId(repoId);
return !!id && Array.isArray(this._favoriteRepoIds) && this._favoriteRepoIds.includes(id);
}
async _toggleFavorite(repoId) {
if (!this._hass || !repoId) return;
const id = this._safeId(repoId);
const current = Array.isArray(this._favoriteRepoIds) ? this._favoriteRepoIds.slice() : [];
const next = current.includes(id)
? current.filter((x) => x !== id)
: current.concat([id]);
this._favoriteRepoIds = next;
if (Array.isArray(this._data?.repos)) {
this._data.repos = this._data.repos.map((r) => {
if (this._safeId(r?.id) !== id) return r;
return { ...r, favorite: next.includes(id) };
});
}
if (this._detailRepo && this._safeId(this._detailRepo?.id) === id) {
this._detailRepo = { ...this._detailRepo, favorite: next.includes(id) };
}
this._update();
await this._setSettings({ favorite_repo_ids: next });
await this._load();
}
_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;
// Source filter
if (this._sourceFilter === "bcs" && r?.source !== "index") return false;
if (this._sourceFilter === "hacs" && r?.source !== "hacs") return false;
if (this._sourceFilter === "custom" && r?.source !== "custom") return false;
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 favorite = this._asBoolStrict(r?.favorite) || this._isFavoriteRepo(r?.id);
if (this._filter === "installed" && !installed) return false;
if (this._filter === "not_installed" && installed) return false;
if (this._filter === "updates" && !updateAvailable) return false;
if (this._filter === "custom" && r?.source !== "custom") return false;
if (this._filter === "favorites" && !favorite) return false;
return true;
})
.sort((a, b) => {
const an = (this._safeText(a?.name) || "").toLowerCase();
const bn = (this._safeText(b?.name) || "").toLowerCase();
const alatest = this._safeText(a?.latest_version);
const ainstalled = this._asBoolStrict(a?.installed);
const ainstalledVersion = this._safeText(a?.installed_version);
const aupdate = ainstalled && !!alatest && (!ainstalledVersion || alatest !== ainstalledVersion);
const afavorite = this._asBoolStrict(a?.favorite) || this._isFavoriteRepo(a?.id);
const blatest = this._safeText(b?.latest_version);
const binstalled = this._asBoolStrict(b?.installed);
const binstalledVersion = this._safeText(b?.installed_version);
const bupdate = binstalled && !!blatest && (!binstalledVersion || blatest !== binstalledVersion);
const bfavorite = this._asBoolStrict(b?.favorite) || this._isFavoriteRepo(b?.id);
if (this._sort === "updates_first") {
if (aupdate !== bupdate) return aupdate ? -1 : 1;
return an.localeCompare(bn);
}
if (this._sort === "favorites_first") {
if (afavorite !== bfavorite) return afavorite ? -1 : 1;
return an.localeCompare(bn);
}
if (this._sort === "installed_first") {
if (ainstalled !== binstalled) return ainstalled ? -1 : 1;
return an.localeCompare(bn);
}
return an.localeCompare(bn);
});
const categories = Array.from(
new Set(repos.map((r) => this._safeText(r?.category)).filter((c) => !!c))
).sort();
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 favorite = this._asBoolStrict(r?.favorite) || this._isFavoriteRepo(r?.id);
const badges = [];
// Source badges
if (r?.source === "index") badges.push("BCS Official");
else if (r?.source === "hacs") badges.push("HACS");
else if (r?.source === "custom") badges.push("Custom");
if (favorite) badges.push("Pinned");
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…" value="${this._esc(this._search)}" />
<label class="toggle" title="Show official HACS repositories">
<input id="hacs_toggle" type="checkbox" ${this._hacsEnabled ? "checked" : ""} />
<span>HACS</span>
</label>
<select id="src">
<option value="all" ${this._sourceFilter === "all" ? "selected" : ""}>All sources</option>
<option value="bcs" ${this._sourceFilter === "bcs" ? "selected" : ""}>BCS Official</option>
<option value="hacs" ${this._sourceFilter === "hacs" ? "selected" : ""}>HACS</option>
<option value="custom" ${this._sourceFilter === "custom" ? "selected" : ""}>Custom</option>
</select>
<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="filter">
<option value="all" ${this._filter === "all" ? "selected" : ""}>All</option>
<option value="favorites" ${this._filter === "favorites" ? "selected" : ""}>Pinned</option>
<option value="installed" ${this._filter === "installed" ? "selected" : ""}>Installed</option>
<option value="not_installed" ${this._filter === "not_installed" ? "selected" : ""}>Not installed</option>
<option value="updates" ${this._filter === "updates" ? "selected" : ""}>Updates available</option>
<option value="custom" ${this._filter === "custom" ? "selected" : ""}>Custom repos</option>
</select>
<select id="sort">
<option value="az" ${this._sort === "az" ? "selected" : ""}>AZ</option>
<option value="favorites_first" ${this._sort === "favorites_first" ? "selected" : ""}>Pinned first</option>
<option value="updates_first" ${this._sort === "updates_first" ? "selected" : ""}>Updates first</option>
<option value="installed_first" ${this._sort === "installed_first" ? "selected" : ""}>Installed first</option>
</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 filter = root.getElementById("filter");
const sort = root.getElementById("sort");
const src = root.getElementById("src");
const hacsToggle = root.getElementById("hacs_toggle");
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 (filter) {
filter.addEventListener("change", (e) => {
this._filter = e?.target?.value || "all";
this._update();
});
}
if (sort) {
sort.addEventListener("change", (e) => {
this._sort = e?.target?.value || "az";
this._update();
});
}
if (src) {
src.addEventListener("change", (e) => {
this._sourceFilter = e?.target?.value || "all";
this._update();
});
}
if (hacsToggle) {
hacsToggle.addEventListener("change", async (e) => {
const enabled = !!e?.target?.checked;
this._hacsEnabled = enabled;
this._update();
await this._setSettings({ hacs_enabled: enabled });
await this._load();
});
}
root.querySelectorAll("[data-open]").forEach((el) => {
const id = el.getAttribute("data-open");
el.addEventListener("click", () => this._openRepoDetail(id, true));
});
}
_pushHistory(state) {
if (this._handlingPopstate) return;
try {
window.history.pushState({ __bcs: true, ...(state || {}) }, "");
} catch (e) {
// ignore
}
}
_closeDetail(pushHistory = true) {
this._view = "store";
this._detailRepoId = null;
this._detailRepo = null;
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = null;
this._readmeExpanded = false;
this._readmeCanToggle = false;
if (pushHistory) this._pushHistory({ view: "store" });
this._update();
}
_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 repoId = this._safeId(r?.id) || this._detailRepoId || "";
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?.install_target) ? `Target: ${this._safeText(r?.install_target)}` : 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…</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 class="readmeWrap ${this._readmeExpanded ? "expanded" : "collapsed"}">
<div id="readmePretty" class="md"></div>
</div>
${this._readmeCanToggle ? `
<div class="readmeActions">
<button class="link" id="btnReadmeToggle">${this._readmeExpanded ? "Show less" : "Show more"}</button>
</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 installed = this._asBoolStrict(r?.installed);
const installedVersion = this._safeText(r?.installed_version);
const installedDomains = Array.isArray(r?.installed_domains) ? r.installed_domains : [];
const installedPaths = Array.isArray(r?.installed_paths) ? r.installed_paths : [];
const latestVersion = this._safeText(r?.latest_version);
const favorite = this._asBoolStrict(r?.favorite) || this._isFavoriteRepo(repoId);
const installType = this._safeText(r?.install_type) || "integration";
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 versions = Array.isArray(this._versionsCache?.[repoId]) ? this._versionsCache[repoId] : [];
const versionsLoading = this._versionsLoadingRepoId === repoId;
const selectedRef = this._safeText(this._selectedVersionByRepoId?.[repoId] || "").trim();
let versionOptions = `<option value="">Latest (recommended)</option>`;
if (selectedRef && !versions.some((v) => this._safeText(v?.ref) === selectedRef)) {
versionOptions += `<option value="${this._esc(selectedRef)}" selected>Selected: ${this._esc(selectedRef)}</option>`;
}
for (const v of versions) {
const ref = this._safeText(v?.ref);
if (!ref) continue;
const label = this._safeText(v?.label) || ref;
const sel = selectedRef === ref ? "selected" : "";
versionOptions += `<option value="${this._esc(ref)}" ${sel}>${this._esc(label)}</option>`;
}
const versionSelect = `
<div style="margin-top:12px;">
<div class="muted small" style="margin-bottom:6px;"><strong>Install version:</strong></div>
<select id="selVersion" ${busy ? "disabled" : ""} style="width:100%;">
${versionOptions}
</select>
${versionsLoading ? `<div class="muted small" style="margin-top:6px;">Loading versions…</div>` : ``}
</div>
`;
const releaseNotesBlock = this._releaseNotesLoading
? `<div class="card" style="margin-top:12px;">Loading release notes...</div>`
: this._releaseNotesText
? `
<div class="card" style="margin-top:12px;">
<div class="row" style="align-items:center;">
<div><strong>Release Notes</strong></div>
<div class="muted small">${this._esc(selectedRef || latestVersion || "-")}</div>
</div>
<div id="releaseNotesPretty" class="md" style="margin-top:12px;"></div>
<details>
<summary>Show raw release notes</summary>
<div style="margin-top:10px;">
<pre class="readme">${this._esc(this._releaseNotesText)}</pre>
</div>
</details>
</div>
`
: `
<div class="card" style="margin-top:12px;">
<div><strong>Release Notes</strong></div>
<div class="muted" style="margin-top:8px;">${this._esc(this._releaseNotesError || "Release notes not available for this version.")}</div>
</div>
`;
const installBtn = `<button class="primary" id="btnInstall" ${installed || busy ? "disabled" : ""}>${busyInstall ? "Installing…" : installed ? "Installed" : "Install"}</button>`;
const updateBtn = `<button class="primary" id="btnUpdate" ${!updateAvailable || busy ? "disabled" : ""}>${busyUpdate ? "Updating…" : updateAvailable ? "Update" : "Up to date"}</button>`;
const uninstallBtn = `<button class="primary" id="btnUninstall" ${!installed || busy ? "disabled" : ""}>${busyUninstall ? "Uninstalling…" : "Uninstall"}</button>`;
const restoreBtn = installType === "integration"
? `<button class="primary" id="btnRestore" ${!installed || busy ? "disabled" : ""}>Restore</button>`
: ``;
const favoriteBtn = `<button id="btnFavorite">${favorite ? "Unpin" : "Pin"}</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 style="margin-top:6px;"><strong>Installed paths:</strong> ${installedPaths.length ? this._esc(installedPaths.join(", ")) : "-"}</div>
</div>
${versionSelect}
${releaseNotesBlock}
<div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;">
${favoriteBtn}
${installBtn}
${updateBtn}
${uninstallBtn}
${restoreBtn}
</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 btnRestore = root.getElementById("btnRestore");
const btnRestart = root.getElementById("btnRestart");
const btnReadmeToggle = root.getElementById("btnReadmeToggle");
const selVersion = root.getElementById("selVersion");
const btnFavorite = root.getElementById("btnFavorite");
if (btnFavorite) {
btnFavorite.addEventListener("click", () => {
if (this._detailRepoId) this._toggleFavorite(this._detailRepoId);
});
}
if (btnInstall) {
btnInstall.addEventListener("click", () => {
if (btnInstall.disabled) return;
if (this._detailRepoId) this._installRepo(this._detailRepoId);
});
}
if (selVersion) {
selVersion.addEventListener("change", () => {
if (!this._detailRepoId) return;
const v = selVersion.value != null ? String(selVersion.value) : "";
this._selectedVersionByRepoId[this._detailRepoId] = v;
this._loadReleaseNotes(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 (btnRestore) {
btnRestore.addEventListener("click", () => {
if (btnRestore.disabled) return;
if (this._detailRepoId) this._openRestore(this._detailRepoId);
});
}
if (btnRestart) {
btnRestart.addEventListener("click", () => this._restartHA());
}
if (btnReadmeToggle) {
btnReadmeToggle.addEventListener("click", () => {
this._readmeExpanded = !this._readmeExpanded;
this._update();
});
}
const mount = root.getElementById("readmePretty");
if (!mount) {
const releaseMount = root.getElementById("releaseNotesPretty");
if (releaseMount) {
if (this._releaseNotesText) {
if (this._releaseNotesHtml) {
releaseMount.innerHTML = this._releaseNotesHtml;
this._postprocessRenderedMarkdown(releaseMount);
} else {
releaseMount.innerHTML = `<div class="muted">Rendered HTML not available. Use "Show raw release notes".</div>`;
}
} else {
releaseMount.innerHTML = "";
}
}
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 “Show raw Markdown”.</div>`;
}
} else {
mount.innerHTML = "";
}
const releaseMount = root.getElementById("releaseNotesPretty");
if (releaseMount) {
if (this._releaseNotesText) {
if (this._releaseNotesHtml) {
releaseMount.innerHTML = this._releaseNotesHtml;
this._postprocessRenderedMarkdown(releaseMount);
} else {
releaseMount.innerHTML = `<div class="muted">Rendered HTML not available. Use "Show raw release notes".</div>`;
}
} else {
releaseMount.innerHTML = "";
}
}
}
_wireRestoreModal() {
const root = this.shadowRoot;
if (!root) return;
const overlay = root.getElementById("restoreOverlay");
if (!overlay) return;
// Click outside modal closes it
overlay.addEventListener("click", (ev) => {
if (ev.target === overlay) this._closeRestore();
});
const sel = root.getElementById("restoreSelect");
if (sel) {
try {
if (this._restoreSelected) sel.value = String(this._restoreSelected);
} catch (_) {}
sel.addEventListener("change", () => {
this._restoreSelected = String(sel.value || "");
});
}
const btnCancel = root.getElementById("btnRestoreCancel");
if (btnCancel) {
btnCancel.addEventListener("click", () => this._closeRestore());
}
const btnApply = root.getElementById("btnRestoreApply");
if (btnApply) {
btnApply.addEventListener("click", () => {
if (btnApply.disabled) return;
const v = root.getElementById("restoreSelect")?.value;
if (v) this._restoreSelected = String(v);
this._restoreSelectedBackup();
});
}
}
_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 (_) {}
}
_renderRestoreModal() {
if (!this._restoreOpen) return "";
const opts = Array.isArray(this._restoreOptions) ? this._restoreOptions : [];
const disabled = this._restoreLoading || this._restoring || !opts.length;
const optionsHtml = opts
.map((o) => {
const id = this._safeText(o?.id) || "";
const label = this._safeText(o?.label) || id;
return `<option value="${this._esc(id)}">${this._esc(label)}</option>`;
})
.join("");
const msg = this._restoreLoading
? "Loading backups…"
: this._restoreError
? this._safeText(this._restoreError)
: opts.length
? "Select a backup to restore. This will overwrite files under /config/custom_components and requires a restart."
: "No backups found.";
return `
<div class="modalOverlay" id="restoreOverlay">
<div class="modal">
<div class="modalTitle">Restore from backup</div>
<div class="muted" style="margin-top:8px;">${this._esc(msg)}</div>
<div style="margin-top:14px;">
<label class="muted small" for="restoreSelect">Backup</label><br/>
<select id="restoreSelect" ${disabled ? "disabled" : ""} style="width:100%; margin-top:6px;">
${optionsHtml}
</select>
</div>
<div class="row" style="margin-top:16px; justify-content:flex-end; gap:10px;">
<button id="btnRestoreCancel">Cancel</button>
<button class="primary" id="btnRestoreApply" ${disabled ? "disabled" : ""}>${this._restoring ? "Restoring…" : "Restore"}</button>
</div>
</div>
</div>
`;
}
_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);