<template> <QuickNav selected="Projects"></QuickNav> <!-- CONTENT --> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <!-- Breadcrumb --> <div id="ViewBreadcrumb" class="mb-8"> <h1 class="text-lg text-center font-light relative px-7"> <!-- Cancel --> <router-link :to="{ name: 'project' }" class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" ><fa icon="chevron-left" class="fa-fw"></fa ></router-link> Edit Idea </h1> </div> <!-- Project Details --> <!-- Image - (see design model) Empty --> <div> {{ errorMessage }} </div> <input type="text" placeholder="Idea Name" class="block w-full rounded border border-slate-400 mb-4 px-3 py-2" v-model="fullClaim.name" /> <div class="flex justify-center mt-4"> <span v-if="imageUrl" class="flex justify-between"> <a :href="imageUrl" target="_blank" class="text-blue-500 ml-4"> <img :src="imageUrl" class="h-24 rounded-xl" /> </a> <fa icon="trash-can" @click="confirmDeleteImage" class="text-red-500 fa-fw ml-8 mt-10" /> </span> <span v-else> <fa icon="camera" class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md" @click="openImageDialog" /> </span> </div> <ImageMethodDialog ref="imageDialog" /> <input type="text" placeholder="Other Authorized Representative" class="mt-4 block w-full rounded border border-slate-400 px-3 py-2" v-model="agentDid" /> <div class="mb-4"> <p v-if="activeDid != projectIssuerDid && agentDid != projectIssuerDid"> <span class="text-red-500">Beware!</span> If you save this, the original project owner will no longer be able to edit it. <button @click="agentDid = projectIssuerDid" class="text-blue-500"> Click here to make the original owner an authorized representative. </button> </p> </div> <textarea placeholder="Description" class="block w-full rounded border border-slate-400 mb-4 px-3 py-2" rows="5" v-model="fullClaim.description" maxlength="5000" ></textarea> <div class="text-xs text-slate-500 italic -mt-3 mb-4"> {{ fullClaim.description?.length }}/5000 max. characters </div> <input v-model="fullClaim.url" placeholder="Website" autocapitalize="none" class="block w-full rounded border border-slate-400 mb-4 px-3 py-2" /> <div class="flex mb-4 columns-3 w-full"> <input v-model="startDateInput" placeholder="Start Date" type="date" class="col-span-1 w-full rounded border border-slate-400 px-3 py-2" /> <input :disabled="!startDateInput" v-model="startTimeInput" placeholder="Start Time" type="time" class="col-span-1 w-full rounded border border-slate-400 ml-2 px-3 py-2" /> <span class="col-span-1 w-full flex justify-center">{{ zoneName }}</span> </div> <div class="flex items-center mb-4"> <input type="checkbox" class="mr-2" v-model="includeLocation" @click="includeLocation = !includeLocation" /> <label for="includeLocation">Include Location</label> </div> <div v-if="includeLocation" class="mb-4 aspect-video"> <p class="text-sm mb-2 text-slate-500"> For your security, choose a location nearby but not exactly at the place. </p> <l-map ref="map" v-model:zoom="zoom" :center="[0, 0]" class="!z-40 rounded-md" @click=" (event) => { latitude = event.latlng.lat; longitude = event.latlng.lng; } " > <l-tile-layer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" layer-type="base" name="OpenStreetMap" /> <l-marker v-if="latitude && longitude" :lat-lng="[latitude, longitude]" @click="confirmEraseLatLong()" /> </l-map> </div> <div class="mt-8"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> <button :disabled="isHiddenSave" class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2" @click="onSaveProjectClick()" > <!-- SHOW if in idle state --> <span :class="{ hidden: isHiddenSave }">Save Project</span> <!-- SHOW if in saving state; DISABLE button while in saving state --> <span :class="{ hidden: isHiddenSpinner }"> <!-- icon no worky? --> <i class="fa-solid fa-spinner fa-spin-pulse"></i> Saving...</span > </button> <button type="button" class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md" @click="onCancelClick()" > Cancel </button> </div> </div> </section> </template> <script lang="ts"> import "leaflet/dist/leaflet.css"; import { AxiosError } from "axios"; import * as didJwt from "did-jwt"; import { DateTime } from "luxon"; import { IIdentifier } from "@veramo/core"; import { Component, Vue } from "vue-facing-decorator"; import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet"; import QuickNav from "@/components/QuickNav.vue"; import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app"; import { accountsDB, db } from "@/db/index"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { accessToken, SimpleSigner } from "@/libs/crypto"; import * as libsUtil from "@/libs/util"; import { useAppStore } from "@/store/app"; import { PlanVerifiableCredential } from "@/libs/endorserServer"; import ImageMethodDialog from "@/components/ImageMethodDialog.vue"; @Component({ components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav }, }) export default class NewEditProjectView extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; errNote(message) { this.$notify( { group: "alert", type: "danger", title: "Error", text: message }, 5000, ); } activeDid = ""; agentDid = ""; apiServer = ""; errorMessage = ""; fullClaim: PlanVerifiableCredential = { "@context": "https://schema.org", "@type": "PlanAction", name: "", description: "", }; // this default is only to avoid errors before plan is loaded imageUrl = ""; includeLocation = false; isHiddenSave = false; isHiddenSpinner = true; lastClaimJwtId = ""; latitude = 0; longitude = 0; numAccounts = 0; projectId = localStorage.getItem("projectId") || ""; projectIssuerDid = ""; startDateInput?: string; startTimeInput?: string; zoneName = DateTime.local().zoneName; zoom = 2; libsUtil = libsUtil; public async getIdentity(activeDid: string) { await accountsDB.open(); const account = await accountsDB.accounts .where("did") .equals(activeDid) .first(); const identity = JSON.parse((account?.identity as string) || "null"); if (!identity) { throw new Error( "Attempted to load project records with no identifier available.", ); } return identity; } public async getHeaders(identity: IIdentifier) { const token = await accessToken(identity); const headers = { "Content-Type": "application/json", Authorization: "Bearer " + token, }; return headers; } async mounted() { await accountsDB.open(); this.numAccounts = await accountsDB.accounts.count(); await db.open(); const settings = await db.settings.get(MASTER_SETTINGS_KEY); this.activeDid = (settings?.activeDid as string) || ""; this.apiServer = (settings?.apiServer as string) || ""; if (this.projectId) { if (this.numAccounts === 0) { this.errNote("There was a problem loading your account info."); } else { const identity = await this.getIdentity(this.activeDid); if (!identity) { throw new Error( "An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.", ); } this.loadProject(identity); } } } async loadProject(identity: IIdentifier) { const url = this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(this.projectId); const token = await accessToken(identity); const headers = { "Content-Type": "application/json", Authorization: "Bearer " + token, }; try { const resp = await this.axios.get(url, { headers }); if (resp.status === 200) { this.projectIssuerDid = resp.data.issuer; this.fullClaim = resp.data.claim; this.imageUrl = resp.data.claim.image || ""; this.lastClaimJwtId = resp.data.id; if (this.fullClaim?.location) { this.includeLocation = true; this.latitude = this.fullClaim.location.geo.latitude; this.longitude = this.fullClaim.location.geo.longitude; } if (this.fullClaim?.agent?.identifier) { this.agentDid = this.fullClaim.agent.identifier; } if (this.fullClaim.startTime) { const localDateTime = DateTime.fromISO( this.fullClaim.startTime as string, ).toLocal(); this.startDateInput = localDateTime.toFormat("yyyy-MM-dd"); this.startTimeInput = localDateTime.toFormat("HH:mm"); } } } catch (error) { console.error("Got error retrieving that project", error); this.errNote("There was an error retrieving that project."); } } openImageDialog() { (this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => { this.imageUrl = imgUrl; }, "PlanAction"); } confirmDeleteImage() { this.$notify( { group: "modal", type: "confirm", title: "Are you sure you want to delete the image?", text: "", onYes: this.deleteImage, }, -1, ); } async deleteImage() { if (!this.imageUrl) { return; } try { const identity = await libsUtil.getIdentity(this.activeDid); const token = await accessToken(identity); const response = await this.axios.delete( DEFAULT_IMAGE_API_SERVER + "/image/" + encodeURIComponent(this.imageUrl), { headers: { Authorization: `Bearer ${token}`, }, }, ); if (response.status === 204) { // don't bother with a notification // (either they'll simply continue or they're canceling and going back) } else { console.error("Problem deleting image:", response); this.$notify( { group: "alert", type: "danger", title: "Error", text: "There was a problem deleting the image.", }, 5000, ); return; } this.imageUrl = ""; } catch (error) { console.error("Error deleting image:", error); // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((error as any).response.status === 404) { console.log("The image was already deleted:", error); this.imageUrl = ""; // it already doesn't exist so we won't say anything to the user } else { this.$notify( { group: "alert", type: "danger", title: "Error", text: "There was an error deleting the image.", }, 5000, ); } } } private async saveProject(identity: IIdentifier) { // Make a claim const vcClaim: PlanVerifiableCredential = this.fullClaim; if (this.projectId) { vcClaim.lastClaimId = this.lastClaimJwtId; } if (this.agentDid) { vcClaim.agent = { identifier: this.agentDid, }; } else { delete vcClaim.agent; } if (this.imageUrl) { vcClaim.image = this.imageUrl; } else { delete vcClaim.image; } if (this.includeLocation) { vcClaim.location = { geo: { "@type": "GeoCoordinates", latitude: this.latitude, longitude: this.longitude, }, }; } else { delete vcClaim.location; } if (this.startDateInput) { try { const startTimeFull = this.startTimeInput || "00:00:00"; const fullTimeString = this.startDateInput + " " + startTimeFull; // throw an error on an invalid date or time string vcClaim.startTime = new Date(fullTimeString).toISOString(); // ensure timezone is part of it } catch { // it's not a valid date so erase it and tell the user delete vcClaim.startTime; this.$notify( { group: "alert", type: "danger", title: "Error", text: "The date was invalid so it was not set.", }, 5000, ); } } else { delete vcClaim.startTime; } // Make a payload for the claim const vcPayload = { vc: { "@context": ["https://www.w3.org/2018/credentials/v1"], type: ["VerifiableCredential"], credentialSubject: vcClaim, }, }; // create a signature using private key of identity if (identity.keys[0].privateKeyHex != null) { const privateKeyHex: string = identity.keys[0].privateKeyHex; const signer = await SimpleSigner(privateKeyHex); const alg = undefined; // create a JWT for the request const vcJwt: string = await didJwt.createJWT(vcPayload, { alg: alg, issuer: identity.did, signer: signer, }); // Make the xhr request payload const payload = JSON.stringify({ jwtEncoded: vcJwt }); const url = this.apiServer + "/api/v2/claim"; const token = await accessToken(identity); const headers = { "Content-Type": "application/json", Authorization: "Bearer " + token, }; try { const resp = await this.axios.post(url, payload, { headers }); if (resp.data?.success?.handleId) { this.errorMessage = ""; useAppStore() .setProjectId(resp.data.success.handleId) .then(() => { this.$router.push({ name: "project" }); }); } else { console.error( "Got unexpected 'data' inside response from server", resp, ); this.$notify( { group: "alert", type: "danger", title: "Error Saving Idea", text: "Server did not save the idea. Try again.", }, -1, ); } } catch (error) { let userMessage = "There was an error saving the project."; const serverError = error as AxiosError<{ error?: { message?: string }; }>; if (serverError) { console.error("Got error from server", serverError); if (Object.prototype.hasOwnProperty.call(serverError, "message")) { userMessage = (serverError.response?.data?.error?.message as string) || userMessage; this.$notify( { group: "alert", type: "danger", title: "User Message", text: userMessage, }, -1, ); } else { this.$notify( { group: "alert", type: "danger", title: "Server Message", text: JSON.stringify(serverError.toJSON()), }, -1, ); } } else { console.error( "Here's the full error trying to save the claim:", error, ); this.$notify( { group: "alert", type: "danger", title: "Claim Error", text: error as string, }, -1, ); } // Now set that error for the user to see. this.errorMessage = userMessage; } } } public async onSaveProjectClick() { this.isHiddenSave = true; this.isHiddenSpinner = false; if (this.numAccounts === 0) { console.error("Error: there is no account."); } else { const identity = await this.getIdentity(this.activeDid); this.saveProject(identity); } } confirmEraseLatLong() { this.$notify( { group: "modal", type: "confirm", title: "Erase Marker", text: "Are you sure you don't want to mark a location? This will erase the current location.", onYes: async () => { this.eraseLatLong(); }, }, -1, ); } public eraseLatLong() { this.latitude = 0; this.longitude = 0; this.includeLocation = false; } public onCancelClick() { this.$router.back(); } } </script>