Files
HomeAssistantVS/custom_components/maintenance_supporter/frontend-src/components/documents-section.ts
T
2026-07-14 23:57:03 -04:00

645 lines
24 KiB
TypeScript

/** 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<string, string> = {
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<string, string> = {};
@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<string> {
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<string, unknown>): 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<void> {
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<void> {
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 <label>s (Enter/Space → open). */
private _labelKeydown(e: KeyboardEvent): void {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
(e.currentTarget as HTMLElement).querySelector("input")?.click();
}
}
private _onFileInput(e: Event): void {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
if (files.length) void this._uploadFiles(files);
input.value = ""; // let the same file be re-picked
}
private _onCameraInput(e: Event): void {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
if (files.length) void this._uploadFiles(files, "photo");
input.value = "";
}
private _onDrop(e: DragEvent): void {
e.preventDefault();
this._dragOver = false;
if (!this.canWrite || this._busy) return;
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length) void this._uploadFiles(files);
}
private _onDragOver(e: DragEvent): void {
if (!this.canWrite) return;
e.preventDefault();
this._dragOver = true;
}
private _onDragLeave(e: DragEvent): void {
// Only clear when the pointer truly leaves the zone (not when moving over a
// child element), otherwise the overlay flickers.
const rt = e.relatedTarget as Node | null;
if (!rt || !(e.currentTarget as HTMLElement).contains(rt)) this._dragOver = false;
}
private async _uploadFiles(files: File[], category?: string): Promise<void> {
const cat = category ?? this._category;
this._busy = true;
this._error = "";
this._hint = "";
let deduped = 0;
let dupInObject = 0;
try {
for (const file of files) {
const form = new FormData();
form.append("entry_id", this.entryId);
form.append("tags", cat);
form.append("file", file, file.name);
const resp = await fetch("/api/maintenance_supporter/document/upload", {
method: "POST",
headers: { Authorization: `Bearer ${this.hass.auth?.data?.access_token ?? ""}` },
body: form,
});
if (!resp.ok) {
this._error = resp.status === 413 ? t("doc_too_large", this._lang) : t("doc_upload_failed", this._lang);
continue;
}
const doc = (await resp.json()) as { deduped?: boolean; duplicate_in_object?: string | null };
if (doc.duplicate_in_object) dupInObject++;
else if (doc.deduped) deduped++;
}
if (dupInObject) this._hint = t("doc_dup_in_object", this._lang);
else if (deduped) this._hint = t("doc_deduped", this._lang);
await this._load();
} catch {
this._error = t("doc_upload_failed", this._lang);
} finally {
this._busy = false;
}
}
private async _download(doc: MaintenanceDocument): Promise<void> {
try {
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 30,
});
downloadUrl(signed.path, doc.filename || doc.title || "document");
} catch (e) {
this._error = describeWsError(e, this._lang);
}
}
/** Open a document for viewing: images in an in-app lightbox, everything else
* inline in a new tab (the serve view sends Content-Disposition: inline). */
private async _preview(doc: MaintenanceDocument): Promise<void> {
if (this._isImage(doc)) {
this._lightboxUrl = this._thumbs[doc.id] || (await this._sign(doc));
return;
}
// Open the tab synchronously (in the click gesture) so it isn't popup-blocked,
// then point it at the freshly signed URL once it resolves.
const win = window.open("about:blank", "_blank");
try {
const url = await this._sign(doc);
// Absolute URL so it always resolves against the blank popup (about:blank).
if (win) win.location.href = new URL(url, window.location.origin).href;
} catch (e) {
if (win) win.close();
this._error = describeWsError(e, this._lang);
}
}
/** Open a document from a title/row click (not just the small icons): preview
* a file, open a web-link in a new tab. */
private _openDoc(doc: MaintenanceDocument): void {
if (doc.kind === "file") void this._preview(doc);
// Only open http(s) links — never a javascript:/data: URL (the same scheme
// guard the rest of the panel applies before opening user-supplied URLs).
else if (isSafeHttpUrl(doc.url)) window.open(doc.url, "_blank", "noopener");
}
private _startEdit(doc: MaintenanceDocument): void {
this._editingId = doc.id;
this._editTitle = doc.title || "";
this._editCategory = this._category_of(doc);
this._addingLink = false;
this._error = "";
}
private _cancelEdit(): void {
this._editingId = "";
}
private async _saveEdit(doc: MaintenanceDocument): Promise<void> {
// Keep any free (non-category) tags; swap in the chosen category.
const freeTags = (doc.tags || []).filter(
(x) => !(CATEGORIES as readonly string[]).includes(x),
);
const tags = doc.kind === "file" ? [this._editCategory, ...freeTags] : (doc.tags ?? []);
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/documents/update",
doc_id: doc.id,
title: this._editTitle.trim() || doc.filename || doc.url || "",
tags,
});
this._editingId = "";
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
}
private async _delete(doc: MaintenanceDocument): Promise<void> {
const name = doc.title || doc.filename || doc.url || "";
if (!window.confirm(t("doc_delete_confirm", this._lang).replace("{name}", name))) return;
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/documents/delete",
doc_id: doc.id,
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
}
private async _addLink(): Promise<void> {
const url = this._linkUrl.trim();
if (!url) return;
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/documents/add_link",
entry_id: this.entryId,
url,
title: this._linkTitle.trim() || null,
});
this._linkUrl = "";
this._linkTitle = "";
this._addingLink = false;
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang, t("doc_link_invalid", this._lang));
} finally {
this._busy = false;
}
}
render() {
const L = this._lang;
return html`
<div
class="doc-zone ${this._dragOver ? "drag-over" : ""}"
@dragover=${this._onDragOver}
@dragleave=${this._onDragLeave}
@drop=${this._onDrop}
>
${this._dragOver && this.canWrite
? html`<div class="drop-overlay">
<ha-icon icon="mdi:tray-arrow-down"></ha-icon> ${t("doc_drop_hint", L)}
</div>`
: nothing}
<div class="doc-header">
<h3>${t("documents", L)} (${this._docs.length})</h3>
${this.canWrite
? html`
<div class="doc-actions">
<select
class="cat-select"
.value=${this._category}
?disabled=${this._busy}
@change=${(e: Event) => (this._category = (e.target as HTMLSelectElement).value)}
>
${CATEGORIES.map((c) => html`<option value=${c}>${t(`doc_cat_${c}`, L)}</option>`)}
</select>
<label
class="btn primary ${this._busy ? "disabled" : ""}"
role="button"
tabindex="0"
@keydown=${this._labelKeydown}
>
<ha-icon icon="mdi:upload"></ha-icon>
${this._busy ? t("doc_uploading", L) : t("doc_upload", L)}
<input type="file" multiple hidden ?disabled=${this._busy} @change=${this._onFileInput} />
</label>
<label
class="btn camera-btn ${this._busy ? "disabled" : ""}"
role="button"
tabindex="0"
aria-label=${t("doc_camera", L)}
title=${t("doc_camera", L)}
@keydown=${this._labelKeydown}
>
<ha-icon icon="mdi:camera"></ha-icon>
<input type="file" accept="image/*" capture="environment" hidden ?disabled=${this._busy} @change=${this._onCameraInput} />
</label>
<button class="btn" ?disabled=${this._busy} @click=${() => (this._addingLink = !this._addingLink)}>
<ha-icon icon="mdi:link-variant"></ha-icon> ${t("doc_add_link", L)}
</button>
</div>
`
: nothing}
</div>
${this._error ? html`<div class="doc-msg error">${this._error}</div>` : nothing}
${this._hint ? html`<div class="doc-msg hint">${this._hint}</div>` : nothing}
${this._addingLink && this.canWrite
? html`
<div class="link-form">
<input
type="url"
placeholder=${t("doc_link_url", L)}
.value=${this._linkUrl}
?disabled=${this._busy}
@input=${(e: Event) => (this._linkUrl = (e.target as HTMLInputElement).value)}
/>
<input
type="text"
placeholder=${t("doc_link_title", L)}
.value=${this._linkTitle}
?disabled=${this._busy}
@input=${(e: Event) => (this._linkTitle = (e.target as HTMLInputElement).value)}
/>
<button class="btn primary" ?disabled=${this._busy || !this._linkUrl.trim()} @click=${this._addLink}>
${t("add", L)}
</button>
<button class="btn" ?disabled=${this._busy} @click=${() => (this._addingLink = false)}>
${t("cancel", L)}
</button>
</div>
`
: nothing}
${!this._loaded
? html`<div class="doc-empty">${t("loading", L)}</div>`
: this._docs.length === 0
? html`<div class="doc-empty">${t("documents_empty", L)}</div>`
: html`
<div class="doc-list">
${this._docs.map((doc) => this._renderDoc(doc, L))}
</div>
`}
${this._lightboxUrl
? html`<div class="lightbox" @click=${() => (this._lightboxUrl = "")}>
<img class="lightbox-img" src=${this._lightboxUrl} @click=${(e: Event) => e.stopPropagation()} />
<button class="lightbox-close" title=${t("doc_close", L)} @click=${() => (this._lightboxUrl = "")}>
<ha-icon icon="mdi:close"></ha-icon>
</button>
</div>`
: nothing}
</div>
`;
}
private _renderDoc(doc: MaintenanceDocument, L: string) {
if (this._editingId === doc.id) return this._renderEdit(doc, L);
const isFile = doc.kind === "file";
const cat = this._category_of(doc);
const meta = isFile
? `${t(`doc_cat_${cat}`, L)} · ${formatBytes(doc.size)}`
: t("doc_link_badge", L);
const thumb = this._thumbs[doc.id];
return html`
<div class="doc-row">
${isFile && thumb
? html`<img
class="doc-thumb"
src=${thumb}
alt=${doc.title || ""}
title=${t("doc_open", L)}
@click=${() => this._preview(doc)}
/>`
: html`<ha-icon
class="doc-icon ${isFile ? "clickable" : ""}"
icon=${isFile ? CATEGORY_ICONS[cat] : "mdi:link-variant"}
@click=${() => isFile && this._preview(doc)}
></ha-icon>`}
<div
class="doc-info"
role="button"
tabindex="0"
title=${t("doc_open", L)}
@click=${() => this._openDoc(doc)}
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this._openDoc(doc);
}
}}
>
<div class="doc-title">${doc.title || doc.filename || doc.url}</div>
<div class="doc-meta">${meta}</div>
</div>
<div class="doc-row-actions">
${isFile
? html`
<button class="icon-btn" title=${t("doc_open", L)} @click=${() => this._preview(doc)}>
<ha-icon icon="mdi:eye-outline"></ha-icon>
</button>
<button class="icon-btn" title=${t("doc_download", L)} @click=${() => this._download(doc)}>
<ha-icon icon="mdi:download"></ha-icon>
</button>`
: html`<a
class="icon-btn"
href=${isSafeHttpUrl(doc.url) ? doc.url : "#"}
target="_blank"
rel="noopener noreferrer"
title=${t("doc_open", L)}
><ha-icon icon="mdi:open-in-new"></ha-icon></a>`}
${this.canWrite
? html`
<button class="icon-btn" title=${t("edit", L)} ?disabled=${this._busy} @click=${() => this._startEdit(doc)}>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>
<button class="icon-btn danger" title=${t("delete", L)} ?disabled=${this._busy} @click=${() => this._delete(doc)}>
<ha-icon icon="mdi:delete"></ha-icon>
</button>`
: nothing}
</div>
</div>
`;
}
private _renderEdit(doc: MaintenanceDocument, L: string) {
const isFile = doc.kind === "file";
return html`
<div class="doc-row editing">
<input
class="edit-title"
type="text"
placeholder=${t("doc_link_title", L)}
.value=${this._editTitle}
?disabled=${this._busy}
@input=${(e: Event) => (this._editTitle = (e.target as HTMLInputElement).value)}
/>
${isFile
? html`<select
class="cat-select"
?disabled=${this._busy}
@change=${(e: Event) => (this._editCategory = (e.target as HTMLSelectElement).value)}
>
${CATEGORIES.map(
(c) => html`<option value=${c} ?selected=${c === this._editCategory}>${t(`doc_cat_${c}`, L)}</option>`,
)}
</select>`
: nothing}
<button class="icon-btn" title=${t("save", L)} ?disabled=${this._busy || !this._editTitle.trim()} @click=${() => this._saveEdit(doc)}>
<ha-icon icon="mdi:check"></ha-icon>
</button>
<button class="icon-btn" title=${t("cancel", L)} ?disabled=${this._busy} @click=${this._cancelEdit}>
<ha-icon icon="mdi:close"></ha-icon>
</button>
</div>
`;
}
static styles = css`
:host { display: block; margin: 8px 0 4px; }
.doc-zone { position: relative; }
.doc-zone.drag-over {
outline: 2px dashed var(--primary-color); outline-offset: 4px; border-radius: 8px;
}
.drop-overlay {
position: absolute; inset: 0; z-index: 5; pointer-events: none;
display: flex; align-items: center; justify-content: center; gap: 8px;
border-radius: 8px; font-size: 15px; font-weight: 600;
color: var(--primary-color); opacity: 0.95;
background: var(--card-background-color, rgba(255, 255, 255, 0.85));
}
.drop-overlay ha-icon { --mdc-icon-size: 24px; }
.camera-btn { padding: 6px 10px; }
.doc-header {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap;
}
h3 { margin: 8px 0; font-size: 16px; }
.doc-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cat-select {
padding: 6px 8px; border-radius: 6px; font: inherit;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color); border: 1px solid var(--divider-color);
}
.btn {
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
padding: 6px 12px; border-radius: 6px; font: inherit; font-size: 13px;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color); border: 1px solid var(--divider-color);
}
.btn.primary { background: var(--primary-color); color: var(--text-primary-color, #fff); border-color: var(--primary-color); }
.btn:focus-visible, .icon-btn:focus-visible {
outline: 2px solid var(--primary-color); outline-offset: 2px;
}
.btn.disabled, .btn[disabled] { opacity: 0.5; pointer-events: none; }
.btn ha-icon { --mdc-icon-size: 18px; }
.link-form { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
.link-form input {
flex: 1 1 180px; padding: 6px 10px; border-radius: 6px; font: inherit;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color); border: 1px solid var(--divider-color);
}
.doc-msg { font-size: 13px; margin: 6px 0; }
.doc-msg.error { color: var(--error-color, #f44336); }
.doc-msg.hint { color: var(--secondary-text-color, #888); }
.doc-empty { color: var(--secondary-text-color, #888); font-size: 13px; padding: 8px 0; }
.doc-list { display: flex; flex-direction: column; gap: 4px; }
.doc-row {
display: flex; align-items: center; gap: 12px; padding: 8px 10px;
border: 1px solid var(--divider-color); border-radius: 8px;
background: var(--card-background-color, transparent);
}
.doc-row.editing { gap: 8px; }
.edit-title {
flex: 1; min-width: 0; padding: 6px 10px; border-radius: 6px; font: inherit;
background: var(--secondary-background-color, rgba(0, 0, 0, 0.06));
color: var(--primary-text-color); border: 1px solid var(--divider-color);
}
.doc-icon { color: var(--primary-color); --mdc-icon-size: 24px; flex: none; }
.doc-icon.clickable { cursor: pointer; }
.doc-thumb {
width: 40px; height: 40px; object-fit: cover; border-radius: 6px; flex: none;
cursor: pointer; border: 1px solid var(--divider-color);
background: var(--secondary-background-color, rgba(0,0,0,0.06));
}
.lightbox {
position: fixed; inset: 0; z-index: 9999; cursor: zoom-out;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.85);
}
.lightbox-img {
max-width: 92vw; max-height: 92vh; object-fit: contain; cursor: default;
border-radius: 8px; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
}
.lightbox-close {
position: fixed; top: 16px; right: 16px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
width: 44px; height: 44px; border-radius: 50%; border: none;
background: rgba(0, 0, 0, 0.5); color: #fff;
}
.lightbox-close ha-icon { --mdc-icon-size: 26px; }
.doc-info { flex: 1; min-width: 0; cursor: pointer; border-radius: 6px; }
.doc-info:hover .doc-title { text-decoration: underline; }
.doc-info:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
.doc-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc-meta { font-size: 12px; color: var(--secondary-text-color, #888); }
.doc-row-actions { display: flex; gap: 4px; flex: none; }
.icon-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 34px; height: 34px; border-radius: 8px; cursor: pointer;
background: transparent; border: none; color: var(--primary-text-color);
text-decoration: none;
}
.icon-btn:hover { background: var(--secondary-background-color, rgba(0,0,0,0.06)); }
.icon-btn.danger { color: var(--error-color, #f44336); }
.icon-btn[disabled] { opacity: 0.4; pointer-events: none; }
.icon-btn ha-icon { --mdc-icon-size: 20px; }
`;
}
if (!customElements.get("maintenance-documents-section")) {
customElements.define("maintenance-documents-section", MaintenanceDocumentsSection);
}