custom_components/bahmcloud_store/panel/panel.js aktualisiert
This commit is contained in:
@@ -1,24 +1,472 @@
|
||||
class BahmcloudStorePanel extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
|
||||
this._hass = null;
|
||||
this._view = "store"; // store | manage | about
|
||||
this._data = null;
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
|
||||
this._customAddUrl = "";
|
||||
this._customAddName = "";
|
||||
}
|
||||
|
||||
set hass(hass) {
|
||||
if (this._rendered) return;
|
||||
this._rendered = true;
|
||||
this._hass = hass;
|
||||
if (!this._rendered) {
|
||||
this._rendered = true;
|
||||
this._render();
|
||||
this._load();
|
||||
}
|
||||
}
|
||||
|
||||
const root = this.attachShadow({ mode: "open" });
|
||||
const iframe = document.createElement("iframe");
|
||||
async _load() {
|
||||
if (!this._hass) return;
|
||||
|
||||
iframe.src = "/api/bahmcloud_store_static/index.html";
|
||||
iframe.style.width = "100%";
|
||||
iframe.style.height = "100%";
|
||||
iframe.style.border = "0";
|
||||
iframe.style.display = "block";
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
this._update();
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
:host { display: block; height: 100vh; }
|
||||
try {
|
||||
const data = await this._hass.callApi("get", "bcs");
|
||||
this._data = data;
|
||||
} catch (e) {
|
||||
this._error = e?.message ? String(e.message) : String(e);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
this._update();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
_render() {
|
||||
const root = this.shadowRoot;
|
||||
|
||||
root.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
--bcs-accent: #1E88E5; /* Bahmcloud Blue (v1 default) */
|
||||
}
|
||||
|
||||
.wrap {
|
||||
padding: 16px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 13px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--divider-color);
|
||||
background: var(--card-background-color);
|
||||
color: var(--primary-text-color);
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
border-color: var(--bcs-accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bcs-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 9px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--divider-color);
|
||||
background: var(--card-background-color);
|
||||
color: var(--primary-text-color);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
border-color: var(--bcs-accent);
|
||||
background: color-mix(in srgb, var(--bcs-accent) 16%, var(--card-background-color));
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--divider-color);
|
||||
background: var(--card-background-color);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.badge.custom {
|
||||
border-color: var(--bcs-accent);
|
||||
color: var(--bcs-accent);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b00020;
|
||||
white-space: pre-wrap;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--divider-color);
|
||||
background: var(--card-background-color);
|
||||
color: var(--primary-text-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--bcs-accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bcs-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--bcs-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="topbar">
|
||||
<div class="brand">
|
||||
<div>
|
||||
<div class="title">Bahmcloud Store</div>
|
||||
<div class="subtitle">BCS 0.2.0 — foundation build</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="refreshBtn">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab" data-view="store">Store</div>
|
||||
<div class="tab" data-view="manage">Manage repositories</div>
|
||||
<div class="tab" data-view="about">Settings / About</div>
|
||||
</div>
|
||||
|
||||
<div id="content"></div>
|
||||
<div id="error" class="error"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
root.appendChild(style);
|
||||
root.appendChild(iframe);
|
||||
root.getElementById("refreshBtn").addEventListener("click", () => this._load());
|
||||
|
||||
for (const tab of root.querySelectorAll(".tab")) {
|
||||
tab.addEventListener("click", () => {
|
||||
this._view = tab.getAttribute("data-view");
|
||||
this._update();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_update() {
|
||||
const root = this.shadowRoot;
|
||||
const content = root.getElementById("content");
|
||||
const err = root.getElementById("error");
|
||||
|
||||
for (const tab of root.querySelectorAll(".tab")) {
|
||||
tab.classList.toggle("active", tab.getAttribute("data-view") === this._view);
|
||||
}
|
||||
|
||||
err.textContent = this._error ? `Error: ${this._error}` : "";
|
||||
|
||||
if (this._loading) {
|
||||
content.innerHTML = `<div class="card">Loading…</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._data) {
|
||||
content.innerHTML = `<div class="card">No data.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._view === "store") {
|
||||
content.innerHTML = this._renderStore();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._view === "manage") {
|
||||
content.innerHTML = this._renderManage();
|
||||
this._wireManage();
|
||||
return;
|
||||
}
|
||||
|
||||
content.innerHTML = this._renderAbout();
|
||||
}
|
||||
|
||||
_renderStore() {
|
||||
const repos = Array.isArray(this._data.repos) ? this._data.repos : [];
|
||||
|
||||
const rows = repos.map((r) => {
|
||||
const badge = r.source === "custom"
|
||||
? `<span class="badge custom">Custom</span>`
|
||||
: `<span class="badge">Index</span>`;
|
||||
|
||||
const owner = r.owner ? `Owner: ${this._esc(r.owner)}` : "Owner: -";
|
||||
const provider = r.provider ? `Provider: ${this._esc(r.provider)}` : "Provider: -";
|
||||
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div>
|
||||
<div><strong>${this._esc(r.name)}</strong></div>
|
||||
<div class="muted">${this._esc(r.description || "Description will be loaded in a later version.")}</div>
|
||||
<div class="muted small">${owner} · ${provider}</div>
|
||||
<div class="muted small"><a href="${this._esc(r.url)}" target="_blank" rel="noreferrer">Open repository</a></div>
|
||||
</div>
|
||||
${badge}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<div class="card">
|
||||
<div><strong>Store</strong></div>
|
||||
<div class="muted small">Index URL: ${this._esc(this._data.store_url || "-")}</div>
|
||||
<div class="muted small">Refresh seconds: ${this._esc(String(this._data.refresh_seconds || "-"))}</div>
|
||||
</div>
|
||||
|
||||
${rows || `<div class="card">No repositories configured.</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) => {
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div>
|
||||
<div><strong>${this._esc(r.name)}</strong></div>
|
||||
<div class="muted">${this._esc(r.url)}</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="primary" data-remove="${this._esc(r.id)}">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<div class="card">
|
||||
<div><strong>Manage repositories</strong></div>
|
||||
<div class="muted">Add public repositories from any git provider.</div>
|
||||
|
||||
<div class="grid" style="margin-top: 12px;">
|
||||
<div class="field">
|
||||
<label class="muted small">Repository URL</label>
|
||||
<input id="addUrl" placeholder="https://github.com/user/repo" value="${this._esc(this._customAddUrl)}" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="muted small">Display name (optional)</label>
|
||||
<input id="addName" placeholder="My Integration" value="${this._esc(this._customAddName)}" />
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="addBtn" class="primary">Add repository</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${list || `<div class="card">No custom repositories added yet.</div>`}
|
||||
`;
|
||||
}
|
||||
|
||||
_wireManage() {
|
||||
const root = this.shadowRoot;
|
||||
|
||||
const addUrl = root.getElementById("addUrl");
|
||||
const addName = root.getElementById("addName");
|
||||
const addBtn = root.getElementById("addBtn");
|
||||
|
||||
if (addUrl) {
|
||||
addUrl.addEventListener("input", (e) => {
|
||||
this._customAddUrl = e.target.value;
|
||||
});
|
||||
}
|
||||
if (addName) {
|
||||
addName.addEventListener("input", (e) => {
|
||||
this._customAddName = e.target.value;
|
||||
});
|
||||
}
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener("click", () => this._addCustomRepo());
|
||||
}
|
||||
|
||||
for (const btn of root.querySelectorAll("[data-remove]")) {
|
||||
btn.addEventListener("click", () => {
|
||||
const id = btn.getAttribute("data-remove");
|
||||
if (id) this._removeCustomRepo(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_renderAbout() {
|
||||
return `
|
||||
<div class="card">
|
||||
<div><strong>Settings / About</strong></div>
|
||||
<div class="muted">Language: English (v1). i18n will be added later.</div>
|
||||
<div class="muted">Theme: follows Home Assistant light/dark automatically.</div>
|
||||
<div class="muted">Accent: Bahmcloud Blue.</div>
|
||||
<div class="muted small" style="margin-top: 10px;">BCS version: ${this._esc(this._data.version || "-")}</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div><strong>Roadmap</strong></div>
|
||||
<div class="muted">
|
||||
Next versions will add: repo metadata (bcs.yaml / hacs.*), README view, install/uninstall, update entities.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_esc(s) {
|
||||
return String(s ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user