/** Document-storage overview card (panel overview). * * Shows the physical footprint (real backup cost), the dedup saving, and a * per-object drill-down sorted by size. Object ids from the WS summary are * mapped to names via the panel's already-loaded objects. Self-hides when no * documents exist, so it never clutters the overview for non-users. */ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { t, ensureLocale } from "../styles"; import { describeWsError } from "../ws-errors"; import { formatBytes } from "../helpers/format-bytes"; import type { HomeAssistant } from "../types"; interface StorageSummary { total_bytes: number; dedup_savings_bytes: number; file_count: number; link_count: number; document_count: number; by_object: Record; } interface PanelObject { entry_id: string; object?: { id?: string; name?: string }; } interface SearchResult { id: string; entry_id: string; object_name: string; kind: "file" | "weblink"; title: string; filename?: string; url?: string; size?: number; tags?: string[]; } export class MaintenanceStorageSectionCard extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public objects: PanelObject[] = []; @state() private _summary: StorageSummary | null = null; @state() private _loaded = false; @state() private _busy = false; @state() private _error = ""; @state() private _query = ""; @state() private _results: SearchResult[] = []; @state() private _expanded = false; private _initiallyLoaded = false; private _searchTimer = 0; private get _lang(): string { return this.hass?.language || "en"; } updated(changed: Map): void { super.updated(changed); if (changed.has("hass") && this.hass && !this._initiallyLoaded) { this._initiallyLoaded = true; void this._load(); // Re-render once the runtime locale JSON arrives (t() falls back to // English until then; this card can paint before the panel's fetch lands). void ensureLocale(this._lang).then(() => this.requestUpdate()); } } private async _load(): Promise { this._busy = true; try { this._summary = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/documents/storage", }); this._error = ""; } catch (e) { this._error = describeWsError(e, this._lang); } finally { this._loaded = true; this._busy = false; } } private _nameFor(objectId: string): string { const o = this.objects.find((x) => x.object?.id === objectId); return o?.object?.name || objectId.slice(0, 8); } private _entryFor(objectId: string): string | undefined { return this.objects.find((x) => x.object?.id === objectId)?.entry_id; } private _toggle(): void { this._expanded = !this._expanded; } /** Ask the panel (which owns navigation) to open an object's detail view. */ private _openObject(entryId: string): void { this.dispatchEvent( new CustomEvent("open-object", { detail: { entry_id: entryId }, bubbles: true, composed: true, }), ); } private _onSearch(e: Event): void { this._query = (e.target as HTMLInputElement).value; clearTimeout(this._searchTimer); this._searchTimer = window.setTimeout(() => void this._doSearch(), 250); } private async _doSearch(): Promise { const q = this._query.trim(); if (!q) { this._results = []; return; } try { const r = await this.hass.connection.sendMessagePromise<{ results: SearchResult[] }>({ type: "maintenance_supporter/documents/search", query: q, }); this._results = r.results || []; } catch (e) { this._error = describeWsError(e, this._lang); this._results = []; } } private async _openResult(doc: SearchResult): Promise { if (doc.kind === "weblink") { window.open(doc.url, "_blank", "noopener"); return; } const win = window.open("about:blank", "_blank"); try { const s = await this.hass.connection.sendMessagePromise<{ path: string }>({ type: "auth/sign_path", path: `/api/maintenance_supporter/document/${doc.id}`, expires: 300, }); // Absolute URL so it navigates the blank popup reliably (about:blank base). if (win) win.location.href = new URL(s.path, window.location.origin).href; } catch (e) { if (win) win.close(); this._error = describeWsError(e, this._lang); } } private _renderResult(doc: SearchResult, L: string) { return html`
this._openResult(doc)}>
${doc.title || doc.filename || doc.url}
${doc.object_name}
`; } render() { if (!this._loaded || !this._summary) return nothing; const s = this._summary; if (s.document_count === 0) return nothing; // self-hide when unused const L = this._lang; const rows = Object.entries(s.by_object) .filter(([, v]) => v.files > 0 || v.links > 0) .map(([id, v]) => ({ id, name: this._nameFor(id), entry: this._entryFor(id), ...v })) .sort((a, b) => b.bytes - a.bytes); return html`
${this._expanded ? html`
${formatBytes(s.total_bytes)}
${s.file_count} ${s.link_count}
${s.dedup_savings_bytes > 0 ? html`
−${formatBytes(s.dedup_savings_bytes)}
${t("doc_storage_saved", L)}
` : nothing}
${this._error ? html`
${this._error}
` : nothing} ${this._query.trim() ? this._results.length ? html`
${this._results.map((d) => this._renderResult(d, L))}
` : html`
${t("doc_search_none", L)}
` : rows.length ? html`
${rows.map((r) => this._renderObjRow(r, L))}
` : nothing}
` : nothing}
`; } private _renderObjRow( r: { id: string; name: string; entry?: string; bytes: number; files: number; links: number }, L: string, ) { const eid = r.entry; return html`
this._openObject(eid) : undefined} @keydown=${eid ? (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); this._openObject(eid); } } : undefined} > ${r.name} ${r.files > 0 ? html`${r.files}` : nothing} ${r.links > 0 ? html`${r.links}` : nothing} ${formatBytes(r.bytes)} ${eid ? html`` : nothing}
`; } static styles = css` ha-card { margin-top: 16px; } .card-content { padding: 16px; } .doc-search { display: flex; align-items: center; gap: 6px; margin: 10px 0 4px; padding: 2px 10px; border-radius: 8px; background: var(--secondary-background-color, rgba(0, 0, 0, 0.06)); border: 1px solid var(--divider-color); } .doc-search ha-icon { --mdc-icon-size: 18px; color: var(--secondary-text-color, #888); } .doc-search input { flex: 1; border: none; background: transparent; font: inherit; outline: none; color: var(--primary-text-color); padding: 6px 0; } .result-row { cursor: pointer; } .result-row > ha-icon { color: var(--primary-color); --mdc-icon-size: 20px; flex: none; } .result-info { flex: 1; min-width: 0; } .result-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .result-obj { font-size: 12px; color: var(--secondary-text-color, #888); } .result-open { color: var(--secondary-text-color, #888); --mdc-icon-size: 18px; flex: none; } .search-empty { color: var(--secondary-text-color, #888); font-size: 13px; padding: 8px 2px; } .header { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .toggle { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; background: none; border: none; padding: 4px 0; margin: 0; cursor: pointer; font: inherit; color: var(--primary-text-color); text-align: left; } .toggle:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; border-radius: 6px; } .chevron { --mdc-icon-size: 22px; color: var(--secondary-text-color, #888); flex: none; } .title-text { font-size: 16px; font-weight: 500; } .header-summary { margin-left: auto; display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; white-space: nowrap; } .header-summary .saved { color: var(--success-color, #4caf50); font-weight: 500; } .emoji { font-size: 18px; } .body { margin-top: 4px; } .totals { display: flex; gap: 24px; margin: 12px 0 8px; flex-wrap: wrap; } .stat-value { font-size: 22px; font-weight: 600; } .stat-value.saved { color: var(--success-color, #4caf50); } .stat-label { font-size: 12px; color: var(--secondary-text-color, #888); display: flex; align-items: center; gap: 4px; } .stat-label ha-icon { --mdc-icon-size: 15px; } .obj-list { display: flex; flex-direction: column; gap: 2px; margin-top: 8px; } .obj-row { display: flex; align-items: center; gap: 10px; padding: 6px 8px; border-radius: 6px; } .obj-row:nth-child(odd) { background: var(--secondary-background-color, rgba(0,0,0,0.04)); } .obj-row.clickable { cursor: pointer; } .obj-row.clickable:hover { background: var(--secondary-background-color, rgba(0,0,0,0.10)); } .obj-row.clickable:focus-visible { outline: 2px solid var(--primary-color); outline-offset: -2px; } .obj-go { --mdc-icon-size: 18px; color: var(--secondary-text-color, #888); flex: none; } .obj-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; } .obj-meta { display: flex; align-items: center; gap: 4px; color: var(--secondary-text-color, #888); font-size: 13px; } .obj-meta ha-icon { --mdc-icon-size: 15px; } .obj-size { font-variant-numeric: tabular-nums; font-size: 13px; min-width: 64px; text-align: right; } .icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; cursor: pointer; background: transparent; border: none; color: var(--primary-text-color); } .icon-btn:hover { background: var(--secondary-background-color, rgba(0,0,0,0.06)); } .icon-btn[disabled] { opacity: 0.4; pointer-events: none; } .error { color: var(--error-color, #f44336); font-size: 13px; margin-top: 6px; } `; } if (!customElements.get("maintenance-storage-section-card")) { customElements.define("maintenance-storage-section-card", MaintenanceStorageSectionCard); }