278 files
This commit is contained in:
+87
-35
@@ -16,8 +16,13 @@ import { downloadUrl } from "../helpers/download";
|
||||
import { downloadSignedDocument, openSignedDocument, signDocumentPath } from "../helpers/document-url";
|
||||
import { formatBytes } from "../helpers/format-bytes";
|
||||
import { docDisplayName, CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
|
||||
import { DOC_FILTER_MIN, filterDocuments } from "../helpers/document-filter";
|
||||
import { DOC_FILTER_MIN, DOC_SORT_MODES, asDocSortMode, filterDocuments, sortDocuments, type DocSortMode } from "../helpers/document-filter";
|
||||
import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { isAndroidCompanion } from "../helpers/companion";
|
||||
import { uploadDocument } from "../helpers/photo-upload";
|
||||
import "./ms-photo-picker";
|
||||
import { photoPickerStyles } from "./ms-photo-picker";
|
||||
|
||||
interface MaintenanceDocument {
|
||||
id: string;
|
||||
@@ -28,6 +33,8 @@ interface MaintenanceDocument {
|
||||
mime?: string;
|
||||
size?: number;
|
||||
tags?: string[];
|
||||
/** #164: a free-text note per document. */
|
||||
description?: string;
|
||||
added_at?: string;
|
||||
}
|
||||
|
||||
@@ -52,6 +59,10 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
@state() private _editingId = "";
|
||||
@state() private _editTitle = "";
|
||||
@state() private _editCategory = "manual";
|
||||
@state() private _editDescription = "";
|
||||
@state() private _linkDescription = "";
|
||||
/** #164: list order, remembered per browser. */
|
||||
@state() private _sort: DocSortMode = asDocSortMode(lsGet(LS_KEYS.docSort));
|
||||
@state() private _dragOver = false;
|
||||
|
||||
private _loadedFor: string | null = null;
|
||||
@@ -134,12 +145,10 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
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 = "";
|
||||
}
|
||||
/** The Android Companion app answers a multi-select with an EMPTY file
|
||||
* list (helpers/companion.ts) — "Upload file" is single-select there.
|
||||
* Evaluated once; the host app does not change. */
|
||||
private readonly _singlePick = isAndroidCompanion();
|
||||
|
||||
private _onDrop(e: DragEvent): void {
|
||||
e.preventDefault();
|
||||
@@ -170,21 +179,18 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
let deduped = 0;
|
||||
let dupInObject = 0;
|
||||
try {
|
||||
// Bulk upload: one refused file (too large, rejected) is noted and
|
||||
// the rest still go up — unlike the photo dialogs, which stop.
|
||||
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);
|
||||
let doc;
|
||||
try {
|
||||
doc = await uploadDocument(this.hass, this.entryId, file, [cat]);
|
||||
} catch (e) {
|
||||
const key = e instanceof Error ? e.message : "";
|
||||
if (key !== "doc_too_large" && key !== "doc_upload_failed") throw e;
|
||||
this._error = t(key, 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++;
|
||||
}
|
||||
@@ -233,6 +239,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
this._editingId = doc.id;
|
||||
this._editTitle = doc.title || "";
|
||||
this._editCategory = this._category_of(doc);
|
||||
this._editDescription = doc.description || "";
|
||||
this._addingLink = false;
|
||||
this._error = "";
|
||||
}
|
||||
@@ -241,6 +248,11 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
this._editingId = "";
|
||||
}
|
||||
|
||||
private _setSort(raw: string): void {
|
||||
this._sort = asDocSortMode(raw);
|
||||
lsSet(LS_KEYS.docSort, this._sort);
|
||||
}
|
||||
|
||||
private async _saveEdit(doc: MaintenanceDocument): Promise<void> {
|
||||
// Keep any free (non-category) tags; swap in the chosen category.
|
||||
const freeTags = (doc.tags || []).filter(
|
||||
@@ -255,6 +267,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
doc_id: doc.id,
|
||||
title: this._editTitle.trim() || doc.filename || doc.url || "",
|
||||
tags,
|
||||
description: this._editDescription.trim(),
|
||||
});
|
||||
this._editingId = "";
|
||||
await this._load();
|
||||
@@ -294,9 +307,11 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
entry_id: this.entryId,
|
||||
url,
|
||||
title: this._linkTitle.trim() || null,
|
||||
description: this._linkDescription.trim() || null,
|
||||
});
|
||||
this._linkUrl = "";
|
||||
this._linkTitle = "";
|
||||
this._linkDescription = "";
|
||||
this._addingLink = false;
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
@@ -341,19 +356,11 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
>
|
||||
<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} />
|
||||
<input type="file" ?multiple=${!this._singlePick} hidden ?disabled=${this._busy} @change=${this._onFileInput} />
|
||||
</label>
|
||||
<ms-photo-picker compact .showGallery=${false} .lang=${L} .disabled=${this._busy}
|
||||
@files-picked=${(e: CustomEvent<{ files: File[] }>) => this._uploadFiles(e.detail.files, "photo")}
|
||||
></ms-photo-picker>
|
||||
<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>
|
||||
@@ -382,6 +389,14 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
?disabled=${this._busy}
|
||||
@input=${(e: Event) => (this._linkTitle = (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="link-desc"
|
||||
placeholder=${t("doc_description", L)}
|
||||
.value=${this._linkDescription}
|
||||
?disabled=${this._busy}
|
||||
@input=${(e: Event) => (this._linkDescription = (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button class="btn primary" ?disabled=${this._busy || !this._linkUrl.trim()} @click=${this._addLink}>
|
||||
${t("add", L)}
|
||||
</button>
|
||||
@@ -392,6 +407,15 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
`
|
||||
: nothing}
|
||||
|
||||
${this._loaded && this._docs.length >= 2
|
||||
? html`<div class="doc-tools">
|
||||
<ha-icon icon="mdi:sort"></ha-icon>
|
||||
<select class="sort-select" aria-label=${t("doc_sort", L)} .value=${this._sort}
|
||||
@change=${(e: Event) => this._setSort((e.target as HTMLSelectElement).value)}>
|
||||
${DOC_SORT_MODES.map((m) => html`<option value=${m} ?selected=${m === this._sort}>${t(`doc_sort_${m}`, L)}</option>`)}
|
||||
</select>
|
||||
</div>`
|
||||
: nothing}
|
||||
${this._loaded && this._docs.length >= DOC_FILTER_MIN
|
||||
? html`<div class="doc-filter">
|
||||
<ha-icon icon="mdi:magnify"></ha-icon>
|
||||
@@ -404,7 +428,8 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
: this._docs.length === 0
|
||||
? html`<div class="doc-empty">${t("documents_empty", L)}</div>`
|
||||
: (() => {
|
||||
const shown = filterDocuments(this._docs, this._filter);
|
||||
// A query ranks by match; otherwise the remembered order applies.
|
||||
const shown = this._filter.trim() ? filterDocuments(this._docs, this._filter) : sortDocuments(this._docs, this._sort);
|
||||
return shown.length === 0
|
||||
? html`<div class="doc-empty">${t("doc_search_none", L)}</div>`
|
||||
: html`<div class="doc-list">${shown.map((doc) => this._renderDoc(doc, L))}</div>`;
|
||||
@@ -460,6 +485,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
>
|
||||
<div class="doc-title">${docDisplayName(doc)}</div>
|
||||
<div class="doc-meta">${meta}</div>
|
||||
${doc.description ? html`<div class="doc-desc">${doc.description}</div>` : nothing}
|
||||
</div>
|
||||
<div class="doc-row-actions">
|
||||
${isFile
|
||||
@@ -514,6 +540,14 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
)}
|
||||
</select>`
|
||||
: nothing}
|
||||
<input
|
||||
class="edit-desc"
|
||||
type="text"
|
||||
placeholder=${t("doc_description", L)}
|
||||
.value=${this._editDescription}
|
||||
?disabled=${this._busy}
|
||||
@input=${(e: Event) => (this._editDescription = (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<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>
|
||||
@@ -524,7 +558,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
static styles = [photoPickerStyles, css`
|
||||
:host { display: block; margin: 8px 0 4px; }
|
||||
.doc-zone { position: relative; }
|
||||
.doc-zone.drag-over {
|
||||
@@ -538,7 +572,15 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
background: var(--card-background-color, rgba(255, 255, 255, 0.85));
|
||||
}
|
||||
.drop-overlay ha-icon { --mdc-icon-size: 24px; }
|
||||
.camera-btn { padding: 6px 10px; }
|
||||
/* The camera picker (ms-photo-picker, light DOM) wears the toolbar's
|
||||
.btn look instead of the dialogs' dashed tile. */
|
||||
.doc-actions .photo-pick {
|
||||
padding: 6px 10px; gap: 6px; border-style: solid; border-radius: 6px;
|
||||
background: var(--secondary-background-color, rgba(0,0,0,0.06));
|
||||
color: var(--primary-text-color); border-color: var(--divider-color);
|
||||
}
|
||||
.doc-actions .photo-pick ha-icon { --mdc-icon-size: 18px; }
|
||||
.doc-actions .photo-pick.disabled { opacity: 0.5; pointer-events: none; }
|
||||
.doc-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; flex-wrap: wrap;
|
||||
@@ -620,6 +662,16 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
.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-desc { font-size: 12.5px; color: var(--secondary-text-color, #888); margin-top: 2px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.doc-tools { display: flex; align-items: center; gap: 6px; margin: 6px 0 2px; color: var(--secondary-text-color, #888); }
|
||||
.doc-tools ha-icon { --mdc-icon-size: 18px; }
|
||||
.sort-select {
|
||||
padding: 4px 6px; 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);
|
||||
}
|
||||
.doc-row.editing { flex-wrap: wrap; }
|
||||
.edit-desc, .link-desc { flex: 1 1 100%; min-width: 0; }
|
||||
.doc-row-actions { display: flex; gap: 4px; flex: none; }
|
||||
.icon-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
@@ -631,7 +683,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
.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")) {
|
||||
|
||||
Reference in New Issue
Block a user