420 lines
13 KiB
TypeScript
420 lines
13 KiB
TypeScript
/** Dialog for generating, printing, and downloading QR codes.
|
||
*
|
||
* For tasks: shows two QR codes side-by-side — "Info" (ℹ) and "Complete" (✓)
|
||
* with embedded icons. For objects: shows a single "Info" QR.
|
||
*/
|
||
|
||
import { LitElement, html, css, nothing } from "lit";
|
||
import { property, state } from "lit/decorators.js";
|
||
import type { HomeAssistant } from "../types";
|
||
import { t } from "../styles";
|
||
|
||
interface QrResult {
|
||
svg_data_uri: string;
|
||
url: string;
|
||
label: {
|
||
object_name: string;
|
||
manufacturer: string;
|
||
model: string;
|
||
task_name: string | null;
|
||
};
|
||
}
|
||
|
||
/** Escape HTML entities to prevent XSS in document.write contexts. */
|
||
function escapeHtml(s: string): string {
|
||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||
}
|
||
|
||
/** Sanitize a data URI for safe use in an img src attribute. */
|
||
function sanitizeDataUri(uri: string): string {
|
||
if (!uri.startsWith("data:image/svg+xml,") && !uri.startsWith("data:image/png;base64,")) {
|
||
return "";
|
||
}
|
||
return escapeHtml(uri);
|
||
}
|
||
|
||
/** Sanitize a string for use in a filename (remove OS-invalid chars). */
|
||
function sanitizeFilename(s: string): string {
|
||
return s.replace(/[/\\:*?"<>|#%]+/g, "").replace(/\s+/g, "-").toLowerCase().substring(0, 100);
|
||
}
|
||
|
||
export class MaintenanceQrDialog extends LitElement {
|
||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||
@property() public lang = "en";
|
||
|
||
@state() private _open = false;
|
||
@state() private _loading = false;
|
||
@state() private _error = "";
|
||
@state() private _viewResult: QrResult | null = null;
|
||
@state() private _completeResult: QrResult | null = null;
|
||
@state() private _urlMode: "companion" | "local" | "server" = "companion";
|
||
|
||
private _entryId = "";
|
||
private _taskId: string | null = null;
|
||
private _objectName = "";
|
||
private _taskName = "";
|
||
private _generateSeq = 0;
|
||
|
||
public openForObject(entryId: string, objectName: string): void {
|
||
this._entryId = entryId;
|
||
this._taskId = null;
|
||
this._objectName = objectName;
|
||
this._taskName = "";
|
||
this._urlMode = "companion";
|
||
this._error = "";
|
||
this._viewResult = null;
|
||
this._completeResult = null;
|
||
this._open = true;
|
||
this._generate();
|
||
}
|
||
|
||
public openForTask(
|
||
entryId: string,
|
||
taskId: string,
|
||
objectName: string,
|
||
taskName: string,
|
||
): void {
|
||
this._entryId = entryId;
|
||
this._taskId = taskId;
|
||
this._objectName = objectName;
|
||
this._taskName = taskName;
|
||
this._urlMode = "companion";
|
||
this._error = "";
|
||
this._viewResult = null;
|
||
this._completeResult = null;
|
||
this._open = true;
|
||
this._generate();
|
||
}
|
||
|
||
private async _generate(): Promise<void> {
|
||
const seq = ++this._generateSeq;
|
||
this._loading = true;
|
||
this._error = "";
|
||
this._viewResult = null;
|
||
this._completeResult = null;
|
||
try {
|
||
const base: Record<string, unknown> = {
|
||
type: "maintenance_supporter/qr/generate",
|
||
entry_id: this._entryId,
|
||
url_mode: this._urlMode,
|
||
};
|
||
if (this._taskId) base.task_id = this._taskId;
|
||
|
||
// Always request "view" QR; also request "complete" if this is a task
|
||
const promises: Promise<unknown>[] = [
|
||
this.hass.connection.sendMessagePromise({ ...base, action: "view" }),
|
||
];
|
||
if (this._taskId) {
|
||
promises.push(
|
||
this.hass.connection.sendMessagePromise({ ...base, action: "complete" }),
|
||
);
|
||
}
|
||
|
||
const results = await Promise.all(promises);
|
||
if (seq !== this._generateSeq) return;
|
||
|
||
this._viewResult = results[0] as QrResult;
|
||
if (results.length > 1) {
|
||
this._completeResult = results[1] as QrResult;
|
||
}
|
||
} catch (err: unknown) {
|
||
if (seq !== this._generateSeq) return;
|
||
const code = (err as Record<string, unknown>)?.code;
|
||
const msg = (err as Record<string, unknown>)?.message;
|
||
this._error = code === "no_url" || (typeof msg === "string" && msg.includes("No Home Assistant URL"))
|
||
? t("qr_error_no_url", this.lang)
|
||
: t("qr_error", this.lang);
|
||
} finally {
|
||
if (seq === this._generateSeq) this._loading = false;
|
||
}
|
||
}
|
||
|
||
private _setUrlMode(mode: "companion" | "local" | "server"): void {
|
||
if (this._urlMode === mode) return;
|
||
this._urlMode = mode;
|
||
this._generate();
|
||
}
|
||
|
||
private _print(): void {
|
||
if (!this._viewResult) return;
|
||
const r = this._viewResult;
|
||
const title = r.label.task_name
|
||
? `${r.label.object_name} — ${r.label.task_name}`
|
||
: r.label.object_name;
|
||
const subtitle = [r.label.manufacturer, r.label.model]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
const w = window.open("", "_blank", "width=600,height=500");
|
||
if (!w) return;
|
||
const L = this.lang || "en";
|
||
const safeTitle = escapeHtml(title);
|
||
const safeSub = escapeHtml(subtitle);
|
||
|
||
const hasComplete = !!this._completeResult;
|
||
const viewLabel = escapeHtml(t("qr_action_view", L));
|
||
const completeLabel = escapeHtml(t("qr_action_complete", L));
|
||
|
||
w.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8">
|
||
<meta name="color-scheme" content="light">
|
||
<title>${safeTitle}</title>
|
||
<style>
|
||
/* Printable sheet — must not inherit the phone's dark theme. The QR images
|
||
carry their own white quiet zone and stay scannable either way, but the
|
||
labels below are explicit dark greys and would vanish on a WebView's dark
|
||
canvas. Same reasoning as helpers/report.ts. */
|
||
:root{color-scheme:light}
|
||
body{font-family:sans-serif;text-align:center;padding:20px;background:#fff;color:#1a1a1a}
|
||
h2{margin:0 0 4px}
|
||
.sub{color:#666;font-size:14px;margin-bottom:16px}
|
||
.qr-row{display:flex;justify-content:center;gap:24px;margin:12px 0}
|
||
.qr-col{display:flex;flex-direction:column;align-items:center;gap:6px}
|
||
.qr-col img{width:${hasComplete ? "200px" : "280px"}}
|
||
.qr-label{font-size:13px;font-weight:500;color:#333}
|
||
.url{font-size:10px;color:#999;word-break:break-all;margin-top:8px;max-width:480px}
|
||
</style></head><body>
|
||
<h2>${safeTitle}</h2>
|
||
${safeSub ? `<div class="sub">${safeSub}</div>` : ""}
|
||
<div class="qr-row">
|
||
<div class="qr-col">
|
||
<img src="${sanitizeDataUri(this._viewResult.svg_data_uri)}" alt="QR Info" />
|
||
<div class="qr-label">${viewLabel}</div>
|
||
</div>
|
||
${hasComplete ? `<div class="qr-col">
|
||
<img src="${sanitizeDataUri(this._completeResult!.svg_data_uri)}" alt="QR Complete" />
|
||
<div class="qr-label">${completeLabel}</div>
|
||
</div>` : ""}
|
||
</div>
|
||
<div class="url">${escapeHtml(this._viewResult.url)}</div>
|
||
<script>setTimeout(()=>window.print(),300)<\/script>
|
||
</body></html>`);
|
||
w.document.close();
|
||
}
|
||
|
||
private _downloadSvg(result: QrResult, suffix: string): void {
|
||
const svgContent = decodeURIComponent(
|
||
result.svg_data_uri.replace("data:image/svg+xml,", ""),
|
||
);
|
||
const blob = new Blob([svgContent], { type: "image/svg+xml" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
const name = this._taskName
|
||
? `${this._objectName}-${this._taskName}`
|
||
: this._objectName;
|
||
a.download = `qr-${sanitizeFilename(name)}-${suffix}.svg`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
private _close(): void {
|
||
this._open = false;
|
||
this._viewResult = null;
|
||
this._completeResult = null;
|
||
this._error = "";
|
||
this._loading = false;
|
||
}
|
||
|
||
render() {
|
||
if (!this._open) return html``;
|
||
const L = this.lang || this.hass?.language || "en";
|
||
const heading = this._taskName
|
||
? `${t("qr_code", L)}: ${this._objectName} — ${this._taskName}`
|
||
: `${t("qr_code", L)}: ${this._objectName}`;
|
||
const hasResults = !!this._viewResult;
|
||
return html`
|
||
<ha-dialog open @closed=${this._close}>
|
||
<div class="dialog-title">${heading}</div>
|
||
<div class="content">
|
||
${this._loading
|
||
? html`<div class="loading">${t("qr_generating", L)}</div>`
|
||
: this._error
|
||
? html`<div class="error">${this._error}</div>`
|
||
: hasResults
|
||
? html`
|
||
<div class="qr-pair">
|
||
<div class="qr-item">
|
||
<img
|
||
class="qr-image ${this._completeResult ? "small" : ""}"
|
||
src="${this._viewResult!.svg_data_uri}"
|
||
alt="QR Info"
|
||
/>
|
||
<div class="qr-item-label">${t("qr_action_view", L)}</div>
|
||
<button class="dl-btn"
|
||
@click=${() => this._downloadSvg(this._viewResult!, "info")}>
|
||
<ha-icon icon="mdi:download"></ha-icon>
|
||
${t("qr_download", L)}
|
||
</button>
|
||
</div>
|
||
${this._completeResult
|
||
? html`
|
||
<div class="qr-item">
|
||
<img
|
||
class="qr-image small"
|
||
src="${this._completeResult.svg_data_uri}"
|
||
alt="QR Complete"
|
||
/>
|
||
<div class="qr-item-label">${t("qr_action_complete", L)}</div>
|
||
<button class="dl-btn"
|
||
@click=${() => this._downloadSvg(this._completeResult!, "complete")}>
|
||
<ha-icon icon="mdi:download"></ha-icon>
|
||
${t("qr_download", L)}
|
||
</button>
|
||
</div>
|
||
`
|
||
: nothing}
|
||
</div>
|
||
<div class="url-display">${this._viewResult!.url}</div>
|
||
`
|
||
: nothing}
|
||
<div class="action-row">
|
||
<label>${t("qr_url_mode", L)}</label>
|
||
<div class="action-toggle">
|
||
<button class="toggle-btn ${this._urlMode === "companion" ? "active" : ""}"
|
||
@click=${() => this._setUrlMode("companion")}>${t("qr_mode_companion", L)}</button>
|
||
<button class="toggle-btn ${this._urlMode === "local" ? "active" : ""}"
|
||
@click=${() => this._setUrlMode("local")}>${t("qr_mode_local", L)}</button>
|
||
<button class="toggle-btn ${this._urlMode === "server" ? "active" : ""}"
|
||
@click=${() => this._setUrlMode("server")}>${t("qr_mode_server", L)}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="dialog-actions">
|
||
<ha-button appearance="plain" @click=${this._close}>
|
||
${t("cancel", L)}
|
||
</ha-button>
|
||
<ha-button
|
||
@click=${this._print}
|
||
.disabled=${!hasResults}
|
||
>
|
||
${t("qr_print", L)}
|
||
</ha-button>
|
||
</div>
|
||
</ha-dialog>
|
||
`;
|
||
}
|
||
|
||
static styles = css`
|
||
.dialog-title {
|
||
font-size: 18px;
|
||
font-weight: 500;
|
||
padding-bottom: 12px;
|
||
}
|
||
.content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 16px;
|
||
min-width: 300px;
|
||
}
|
||
.dialog-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
padding-top: 16px;
|
||
}
|
||
.qr-pair {
|
||
display: flex;
|
||
gap: 20px;
|
||
justify-content: center;
|
||
width: 100%;
|
||
}
|
||
.qr-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.qr-image {
|
||
width: 240px;
|
||
height: 240px;
|
||
image-rendering: pixelated;
|
||
}
|
||
.qr-image.small {
|
||
width: 180px;
|
||
height: 180px;
|
||
}
|
||
.qr-item-label {
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: var(--secondary-text-color);
|
||
text-align: center;
|
||
}
|
||
.dl-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: none;
|
||
border: 1px solid var(--divider-color, #e0e0e0);
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
color: var(--primary-text-color);
|
||
padding: 6px 14px;
|
||
border-radius: 18px;
|
||
transition: background 0.2s, border-color 0.2s;
|
||
}
|
||
.dl-btn:hover {
|
||
background: var(--secondary-background-color, #f5f5f5);
|
||
border-color: var(--primary-color);
|
||
}
|
||
.dl-btn ha-icon {
|
||
--mdc-icon-size: 18px;
|
||
}
|
||
.url-display {
|
||
font-size: 11px;
|
||
color: var(--secondary-text-color);
|
||
word-break: break-all;
|
||
text-align: center;
|
||
max-width: 400px;
|
||
}
|
||
.loading {
|
||
padding: 40px 0;
|
||
color: var(--secondary-text-color);
|
||
}
|
||
.error {
|
||
padding: 20px 0;
|
||
color: var(--error-color, #f44336);
|
||
}
|
||
.action-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
width: 100%;
|
||
}
|
||
.action-row label {
|
||
font-size: 13px;
|
||
color: var(--secondary-text-color);
|
||
}
|
||
.action-toggle {
|
||
display: flex;
|
||
gap: 4px;
|
||
background: var(--divider-color, #e0e0e0);
|
||
border-radius: 6px;
|
||
padding: 3px;
|
||
}
|
||
.toggle-btn {
|
||
flex: 1;
|
||
padding: 8px 12px;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--primary-text-color);
|
||
cursor: pointer;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
transition: all 0.2s;
|
||
line-height: 1.3;
|
||
}
|
||
.toggle-btn:hover {
|
||
background: rgba(0, 0, 0, 0.05);
|
||
}
|
||
.toggle-btn.active {
|
||
background: var(--primary-color);
|
||
color: var(--text-primary-color, #fff);
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||
}
|
||
`;
|
||
}
|
||
|
||
if (!customElements.get("maintenance-qr-dialog")) {
|
||
customElements.define("maintenance-qr-dialog", MaintenanceQrDialog);
|
||
}
|