feat(PaperMessagePage): add Paper Message Generator component for creating and ingesting signed LXMF messages with QR code support

This commit is contained in:
2026-01-02 17:25:59 -06:00
parent abf54ed45f
commit 49d55ce28b

View File

@@ -0,0 +1,409 @@
<template>
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
<div class="overflow-y-auto p-4 md:p-6 max-w-5xl mx-auto w-full">
<!-- header -->
<div class="glass-card mb-6">
<div class="flex items-center gap-4">
<div class="p-3 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-2xl">
<MaterialDesignIcon icon-name="qrcode" class="size-8" />
</div>
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white tracking-tight">
Paper Message Generator
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
Generate signed LXMF messages for physical delivery or offline transfer.
</p>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- composer -->
<div class="space-y-6">
<section class="glass-card">
<div class="glass-card__header">
<h2 class="flex items-center gap-2">
<MaterialDesignIcon icon-name="pencil-outline" class="size-5 text-gray-400" />
Compose Message
</h2>
</div>
<div class="glass-card__body space-y-4">
<div>
<label
class="block text-xs font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-2"
>
Recipient Address
</label>
<input
v-model="destinationHash"
type="text"
placeholder="Destination hash (e.g. a39610...)"
class="input-field font-mono"
/>
</div>
<div>
<label
class="block text-xs font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-2"
>
Subject (Optional)
</label>
<input v-model="title" type="text" placeholder="Message title..." class="input-field" />
</div>
<div>
<label
class="block text-xs font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-2"
>
Message Content
</label>
<textarea
v-model="content"
rows="6"
placeholder="Type your message here..."
class="input-field resize-none"
></textarea>
</div>
<button
type="button"
class="w-full flex items-center justify-center gap-2 py-3 px-6 bg-blue-600 hover:bg-blue-700 text-white rounded-2xl font-bold shadow-lg shadow-blue-500/20 transition-all active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none"
:disabled="!canGenerate || isGenerating"
@click="generatePaperMessage"
>
<template v-if="isGenerating">
<div
class="size-5 border-2 border-white/20 border-t-white rounded-full animate-spin"
></div>
Generating...
</template>
<template v-else>
<MaterialDesignIcon icon-name="qrcode-plus" class="size-5" />
Generate Paper Message
</template>
</button>
</div>
</section>
<!-- read / ingest section -->
<section class="glass-card">
<div class="glass-card__header">
<h2 class="flex items-center gap-2">
<MaterialDesignIcon icon-name="qrcode-scan" class="size-5 text-gray-400" />
Ingest Paper Message
</h2>
</div>
<div class="glass-card__body space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
Paste an LXMF URI to decode and add it to your conversations.
</p>
<div class="flex gap-2">
<input
v-model="ingestUri"
type="text"
placeholder="lxmf://..."
class="input-field flex-1 font-mono"
@keydown.enter="ingestPaperMessage"
/>
<button
type="button"
class="px-4 py-2 bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 rounded-xl hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors"
@click="pasteFromClipboard"
>
<MaterialDesignIcon icon-name="content-paste" class="size-5" />
</button>
</div>
<button
type="button"
class="w-full py-3 px-6 bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 rounded-2xl font-bold hover:bg-gray-200 dark:hover:bg-zinc-700 transition-all active:scale-[0.98]"
:disabled="!ingestUri"
@click="ingestPaperMessage"
>
Read LXM
</button>
</div>
</section>
</div>
<!-- preview / result -->
<div class="space-y-6">
<section v-if="generatedUri" class="glass-card overflow-hidden">
<div class="glass-card__header bg-blue-50/50 dark:bg-blue-900/10">
<h2 class="text-blue-600 dark:text-blue-400">Generated QR Code</h2>
</div>
<div class="glass-card__body flex flex-col items-center p-8">
<div class="p-6 bg-white rounded-3xl shadow-inner border border-gray-100 mb-8">
<div
ref="qrcode"
class="size-64 sm:size-80 flex items-center justify-center overflow-hidden"
>
<!-- qr code will be rendered here -->
</div>
</div>
<div class="w-full space-y-4">
<div
class="bg-gray-50 dark:bg-zinc-800/50 rounded-2xl p-4 border border-gray-100 dark:border-zinc-700/50"
>
<label
class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-2"
>
LXMF URI
</label>
<div class="flex gap-3">
<div
class="flex-1 font-mono text-xs break-all text-gray-600 dark:text-zinc-300 bg-white dark:bg-zinc-900 p-3 rounded-xl border border-gray-200 dark:border-zinc-700 max-h-24 overflow-y-auto"
>
{{ generatedUri }}
</div>
<button
type="button"
class="size-10 flex items-center justify-center bg-white dark:bg-zinc-900 text-gray-500 dark:text-zinc-400 rounded-xl border border-gray-200 dark:border-zinc-700 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200 transition-all shadow-sm"
title="Copy URI"
@click="copyUri"
>
<MaterialDesignIcon icon-name="content-copy" class="size-5" />
</button>
</div>
</div>
<div class="flex gap-3 pt-2">
<button
type="button"
class="flex-1 flex items-center justify-center gap-2 py-3.5 px-6 bg-blue-600 hover:bg-blue-700 text-white rounded-2xl font-bold shadow-lg shadow-blue-500/20 transition-all active:scale-[0.98]"
@click="printQRCode"
>
<MaterialDesignIcon icon-name="printer" class="size-5" />
Print
</button>
<button
type="button"
class="flex-1 flex items-center justify-center gap-2 py-3.5 px-6 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-200 rounded-2xl font-bold transition-all active:scale-[0.98]"
@click="downloadQRCode"
>
<MaterialDesignIcon icon-name="download" class="size-5" />
Save
</button>
</div>
</div>
</div>
</section>
<div
v-else
class="glass-card flex flex-col items-center justify-center p-12 text-center h-[400px] border-dashed"
>
<div class="p-4 bg-gray-100 dark:bg-zinc-800 text-gray-400 rounded-full mb-4">
<MaterialDesignIcon icon-name="qrcode" class="size-12" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">No QR Code Generated</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 max-w-xs">
Fill out the message details and click generate to create a signed paper message.
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import WebSocketConnection from "../../js/WebSocketConnection";
import ToastUtils from "../../js/ToastUtils";
export default {
name: "PaperMessagePage",
components: { MaterialDesignIcon },
data() {
return {
destinationHash: "",
title: "",
content: "",
isGenerating: false,
generatedUri: null,
ingestUri: "",
qrCodeLibrary: null,
};
},
computed: {
canGenerate() {
return this.destinationHash.length === 32 && this.content.length > 0;
},
},
mounted() {
WebSocketConnection.on("message", this.onWebsocketMessage);
this.loadQRCodeLibrary();
},
beforeUnmount() {
WebSocketConnection.off("message", this.onWebsocketMessage);
},
methods: {
async loadQRCodeLibrary() {
if (window.QRCode) {
this.qrCodeLibrary = window.QRCode;
return;
}
return new Promise((resolve) => {
const script = document.createElement("script");
script.src = "https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js";
script.onload = () => {
this.qrCodeLibrary = window.QRCode;
resolve();
};
document.head.appendChild(script);
});
},
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
if (json.type === "lxm.generate_paper_uri.result") {
this.isGenerating = false;
if (json.status === "success") {
this.generatedUri = json.uri;
this.$nextTick(() => {
this.renderQRCode();
});
} else {
ToastUtils.error(json.message);
}
} else if (json.type === "lxm.ingest_uri.result") {
if (json.status === "success") {
ToastUtils.success(json.message);
this.ingestUri = "";
} else if (json.status === "error") {
ToastUtils.error(json.message);
} else {
ToastUtils.warning(json.message);
}
}
},
async generatePaperMessage() {
if (!this.canGenerate) return;
this.isGenerating = true;
this.generatedUri = null;
WebSocketConnection.send(
JSON.stringify({
type: "lxm.generate_paper_uri",
destination_hash: this.destinationHash,
content: this.content,
title: this.title,
})
);
},
renderQRCode() {
if (!this.qrCodeLibrary || !this.$refs.qrcode) return;
this.$refs.qrcode.innerHTML = "";
new this.qrCodeLibrary(this.$refs.qrcode, {
text: this.generatedUri,
width: 320,
height: 320,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: this.qrCodeLibrary.CorrectLevel.L,
});
const el = this.$refs.qrcode.querySelector("img") || this.$refs.qrcode.querySelector("canvas");
if (el) {
el.style.maxWidth = "100%";
el.style.height = "auto";
el.classList.add("rounded-lg");
}
},
async ingestPaperMessage() {
if (!this.ingestUri) return;
WebSocketConnection.send(
JSON.stringify({
type: "lxm.ingest_uri",
uri: this.ingestUri,
})
);
},
async pasteFromClipboard() {
try {
this.ingestUri = await navigator.clipboard.readText();
} catch {
ToastUtils.error("Failed to read from clipboard");
}
},
async copyUri() {
try {
await navigator.clipboard.writeText(this.generatedUri);
ToastUtils.success("URI copied to clipboard");
} catch {
ToastUtils.error("Failed to copy URI");
}
},
downloadQRCode() {
const canvas = this.$refs.qrcode.querySelector("canvas");
const img = this.$refs.qrcode.querySelector("img");
let dataUrl = "";
if (img && img.src) {
dataUrl = img.src;
} else if (canvas) {
dataUrl = canvas.toDataURL("image/png");
}
if (dataUrl) {
const link = document.createElement("a");
link.download = `lxmf-paper-message-${Date.now()}.png`;
link.href = dataUrl;
link.click();
}
},
printQRCode() {
const dataUrl =
this.$refs.qrcode.querySelector("img")?.src ||
this.$refs.qrcode.querySelector("canvas")?.toDataURL("image/png");
if (!dataUrl) return;
const printWindow = window.open("", "_blank");
printWindow.document.write(`
<html>
<head>
<title>LXMF Paper Message</title>
<style>
body { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; font-family: sans-serif; }
img { width: 400px; height: 400px; margin-bottom: 20px; }
.hash { font-family: monospace; font-size: 12px; color: #666; }
@media print { body { height: auto; padding: 20px; } }
</style>
</head>
<body>
<h1>LXMF Paper Message</h1>
<img src="${dataUrl}" />
<div class="hash">Recipient: ${this.destinationHash}</div>
<p>Scan this code with an LXMF-compatible app to read the message.</p>
<script>window.onload = () => { window.print(); window.close(); }</scr' + 'ipt>
</body>
</html>
`);
printWindow.document.close();
},
},
};
</script>
<style scoped>
.glass-card {
@apply bg-white/90 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-lg flex flex-col;
}
.glass-card__header {
@apply flex items-center justify-between gap-3 px-6 py-4 border-b border-gray-100/70 dark:border-zinc-800/80;
}
.glass-card__header h2 {
@apply text-lg font-semibold text-gray-900 dark:text-white;
}
.glass-card__body {
@apply px-6 py-6 text-gray-900 dark:text-gray-100;
}
.input-field {
@apply bg-gray-50/90 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 text-sm rounded-2xl focus:ring-2 focus:ring-blue-400 focus:border-blue-400 dark:focus:ring-blue-50 dark:focus:border-blue-500 block w-full p-2.5 text-gray-900 dark:text-gray-100 transition;
}
</style>