@@ -0,0 +1,946 @@
class BahmcloudStorePanel extends HTMLElement {
constructor ( ) {
super ( ) ;
this . attachShadow ( { mode : "open" } ) ;
this . _hass = null ;
this . _view = "store" ; // store | manage | about | detail
this . _data = null ;
this . _loading = true ;
this . _error = null ;
this . _customAddUrl = "" ;
this . _customAddName = "" ;
this . _search = "" ;
this . _category = "all" ;
this . _provider = "all" ; // all|github|gitea|gitlab|other|custom
this . _detailRepoId = null ;
this . _detailRepo = null ;
this . _readmeLoading = false ;
this . _readmeText = null ;
this . _readmeHtml = null ;
this . _readmeError = null ;
}
set hass ( hass ) {
this . _hass = hass ;
if ( ! this . _rendered ) {
this . _rendered = true ;
this . _render ( ) ;
this . _load ( ) ;
}
}
async _load ( ) {
if ( ! this . _hass ) return ;
this . _loading = true ;
this . _error = null ;
this . _update ( ) ;
try {
const data = await this . _hass . callApi ( "get" , "bcs" ) ;
this . _data = data ;
} catch ( e ) {
this . _error = e ? . message ? String ( e . message ) : String ( e ) ;
} finally {
this . _loading = false ;
this . _update ( ) ;
}
}
_isDesktop ( ) {
return window . matchMedia && window . matchMedia ( "(min-width: 1024px)" ) . matches ;
}
_toggleMenu ( ) {
if ( this . _isDesktop ( ) ) return ;
try {
const ev = new Event ( "hass-toggle-menu" , { bubbles : true , composed : true } ) ;
this . dispatchEvent ( ev ) ;
} catch ( _ ) { }
}
_goBack ( ) {
if ( this . _view === "detail" ) {
this . _view = "store" ;
this . _detailRepoId = null ;
this . _detailRepo = null ;
this . _readmeText = null ;
this . _readmeHtml = null ;
this . _readmeError = null ;
this . _update ( ) ;
return ;
}
try {
history . back ( ) ;
} catch ( _ ) {
window . location . href = "/" ;
}
}
async _addCustomRepo ( ) {
if ( ! this . _hass ) return ;
const url = ( this . _customAddUrl || "" ) . trim ( ) ;
const name = ( this . _customAddName || "" ) . trim ( ) || null ;
if ( ! url ) {
this . _error = "Please enter a repository URL." ;
this . _update ( ) ;
return ;
}
this . _error = null ;
this . _update ( ) ;
try {
await this . _hass . callApi ( "post" , "bcs" , { op : "add_custom_repo" , url , name } ) ;
this . _customAddUrl = "" ;
this . _customAddName = "" ;
await this . _load ( ) ;
this . _view = "manage" ;
this . _update ( ) ;
} catch ( e ) {
this . _error = e ? . message ? String ( e . message ) : String ( e ) ;
this . _update ( ) ;
}
}
async _removeCustomRepo ( id ) {
if ( ! this . _hass ) return ;
try {
await this . _hass . callApi ( "delete" , ` bcs/custom_repo?id= ${ encodeURIComponent ( id ) } ` ) ;
await this . _load ( ) ;
this . _view = "manage" ;
this . _update ( ) ;
} catch ( e ) {
this . _error = e ? . message ? String ( e . message ) : String ( e ) ;
this . _update ( ) ;
}
}
_openRepoDetail ( repoId ) {
const repos = Array . isArray ( this . _data ? . repos ) ? this . _data . repos : [ ] ;
const repo = repos . find ( ( r ) => this . _safeId ( r ? . id ) === repoId ) ;
if ( ! repo ) return ;
this . _view = "detail" ;
this . _detailRepoId = repoId ;
this . _detailRepo = repo ;
this . _readmeText = null ;
this . _readmeHtml = null ;
this . _readmeError = null ;
this . _update ( ) ;
this . _loadReadme ( repoId ) ;
}
async _loadReadme ( repoId ) {
if ( ! this . _hass ) return ;
this . _readmeLoading = true ;
this . _readmeError = null ;
this . _update ( ) ;
try {
const resp = await this . _hass . callApi (
"get" ,
` bcs/readme?repo_id= ${ encodeURIComponent ( repoId ) } `
) ;
if ( resp ? . ok && typeof resp . readme === "string" && resp . readme . trim ( ) ) {
this . _readmeText = resp . readme ;
this . _readmeHtml = typeof resp . html === "string" && resp . html . trim ( ) ? resp . html : null ;
} else {
this . _readmeText = null ;
this . _readmeHtml = null ;
this . _readmeError = this . _safeText ( resp ? . message ) || "README not found." ;
}
} catch ( e ) {
this . _readmeText = null ;
this . _readmeHtml = null ;
this . _readmeError = e ? . message ? String ( e . message ) : String ( e ) ;
} finally {
this . _readmeLoading = false ;
this . _update ( ) ;
}
}
_render ( ) {
const root = this . shadowRoot ;
root . innerHTML = `
<style>
:host { display:block; min-height:100%; --bcs-accent:#1E88E5; }
.mobilebar{
position:sticky; top:0; z-index:50;
display:flex; align-items:center; justify-content:space-between;
gap:10px; padding:10px 12px;
background: var(--app-header-background-color, var(--card-background-color));
color: var(--app-header-text-color, var(--primary-text-color));
border-bottom:1px solid var(--divider-color);
}
.mobilebar .left, .mobilebar .right { display:flex; align-items:center; gap:10px; }
.iconbtn{
width:40px; height:40px; border-radius:14px;
border:1px solid var(--divider-color);
background: color-mix(in srgb, var(--card-background-color) 82%, transparent);
color:inherit; display:inline-flex; align-items:center; justify-content:center;
cursor:pointer; user-select:none; font-weight:900; font-size:18px; line-height:1;
}
.iconbtn:hover{ box-shadow: 0 10px 30px rgba(0,0,0,0.10); transform: translateY(-1px); }
.iconbtn:active{ transform: translateY(0px); box-shadow:none; }
@media (min-width: 1024px) { .iconbtn.menu { display:none; } }
.brandtitle{ display:flex; flex-direction:column; line-height:1.2; }
.brandtitle .t{ font-size:16px; font-weight:900; letter-spacing: .2px; }
.brandtitle .s{ font-size:12px; color:var(--secondary-text-color); margin-top:2px; }
.wrap{
padding:16px; max-width:1100px; margin:0 auto;
font-family: system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
color:var(--primary-text-color);
}
.tabs{ display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; }
.tab{
border:1px solid var(--divider-color);
background:var(--card-background-color);
color:var(--primary-text-color);
padding:8px 12px; border-radius:999px;
cursor:pointer; font-weight:800; font-size:13px;
}
.tab.active{
border-color:var(--bcs-accent);
box-shadow:0 0 0 2px color-mix(in srgb, var(--bcs-accent) 20%, transparent);
}
button{
padding:10px 12px; border-radius:14px;
border:1px solid var(--divider-color);
background:var(--card-background-color);
color:var(--primary-text-color);
cursor:pointer; font-weight:900;
}
button.primary{
border-color:var(--bcs-accent);
background: color-mix(in srgb, var(--bcs-accent) 16%, var(--card-background-color));
}
button:hover{ box-shadow: 0 10px 30px rgba(0,0,0,0.10); transform: translateY(-1px); }
button:active{ transform: translateY(0px); box-shadow:none; }
button:disabled{ opacity: 0.55; cursor: not-allowed; }
.card{
border:1px solid var(--divider-color);
background:var(--card-background-color);
border-radius:18px; padding:12px; margin:10px 0;
}
.card.clickable{
cursor:pointer;
transition: transform 120ms ease, box-shadow 120ms ease;
}
.card.clickable:hover{
transform: translateY(-1px);
box-shadow: 0 12px 34px rgba(0,0,0,0.10);
}
.row{ display:flex; justify-content:space-between; gap:10px; align-items:flex-start; }
.muted{ color:var(--secondary-text-color); font-size:13px; margin-top:4px; }
.small{ font-size:12px; }
.badge{
border:1px solid var(--divider-color);
border-radius:999px; padding:2px 10px;
font-size:12px; font-weight:900; height:fit-content;
}
.badge.custom{ border-color:var(--bcs-accent); color:var(--bcs-accent); }
.error{ color:#b00020; white-space:pre-wrap; margin-top:10px; }
.grid2{ display:grid; grid-template-columns: 1fr; gap: 12px; }
@media (min-width: 900px){ .grid2{ grid-template-columns: 1.2fr 0.8fr; } }
.filters{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:12px; }
.chips{ display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; }
.chip{
display:inline-flex; align-items:center; gap:8px;
padding:6px 10px; border-radius:999px;
border:1px solid var(--divider-color);
background:var(--card-background-color);
cursor:pointer; user-select:none; font-weight:800; font-size:12px;
}
.chip strong{ font-size:12px; }
.chip.active{
border-color:var(--bcs-accent);
box-shadow:0 0 0 2px color-mix(in srgb, var(--bcs-accent) 18%, transparent);
}
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 color-mix(in srgb, var(--bcs-accent) 20%, transparent);
}
a{ color:var(--bcs-accent); text-decoration:none; }
a:hover{ text-decoration:underline; }
.fabs{
position: fixed;
right: 18px;
bottom: 18px;
display: grid;
gap: 10px;
z-index: 100;
}
.fab{
width: 56px;
height: 56px;
border-radius: 18px;
border: 1px solid var(--divider-color);
background: var(--card-background-color);
box-shadow: 0 12px 30px rgba(0,0,0,0.14);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 900;
cursor: pointer;
user-select: none;
}
.fab.primary{
border-color: var(--bcs-accent);
background: color-mix(in srgb, var(--bcs-accent) 18%, var(--card-background-color));
}
.fab[disabled]{ opacity: .55; cursor: not-allowed; }
pre.readme{
white-space: pre-wrap;
word-break: break-word;
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12.5px;
line-height: 1.5;
}
details{ margin-top: 10px; }
summary{ cursor:pointer; color: var(--bcs-accent); font-weight: 900; }
.md { line-height: 1.65; font-size: 14px; }
.md :is(h1,h2,h3){ margin: 18px 0 10px; }
.md :is(p,ul,ol,pre,blockquote,table){ margin: 10px 0; }
.md pre { overflow:auto; padding:12px; border-radius:14px; border:1px solid var(--divider-color); }
.md code { padding:2px 6px; border-radius:8px; border:1px solid var(--divider-color); }
.md blockquote { border-left:4px solid var(--bcs-accent); padding:8px 12px; border-radius:12px;
background: color-mix(in srgb, var(--bcs-accent) 8%, var(--card-background-color)); }
.md table{ width:100%; border-collapse: collapse; }
.md th,.md td{ border:1px solid var(--divider-color); padding:8px; text-align:left; }
.md img{ max-width:100%; height:auto; border-radius:12px; }
</style>
<div class="mobilebar">
<div class="left">
<div class="iconbtn menu" id="menuBtn" title="Menu">☰</div>
<div class="iconbtn" id="backBtn" title="Back">←</div>
<div class="brandtitle">
<div class="t">Bahmcloud Store</div>
<div class="s" id="subtitle">BCS — loading…</div>
</div>
</div>
<div class="right">
<button id="refreshBtn" class="primary">Refresh</button>
</div>
</div>
<div class="wrap">
<div class="tabs" id="tabs">
<div class="tab" data-view="store">Store</div>
<div class="tab" data-view="manage">Manage repositories</div>
<div class="tab" data-view="about">Settings / About</div>
</div>
<div id="content"></div>
<div id="error" class="error"></div>
</div>
<div id="fabs"></div>
` ;
root . getElementById ( "refreshBtn" ) . addEventListener ( "click" , ( ) => this . _load ( ) ) ;
root . getElementById ( "menuBtn" ) . addEventListener ( "click" , ( ) => this . _toggleMenu ( ) ) ;
root . getElementById ( "backBtn" ) . addEventListener ( "click" , ( ) => this . _goBack ( ) ) ;
for ( const tab of root . querySelectorAll ( ".tab" ) ) {
tab . addEventListener ( "click" , ( ) => {
this . _view = tab . getAttribute ( "data-view" ) ;
this . _update ( ) ;
} ) ;
}
// prevent HA global shortcuts while typing
const stopIfFormField = ( e ) => {
const t = e . composedPath ? e . composedPath ( ) [ 0 ] : e . target ;
if ( ! t ) return ;
const tag = ( t . tagName || "" ) . toLowerCase ( ) ;
const isEditable =
tag === "input" || tag === "textarea" || tag === "select" || t . isContentEditable ;
if ( isEditable ) e . stopPropagation ( ) ;
} ;
root . addEventListener ( "keydown" , stopIfFormField , true ) ;
root . addEventListener ( "keyup" , stopIfFormField , true ) ;
root . addEventListener ( "keypress" , stopIfFormField , true ) ;
}
_captureFocusState ( ) {
const root = this . shadowRoot ;
const ae = root . activeElement ;
if ( ! ae || ! ae . id ) return null ;
const supported = new Set ( [ "searchInput" , "categorySelect" , "addUrl" , "addName" ] ) ;
if ( ! supported . has ( ae . id ) ) return null ;
return {
id : ae . id ,
value : ae . value ,
selectionStart : typeof ae . selectionStart === "number" ? ae . selectionStart : null ,
selectionEnd : typeof ae . selectionEnd === "number" ? ae . selectionEnd : null ,
} ;
}
_restoreFocusState ( state ) {
if ( ! state ) return ;
const root = this . shadowRoot ;
const el = root . getElementById ( state . id ) ;
if ( ! el ) return ;
try {
el . focus ( { preventScroll : true } ) ;
if (
state . selectionStart !== null &&
state . selectionEnd !== null &&
typeof el . setSelectionRange === "function"
) {
el . setSelectionRange ( state . selectionStart , state . selectionEnd ) ;
}
} catch ( _ ) { }
}
_update ( ) {
const root = this . shadowRoot ;
const focusState = this . _captureFocusState ( ) ;
const content = root . getElementById ( "content" ) ;
const err = root . getElementById ( "error" ) ;
const subtitle = root . getElementById ( "subtitle" ) ;
const fabs = root . getElementById ( "fabs" ) ;
const v = this . _safeText ( this . _data ? . version ) ;
subtitle . textContent = v ? ` BCS ${ v } ` : "BCS — loading…" ;
for ( const tab of root . querySelectorAll ( ".tab" ) ) {
tab . classList . toggle ( "active" , tab . getAttribute ( "data-view" ) === this . _view ) ;
}
err . textContent = this . _error ? ` Error: ${ this . _error } ` : "" ;
fabs . innerHTML = this . _view === "detail" ? this . _renderFabs ( ) : "" ;
this . _wireFabs ( ) ;
if ( this . _loading ) {
content . innerHTML = ` <div class="card">Loading…</div> ` ;
this . _restoreFocusState ( focusState ) ;
return ;
}
if ( ! this . _data ) {
content . innerHTML = ` <div class="card">No data.</div> ` ;
this . _restoreFocusState ( focusState ) ;
return ;
}
if ( this . _view === "store" ) {
content . innerHTML = this . _renderStore ( ) ;
this . _wireStore ( ) ;
this . _restoreFocusState ( focusState ) ;
return ;
}
if ( this . _view === "manage" ) {
content . innerHTML = this . _renderManage ( ) ;
this . _wireManage ( ) ;
this . _restoreFocusState ( focusState ) ;
return ;
}
if ( this . _view === "about" ) {
content . innerHTML = this . _renderAbout ( ) ;
this . _restoreFocusState ( focusState ) ;
return ;
}
content . innerHTML = this . _renderDetail ( ) ;
this . _wireDetail ( ) ;
this . _restoreFocusState ( focusState ) ;
}
_renderStore ( ) {
const repos = Array . isArray ( this . _data . repos ) ? this . _data . repos : [ ] ;
const categories = this . _computeCategories ( repos ) ;
const stats = this . _computeProviderStats ( repos ) ;
const filtered = repos
. filter ( ( r ) => {
const q = ( this . _search || "" ) . trim ( ) . toLowerCase ( ) ;
if ( ! q ) return true ;
const hay = ` ${ this . _safeText ( r ? . name ) } ${ this . _safeText ( r ? . description ) } ${ this . _safeText ( r ? . url ) } ${ this . _safeText ( r ? . owner ) } ` . toLowerCase ( ) ;
return hay . includes ( q ) ;
} )
. filter ( ( r ) => {
if ( this . _category === "all" ) return true ;
return this . _safeLower ( r ? . category ) === this . _category ;
} )
. filter ( ( r ) => {
if ( this . _provider === "all" ) return true ;
if ( this . _provider === "custom" ) return r ? . source === "custom" ;
return this . _safeLower ( r ? . provider ) === this . _provider ;
} ) ;
const options = [
` <option value="all" ${ this . _category === "all" ? " selected" : "" } >All categories</option> ` ,
... categories . map (
( c ) =>
` <option value=" ${ this . _esc ( c ) } " ${
this . _category === c ? " selected" : ""
} > ${ this . _esc ( c ) } </option> `
) ,
] . join ( "" ) ;
const providerChips = this . _renderProviderChips ( stats ) ;
const rows = filtered
. map ( ( r ) => {
const id = this . _safeId ( r ? . id ) ;
const name = this . _safeText ( r ? . name ) || "Unnamed repository" ;
const desc = this . _safeText ( r ? . description ) || "No description available." ;
const badge =
r ? . source === "custom"
? ` <span class="badge custom">Custom</span> `
: ` <span class="badge">Index</span> ` ;
const creator = this . _safeText ( r ? . owner ) ? ` Creator: ${ this . _safeText ( r ? . owner ) } ` : "Creator: -" ;
const latest = this . _safeText ( r ? . latest _version ) ? ` Latest: ${ this . _safeText ( r ? . latest _version ) } ` : "Latest: unknown" ;
const cat = this . _safeText ( r ? . category ) ? ` Category: ${ this . _safeText ( r ? . category ) } ` : null ;
const metaSrc = this . _safeText ( r ? . meta _source ) ? ` Meta: ${ this . _safeText ( r ? . meta _source ) } ` : null ;
const lineBits = [ creator , latest , cat , metaSrc ] . filter ( Boolean ) ;
return `
<div class="card clickable" data-repo=" ${ 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"> ${ this . _esc ( lineBits . join ( " · " ) ) } </div>
</div>
${ badge }
</div>
</div>
` ;
} )
. join ( "" ) ;
return `
<div class="card">
<div><strong>Providers</strong></div>
<div class="chips" id="providerChips"> ${ providerChips } </div>
</div>
<div class="filters">
<input id="searchInput" placeholder="Search repositories…" value=" ${ this . _esc ( this . _search ) } " />
<select id="categorySelect"> ${ options } </select>
</div>
${ rows || ` <div class="card">No repositories match this filter.</div> ` }
` ;
}
_wireStore ( ) {
const root = this . shadowRoot ;
const search = root . getElementById ( "searchInput" ) ;
const cat = root . getElementById ( "categorySelect" ) ;
const chips = root . getElementById ( "providerChips" ) ;
if ( search ) {
search . addEventListener ( "input" , ( e ) => {
this . _search = e . target . value ;
this . _update ( ) ;
} ) ;
}
if ( cat ) {
cat . addEventListener ( "change" , ( e ) => {
this . _category = String ( e . target . value || "all" ) ;
this . _update ( ) ;
} ) ;
}
if ( chips ) {
for ( const c of chips . querySelectorAll ( "[data-prov]" ) ) {
c . addEventListener ( "click" , ( ) => {
const key = c . getAttribute ( "data-prov" ) ;
if ( ! key ) return ;
this . _provider = key ;
this . _update ( ) ;
} ) ;
}
}
for ( const card of root . querySelectorAll ( "[data-repo]" ) ) {
card . addEventListener ( "click" , ( ) => {
const id = card . getAttribute ( "data-repo" ) ;
if ( id ) this . _openRepoDetail ( id ) ;
} ) ;
}
}
_renderProviderChips ( stats ) {
const order = [ "all" , "github" , "gitea" , "gitlab" , "other" , "custom" ] ;
const labels = {
all : "All" ,
github : "GitHub" ,
gitea : "Gitea" ,
gitlab : "GitLab" ,
other : "Other" ,
custom : "Custom" ,
} ;
const values = {
all : stats . total ,
github : stats . github ,
gitea : stats . gitea ,
gitlab : stats . gitlab ,
other : stats . other ,
custom : stats . custom ,
} ;
return order
. map ( ( key ) => {
const count = values [ key ] ? ? 0 ;
const active = this . _provider === key ? " active" : "" ;
return ` <div class="chip ${ active } " data-prov=" ${ key } "><strong> ${ labels [ key ] } </strong> ${ count } </div> ` ;
} )
. join ( "" ) ;
}
_computeProviderStats ( repos ) {
const s = { total : 0 , github : 0 , gitea : 0 , gitlab : 0 , other : 0 , custom : 0 } ;
if ( ! Array . isArray ( repos ) ) return s ;
for ( const r of repos ) {
s . total += 1 ;
if ( r ? . source === "custom" ) s . custom += 1 ;
const p = this . _safeLower ( r ? . provider ) || "other" ;
if ( p === "github" ) s . github += 1 ;
else if ( p === "gitea" ) s . gitea += 1 ;
else if ( p === "gitlab" ) s . gitlab += 1 ;
else s . other += 1 ;
}
return s ;
}
_renderDetail ( ) {
const r = this . _detailRepo ;
if ( ! r ) return ` <div class="card">Repository not found.</div> ` ;
const name = this . _safeText ( r ? . name ) || "Unnamed repository" ;
const desc = this . _safeText ( r ? . description ) || "No description available." ;
const url = this . _safeText ( r ? . url ) || "#" ;
const badge =
r ? . source === "custom"
? ` <span class="badge custom">Custom</span> `
: ` <span class="badge">Index</span> ` ;
const latest = this . _safeText ( r ? . latest _version ) ? ` Latest: ${ this . _safeText ( r ? . latest _version ) } ` : "Latest: unknown" ;
const infoBits = [
this . _safeText ( r ? . owner ) ? ` Creator: ${ this . _safeText ( r ? . owner ) } ` : "Creator: -" ,
latest ,
this . _safeText ( r ? . provider ) ? ` Provider: ${ this . _safeText ( r ? . provider ) } ` : null ,
this . _safeText ( r ? . category ) ? ` Category: ${ this . _safeText ( r ? . category ) } ` : null ,
this . _safeText ( r ? . meta _author ) ? ` Author: ${ this . _safeText ( r ? . meta _author ) } ` : null ,
this . _safeText ( r ? . meta _maintainer ) ? ` Maintainer: ${ this . _safeText ( r ? . meta _maintainer ) } ` : null ,
this . _safeText ( r ? . meta _source ) ? ` Meta: ${ this . _safeText ( r ? . meta _source ) } ` : null ,
] . filter ( Boolean ) ;
const readmeBlock = this . _readmeLoading
? ` <div class="card">Loading README…</div> `
: this . _readmeText
? `
<div class="card">
<div class="row" style="align-items:center;">
<div><strong>README</strong></div>
<div class="muted small">Rendered Markdown</div>
</div>
<div id="readmePretty" class="md" style="margin-top:12px;"></div>
<details>
<summary>Show raw Markdown</summary>
<div style="margin-top:10px;">
<pre class="readme"> ${ this . _esc ( this . _readmeText ) } </pre>
</div>
</details>
</div>
`
: `
<div class="card">
<div><strong>README</strong></div>
<div class="muted"> ${ this . _esc ( this . _readmeError || "README not found." ) } </div>
</div>
` ;
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>
${ badge }
</div>
</div>
${ readmeBlock }
</div>
<div>
<div class="card">
<div><strong>Installation & Updates</strong></div>
<div class="muted">
Installation and updates are performed manually via <strong>Settings → System → Updates</strong>.
The Store UI is used to browse repositories and trigger installation/update actions.
</div>
<div class="muted small" style="margin-top:10px;">
Updates remain manual (like HACS).
</div>
</div>
</div>
</div>
` ;
}
_wireDetail ( ) {
const root = this . shadowRoot ;
const mount = root . getElementById ( "readmePretty" ) ;
if ( ! mount ) return ;
if ( this . _readmeText ) {
// Client renderer may be unavailable; prefer server-provided HTML
if ( this . _readmeHtml ) {
mount . innerHTML = this . _readmeHtml ;
this . _postprocessRenderedMarkdown ( mount ) ;
return ;
}
mount . innerHTML = ` <div class="muted">Rendered HTML not available. Use “Show raw Markdown”.</div> ` ;
return ;
}
mount . innerHTML = "" ;
}
_postprocessRenderedMarkdown ( container ) {
if ( ! container ) return ;
try {
const links = container . querySelectorAll ( "a[href]" ) ;
links . forEach ( ( a ) => {
a . setAttribute ( "target" , "_blank" ) ;
a . setAttribute ( "rel" , "noreferrer noopener" ) ;
} ) ;
} catch ( _ ) { }
}
_renderFabs ( ) {
const r = this . _detailRepo ;
if ( ! r ) return "" ;
return `
<div class="fabs">
<div class="fab primary" id="fabOpen" title="Open repository">↗</div>
<div class="fab" id="fabReload" title="Reload README">⟳</div>
<div class="fab" id="fabInstall" title="Install (coming soon)" disabled>+ </div>
<div class="fab" id="fabUpdate" title="Update (coming soon)" disabled>↑</div>
<div class="fab" id="fabInfo" title="About">i</div>
</div>
` ;
}
_wireFabs ( ) {
const root = this . shadowRoot ;
const r = this . _detailRepo ;
if ( ! r ) return ;
const url = this . _safeText ( r ? . url ) ;
const open = root . getElementById ( "fabOpen" ) ;
const reload = root . getElementById ( "fabReload" ) ;
const info = root . getElementById ( "fabInfo" ) ;
if ( open ) open . addEventListener ( "click" , ( ) => url && window . open ( url , "_blank" , "noreferrer" ) ) ;
if ( reload ) {
reload . addEventListener ( "click" , ( ) => {
if ( this . _detailRepoId ) this . _loadReadme ( this . _detailRepoId ) ;
} ) ;
}
if ( info ) {
info . addEventListener ( "click" , ( ) => {
this . _view = "about" ;
this . _update ( ) ;
} ) ;
}
}
_renderManage ( ) {
const repos = Array . isArray ( this . _data . repos ) ? this . _data . repos : [ ] ;
const custom = repos . filter ( ( r ) => r ? . source === "custom" ) ;
const list = custom
. map ( ( r ) => {
const id = this . _safeId ( r ? . id ) ;
const name = this . _safeText ( r ? . name ) || "Unnamed repository" ;
const url = this . _safeText ( r ? . url ) || "" ;
return `
<div class="card">
<div class="row">
<div>
<div><strong> ${ this . _esc ( name ) } </strong></div>
<div class="muted"> ${ this . _esc ( url ) } </div>
</div>
<div>
<button class="primary" data-remove=" ${ this . _esc ( id ) } ">Remove</button>
</div>
</div>
</div>
` ;
} )
. join ( "" ) ;
return `
<div class="card">
<div><strong>Manage repositories</strong></div>
<div class="muted">Add public repositories from any git provider.</div>
<div style="display:grid; gap:10px; margin-top:12px;">
<div>
<div class="muted small">Repository URL</div>
<input id="addUrl" placeholder="https://github.com/user/repo" value=" ${ this . _esc ( this . _customAddUrl ) } " />
</div>
<div>
<div class="muted small">Display name (optional)</div>
<input id="addName" placeholder="My Integration" value=" ${ this . _esc ( this . _customAddName ) } " />
</div>
<div>
<button id="addBtn" class="primary">Add repository</button>
</div>
</div>
</div>
${ list || ` <div class="card">No custom repositories added yet.</div> ` }
` ;
}
_wireManage ( ) {
const root = this . shadowRoot ;
const addUrl = root . getElementById ( "addUrl" ) ;
const addName = root . getElementById ( "addName" ) ;
const addBtn = root . getElementById ( "addBtn" ) ;
if ( addUrl ) addUrl . addEventListener ( "input" , ( e ) => ( this . _customAddUrl = e . target . value ) ) ;
if ( addName ) addName . addEventListener ( "input" , ( e ) => ( this . _customAddName = e . target . value ) ) ;
if ( addBtn ) addBtn . addEventListener ( "click" , ( ) => this . _addCustomRepo ( ) ) ;
for ( const btn of root . querySelectorAll ( "[data-remove]" ) ) {
btn . addEventListener ( "click" , ( ) => {
const id = btn . getAttribute ( "data-remove" ) ;
if ( id ) this . _removeCustomRepo ( id ) ;
} ) ;
}
}
_renderAbout ( ) {
const v = this . _safeText ( this . _data ? . version ) || "-" ;
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 ( v ) } </div>
</div>
` ;
}
_computeCategories ( repos ) {
const set = new Set ( ) ;
for ( const r of repos ) {
const c = this . _safeLower ( r ? . category ) ;
if ( c ) set . add ( c ) ;
}
return Array . from ( set ) . sort ( ) ;
}
// --- HARDENING HELPERS (fixes [object Object]) ---
_safeText ( v ) {
if ( v === null || v === undefined ) return "" ;
const t = typeof v ;
if ( t === "string" ) return v ;
if ( t === "number" || t === "boolean" ) return String ( v ) ;
return "" ; // objects/arrays/functions => empty (prevents [object Object])
}
_safeLower ( v ) {
const s = this . _safeText ( v ) ;
return s ? s . trim ( ) . toLowerCase ( ) : "" ;
}
_safeId ( v ) {
const s = this . _safeText ( v ) ;
return s || "" ;
}
_esc ( s ) {
return String ( s ? ? "" )
. replaceAll ( "&" , "&" )
. replaceAll ( "<" , "<" )
. replaceAll ( ">" , ">" )
. replaceAll ( '"' , """ )
. replaceAll ( "'" , "'" ) ;
}
}
customElements . define ( "bahmcloud-store-panel" , BahmcloudStorePanel ) ;