/** Documents section for an object's detail view. * * Self-contained: lists an object's documents (files + web-links), uploads a * file (multipart POST to the authenticated view — a WS frame can't carry a * binary body), downloads via a signed path (`auth/sign_path` → Companion-safe * anchor), adds web-links, and deletes. Write actions are gated by `canWrite` * (the server enforces it too); download/open is available to everyone. */ import { LitElement, html, css, nothing } from "lit"; import { isSafeHttpUrl } from "../helpers/url"; import { property, state } from "lit/decorators.js"; import { t, ensureLocale } from "../styles"; import { describeWsError } from "../ws-errors"; import { downloadUrl } from "../helpers/download"; import { formatBytes } from "../helpers/format-bytes"; import type { HomeAssistant } from "../types"; interface MaintenanceDocument { id: string; kind: "file" | "weblink"; title: string; filename?: string; url?: string; mime?: string; size?: number; tags?: string[]; added_at?: string; } const CATEGORIES = ["manual", "warranty", "invoice", "spare_parts", "photo", "other"] as const; const CATEGORY_ICONS: Record = { manual: "mdi:book-open-variant", warranty: "mdi:shield-check", invoice: "mdi:receipt-text-outline", spare_parts: "mdi:cog-outline", photo: "mdi:image-outline", other: "mdi:file-document-outline", }; export class MaintenanceDocumentsSection extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public entryId!: string; @property({ type: Boolean }) public canWrite = false; @state() private _docs: MaintenanceDocument[] = []; @state() private _loaded = false; @state() private _busy = false; @state() private _error = ""; @state() private _hint = ""; @state() private _addingLink = false; @state() private _linkUrl = ""; @state() private _linkTitle = ""; @state() private _category = "manual"; @state() private _thumbs: Record = {}; @state() private _lightboxUrl = ""; @state() private _editingId = ""; @state() private _editTitle = ""; @state() private _editCategory = "manual"; @state() private _dragOver = false; private _loadedFor: string | null = null; private _localeReady = false; private _isImage(doc: MaintenanceDocument): boolean { return doc.kind === "file" && (doc.mime || "").startsWith("image/"); } private async _sign(doc: MaintenanceDocument): Promise { const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({ type: "auth/sign_path", path: `/api/maintenance_supporter/document/${doc.id}`, expires: 300, }); return signed.path; } private get _lang(): string { return this.hass?.language || "en"; } updated(changed: Map): void { super.updated(changed); if (this.hass && !this._localeReady) { this._localeReady = true; // Re-render once the runtime locale JSON arrives (t() falls back to English // until then), so a first paint before the fetch lands still localizes. void ensureLocale(this._lang).then(() => this.requestUpdate()); } if (this.hass && this.entryId && this._loadedFor !== this.entryId) { this._loadedFor = this.entryId; void this._load(); } } private async _load(): Promise { try { const r = await this.hass.connection.sendMessagePromise<{ documents: MaintenanceDocument[] }>({ type: "maintenance_supporter/documents/list", entry_id: this.entryId, }); this._docs = r.documents || []; this._loaded = true; this._error = ""; this._thumbs = {}; void this._loadThumbs(); } catch (e) { this._error = describeWsError(e, this._lang); this._loaded = true; } } /** Pre-sign a serve URL for each image doc so it can render as a thumbnail. */ private async _loadThumbs(): Promise { await Promise.all( this._docs.filter((d) => this._isImage(d)).map(async (d) => { try { const url = await this._sign(d); this._thumbs = { ...this._thumbs, [d.id]: url }; } catch { /* leave the fallback icon */ } }), ); } private _category_of(doc: MaintenanceDocument): string { const tag = (doc.tags || []).find((x) => (CATEGORIES as readonly string[]).includes(x)); return tag || "other"; } /** Keyboard support for the file-picker