1727 lines
58 KiB
JavaScript
1727 lines
58 KiB
JavaScript
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("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
_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" : ""}>A–Z</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);
|