diff --git a/package-lock.json b/package-lock.json index 0d9584124..f5e9ca80e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@simplewebauthn/browser": "^10.0.0", "@simplewebauthn/server": "^10.0.0", "@tweenjs/tween.js": "^21.1.1", + "@types/qrcode": "^1.5.5", "@veramo/core": "^5.6.0", "@veramo/credential-w3c": "^5.6.0", "@veramo/data-store": "^5.6.0", @@ -57,6 +58,7 @@ "pina": "^0.20.2204228", "pinia-plugin-persistedstate": "^3.2.1", "qr-code-generator-vue3": "^1.4.21", + "qrcode": "^1.5.4", "ramda": "^0.29.1", "readable-stream": "^4.5.2", "reflect-metadata": "^0.1.14", @@ -9766,6 +9768,14 @@ "@types/node": "*" } }, + "node_modules/@types/qrcode": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", + "integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ramda": { "version": "0.29.11", "dev": true, @@ -13040,10 +13050,6 @@ "version": "8.0.0", "license": "MIT" }, - "node_modules/encode-utf8": { - "version": "1.0.3", - "license": "MIT" - }, "node_modules/encodeurl": { "version": "1.0.2", "license": "MIT", @@ -19720,11 +19726,11 @@ } }, "node_modules/qrcode": { - "version": "1.5.3", - "license": "MIT", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", "dependencies": { "dijkstrajs": "^1.0.1", - "encode-utf8": "^1.0.3", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, diff --git a/package.json b/package.json index b7183a85f..54421f90d 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@simplewebauthn/browser": "^10.0.0", "@simplewebauthn/server": "^10.0.0", "@tweenjs/tween.js": "^21.1.1", + "@types/qrcode": "^1.5.5", "@veramo/core": "^5.6.0", "@veramo/credential-w3c": "^5.6.0", "@veramo/data-store": "^5.6.0", @@ -61,6 +62,7 @@ "pina": "^0.20.2204228", "pinia-plugin-persistedstate": "^3.2.1", "qr-code-generator-vue3": "^1.4.21", + "qrcode": "^1.5.4", "ramda": "^0.29.1", "readable-stream": "^4.5.2", "reflect-metadata": "^0.1.14", diff --git a/public/img/background/cert-frame-2.jpg b/public/img/background/cert-frame-2.jpg new file mode 100644 index 000000000..a901aff03 Binary files /dev/null and b/public/img/background/cert-frame-2.jpg differ diff --git a/src/main.ts b/src/main.ts index 229497f8d..cd386a3f6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -43,6 +43,7 @@ import { faEraser, faEye, faEyeSlash, + faFileContract, faFileLines, faFilter, faFloppyDisk, @@ -117,6 +118,7 @@ library.add( faEraser, faEye, faEyeSlash, + faFileContract, faFileLines, faFilter, faFloppyDisk, diff --git a/src/router/index.ts b/src/router/index.ts index d255be5b6..e526302f4 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -43,6 +43,11 @@ const routes: Array<RouteRecordRaw> = [ name: "claim-add-raw", component: () => import("../views/ClaimAddRawView.vue"), }, + { + path: "/claim-cert/:id?", + name: "claim-cert", + component: () => import("../views/ClaimCertificateView.vue"), + }, { path: "/confirm-contact", name: "confirm-contact", diff --git a/src/views/ClaimCertificateView.vue b/src/views/ClaimCertificateView.vue new file mode 100644 index 000000000..c87c39eac --- /dev/null +++ b/src/views/ClaimCertificateView.vue @@ -0,0 +1,149 @@ +<template> + <section id="Content"> + <div v-if="claimData"> + <canvas ref="claimCanvas"></canvas> + </div> + </section> +</template> + +<style scoped> +canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} +</style> + +<script lang="ts"> +import { Component, Vue } from "vue-facing-decorator"; +import { nextTick } from "vue"; +import QRCode from "qrcode"; + +import { NotificationIface } from "@/constants/app"; +import { retrieveSettingsForActiveAccount } from "@/db/index"; +import * as endorserServer from "@/libs/endorserServer"; + +@Component +export default class ClaimViewCertificate extends Vue { + $notify!: (notification: NotificationIface, timeout?: number) => void; + + activeDid = ""; + allMyDids: Array<string> = []; + apiServer = ""; + claimId = ""; + claimData = null; + + endorserServer = endorserServer; + + async created() { + const settings = await retrieveSettingsForActiveAccount(); + this.activeDid = settings.activeDid || ""; + this.apiServer = settings.apiServer || ""; + const pathParams = window.location.pathname.substring( + "/claim-cert/".length, + ); + this.claimId = pathParams; + await this.fetchClaim(); + } + + async fetchClaim() { + try { + const response = await fetch( + `${this.apiServer}/api/claim/${this.claimId}`, + ); + if (response.ok) { + this.claimData = await response.json(); + await nextTick(); // Wait for the DOM to update + this.drawCanvas(); + } else { + throw new Error(`Error fetching claim: ${response.statusText}`); + } + } catch (error) { + console.error("Failed to load claim:", error); + this.$notify({ + group: "alert", + type: "danger", + title: "Error", + text: "There was a problem loading the claim.", + }); + } + } + + drawCanvas() { + const canvas = this.$refs.claimCanvas as HTMLCanvasElement; + if (canvas) { + const CANVAS_WIDTH = 1100; + const CANVAS_HEIGHT = 850; + + // size to approximate portrait of 8.5"x11" + canvas.width = CANVAS_WIDTH; + canvas.height = CANVAS_HEIGHT; + const ctx = canvas.getContext("2d"); + if (ctx) { + // Load the background image + const backgroundImage = new Image(); + backgroundImage.src = "/img/background/cert-frame-2.jpg"; + backgroundImage.onload = async () => { + // Draw the background image + ctx.drawImage(backgroundImage, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + + // Set font and styles + ctx.fillStyle = "black"; + + // Draw claim type + ctx.font = "bold 20px Arial"; + const claimTypeText = + this.endorserServer.capitalizeAndInsertSpacesBeforeCaps( + this.claimData.claimType, + ); + const claimTypeWidth = ctx.measureText(claimTypeText).width; + ctx.fillText( + claimTypeText, + (CANVAS_WIDTH - claimTypeWidth) / 2, // Center horizontally + CANVAS_HEIGHT * 0.35, + ); + + const descriptionText = + this.claimData.claim.description || this.claimData.claim.name; + if (descriptionText) { + const descriptionLine = + descriptionText.length > 50 + ? descriptionText.substring(0, 47) + "..." + : descriptionText; + ctx.font = "14px Arial"; + const descriptionWidth = ctx.measureText(descriptionLine).width; + ctx.fillText( + descriptionLine, + (CANVAS_WIDTH - descriptionWidth) / 2, + CANVAS_HEIGHT * 0.45, + ); + } + + // Draw claim ID + ctx.font = "14px Arial"; + ctx.fillText(this.claimId, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.62); + ctx.fillText( + "via EndorserSearch.com", + CANVAS_WIDTH * 0.3, + CANVAS_HEIGHT * 0.65, + ); + + // Generate and draw QR code + const qrCodeCanvas = document.createElement("canvas"); + await QRCode.toCanvas(qrCodeCanvas, window.location.href, { + width: 150, + color: { light: "#0000" /* Transparent background */ }, + }); + ctx.drawImage( + qrCodeCanvas, + CANVAS_WIDTH * 0.57, + CANVAS_HEIGHT * 0.55, + ); + }; + } + } + } +} +</script> diff --git a/src/views/ClaimView.vue b/src/views/ClaimView.vue index e58f4aad4..e318d685d 100644 --- a/src/views/ClaimView.vue +++ b/src/views/ClaimView.vue @@ -157,6 +157,14 @@ <fa icon="comment" class="text-slate-400" /> {{ issuerName }} posted that. </div> + <!-- + <div> + <router-link :to="'/claim-cert/' + encodeURIComponent(veriClaim.id)"> + <fa icon="file-contract" class="text-slate-400" /> + <span class="ml-2 text-blue-500">Printable Certificate</span> + </router-link> + </div> + --> <div class="mt-8"> <button @@ -885,7 +893,6 @@ export default class ClaimView extends Vue { this.veriClaim as GenericCredWrapper<OfferVerifiableCredential>, ), }; - console.log("giver & dialog", giver, this.$refs.customGiveDialog); (this.$refs.customGiveDialog as GiftedDialog).open( giver, undefined,