Browse Source

add an image to projects (which shows on all ProjectIcons except for offers)

pull/117/head
Trent Larson 6 months ago
parent
commit
64f5656f41
  1. 13
      src/components/ProjectIcon.vue
  2. 4
      src/libs/endorserServer.ts
  3. 18
      src/views/DiscoverView.vue
  4. 132
      src/views/NewEditProjectView.vue
  5. 13
      src/views/ProjectViewView.vue
  6. 75
      src/views/ProjectsView.vue

13
src/components/ProjectIcon.vue

@ -1,5 +1,5 @@
<template> <template>
<div v-html="generateIdenticon()" class="w-fit"></div> <div v-html="generateIdenticon()" class="h-full w-full object-contain"></div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { toSvg } from "jdenticon"; import { toSvg } from "jdenticon";
@ -21,11 +21,16 @@ const BLANK_CONFIG = {
export default class ProjectIcon extends Vue { export default class ProjectIcon extends Vue {
@Prop entityId = ""; @Prop entityId = "";
@Prop iconSize = 0; @Prop iconSize = 0;
@Prop imageUrl = "";
generateIdenticon() { generateIdenticon() {
const config = this.entityId ? undefined : BLANK_CONFIG; if (this.imageUrl) {
const svgString = toSvg(this.entityId, this.iconSize, config); return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`;
return svgString; } else {
const config = this.entityId ? undefined : BLANK_CONFIG;
const svgString = toSvg(this.entityId, this.iconSize, config);
return svgString;
}
} }
} }
</script> </script>

4
src/libs/endorserServer.ts

@ -118,6 +118,7 @@ export interface PlanSummaryRecord {
endTime?: string; endTime?: string;
fulfillsPlanHandleId: string; fulfillsPlanHandleId: string;
handleId: string; handleId: string;
image?: string;
issuerDid: string; issuerDid: string;
locLat?: number; locLat?: number;
locLon?: number; locLon?: number;
@ -175,7 +176,7 @@ export interface PlanVerifiableCredential {
* Represents data about a project * Represents data about a project
* *
* @deprecated * @deprecated
* We should use PlanServerRecord instead. * We should use PlanSummaryRecord instead.
**/ **/
export interface PlanData { export interface PlanData {
/** /**
@ -190,6 +191,7 @@ export interface PlanData {
* URL referencing information about the project * URL referencing information about the project
**/ **/
handleId: string; handleId: string;
image?: string;
/** /**
* The DID of the issuer * The DID of the issuer
*/ */

18
src/views/DiscoverView.vue

@ -102,12 +102,13 @@
@click="onClickLoadProject(project.handleId)" @click="onClickLoadProject(project.handleId)"
class="block py-4 flex gap-4" class="block py-4 flex gap-4"
> >
<div class="w-12"> <div>
<ProjectIcon <ProjectIcon
:entityId="project.handleId" :entityId="project.handleId"
:iconSize="48" :iconSize="48"
class="block border border-slate-300 rounded-md" :imageUrl="project.image"
></ProjectIcon> class="block border border-slate-300 rounded-md max-h-12 max-w-12"
/>
</div> </div>
<div class="grow"> <div class="grow">
@ -271,8 +272,15 @@ export default class DiscoverView extends Vue {
const plans: PlanData[] = results.data; const plans: PlanData[] = results.data;
if (plans) { if (plans) {
for (const plan of plans) { for (const plan of plans) {
const { name, description, handleId, issuerDid, rowid } = plan; const { name, description, handleId, image, issuerDid, rowid } = plan;
this.projects.push({ name, description, handleId, issuerDid, rowid }); this.projects.push({
name,
description,
handleId,
image,
issuerDid,
rowid,
});
} }
this.remoteCount = this.projects.length; this.remoteCount = this.projects.length;
} else { } else {

132
src/views/NewEditProjectView.vue

@ -29,10 +29,31 @@
v-model="fullClaim.name" 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 <input
type="text" type="text"
placeholder="Other Authorized Representative" placeholder="Other Authorized Representative"
class="block w-full rounded border border-slate-400 px-3 py-2" class="mt-4 block w-full rounded border border-slate-400 px-3 py-2"
v-model="agentDid" v-model="agentDid"
/> />
<div class="mb-4"> <div class="mb-4">
@ -155,23 +176,31 @@ import "leaflet/dist/leaflet.css";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import * as didJwt from "did-jwt"; import * as didJwt from "did-jwt";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet"; import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app"; import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken, SimpleSigner } from "@/libs/crypto"; import { accessToken, SimpleSigner } from "@/libs/crypto";
import * as libsUtil from "@/libs/util";
import { useAppStore } from "@/store/app"; import { useAppStore } from "@/store/app";
import { IIdentifier } from "@veramo/core";
import { PlanVerifiableCredential } from "@/libs/endorserServer"; import { PlanVerifiableCredential } from "@/libs/endorserServer";
import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
@Component({ @Component({
components: { LMap, LMarker, LTileLayer, QuickNav }, components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
}) })
export default class NewEditProjectView extends Vue { export default class NewEditProjectView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
errNote(message) {
this.$notify(
{ group: "alert", type: "danger", title: "Error", text: message },
5000,
);
}
activeDid = ""; activeDid = "";
agentDid = ""; agentDid = "";
@ -183,6 +212,7 @@ export default class NewEditProjectView extends Vue {
name: "", name: "",
description: "", description: "",
}; // this default is only to avoid errors before plan is loaded }; // this default is only to avoid errors before plan is loaded
imageUrl = "";
includeLocation = false; includeLocation = false;
isHiddenSave = false; isHiddenSave = false;
isHiddenSpinner = true; isHiddenSpinner = true;
@ -197,10 +227,7 @@ export default class NewEditProjectView extends Vue {
zoneName = DateTime.local().zoneName; zoneName = DateTime.local().zoneName;
zoom = 2; zoom = 2;
async beforeCreate() { libsUtil = libsUtil;
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
}
public async getIdentity(activeDid: string) { public async getIdentity(activeDid: string) {
await accountsDB.open(); await accountsDB.open();
@ -228,6 +255,9 @@ export default class NewEditProjectView extends Vue {
} }
async mounted() { async mounted() {
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
await db.open(); await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY); const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = (settings?.activeDid as string) || ""; this.activeDid = (settings?.activeDid as string) || "";
@ -235,7 +265,7 @@ export default class NewEditProjectView extends Vue {
if (this.projectId) { if (this.projectId) {
if (this.numAccounts === 0) { if (this.numAccounts === 0) {
console.error("Error: no account was found."); this.errNote("There was a problem loading your account info.");
} else { } else {
const identity = await this.getIdentity(this.activeDid); const identity = await this.getIdentity(this.activeDid);
if (!identity) { if (!identity) {
@ -264,6 +294,7 @@ export default class NewEditProjectView extends Vue {
if (resp.status === 200) { if (resp.status === 200) {
this.projectIssuerDid = resp.data.issuer; this.projectIssuerDid = resp.data.issuer;
this.fullClaim = resp.data.claim; this.fullClaim = resp.data.claim;
this.imageUrl = resp.data.claim.image || "";
this.lastClaimJwtId = resp.data.id; this.lastClaimJwtId = resp.data.id;
if (this.fullClaim?.location) { if (this.fullClaim?.location) {
this.includeLocation = true; this.includeLocation = true;
@ -283,6 +314,84 @@ export default class NewEditProjectView extends Vue {
} }
} catch (error) { } catch (error) {
console.error("Got error retrieving that project", 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,
);
}
} }
} }
@ -299,6 +408,11 @@ export default class NewEditProjectView extends Vue {
} else { } else {
delete vcClaim.agent; delete vcClaim.agent;
} }
if (this.imageUrl) {
vcClaim.image = this.imageUrl;
} else {
delete vcClaim.image;
}
if (this.includeLocation) { if (this.includeLocation) {
vcClaim.location = { vcClaim.location = {
geo: { geo: {

13
src/views/ProjectViewView.vue

@ -22,13 +22,14 @@
<!-- Project Details --> <!-- Project Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4"> <div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4">
<div> <div>
<div class="block pb-4 flex gap-4"> <div class="pb-4 flex gap-4">
<div class="flex-none w-16 pt-1"> <div class="pt-1">
<ProjectIcon <ProjectIcon
:entityId="projectId" :entityId="projectId"
:iconSize="64" :iconSize="64"
class="block border border-slate-300 rounded-md" :imageUrl="imageUrl"
></ProjectIcon> class="block border border-slate-300 rounded-md max-h-16 max-w-16"
/>
</div> </div>
<div class="overflow-hidden"> <div class="overflow-hidden">
@ -399,6 +400,7 @@ export default class ProjectViewView extends Vue {
fulfillersToHitLimit = false; fulfillersToHitLimit = false;
givesToThis: Array<GiveSummaryRecord> = []; givesToThis: Array<GiveSummaryRecord> = [];
givesHitLimit = false; givesHitLimit = false;
imageUrl = "";
issuer = ""; issuer = "";
latitude = 0; latitude = 0;
longitude = 0; longitude = 0;
@ -427,7 +429,7 @@ export default class ProjectViewView extends Vue {
const accountsArr: Account[] = await accounts?.toArray(); const accountsArr: Account[] = await accounts?.toArray();
this.allMyDids = accountsArr.map((acc) => acc.did); this.allMyDids = accountsArr.map((acc) => acc.did);
const account = accountsArr.find((acc) => acc.did === this.activeDid); const account = accountsArr.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse(account?.identity || "null"); const identity = JSON.parse((account?.identity as string) || "null");
const pathParam = window.location.pathname.substring("/project/".length); const pathParam = window.location.pathname.substring("/project/".length);
if (pathParam) { if (pathParam) {
@ -488,6 +490,7 @@ export default class ProjectViewView extends Vue {
startDateTime.toLocaleTimeString(); startDateTime.toLocaleTimeString();
} }
this.agentDid = resp.data.claim?.agent?.identifier; this.agentDid = resp.data.claim?.agent?.identifier;
this.imageUrl = resp.data.claim?.image;
this.issuer = resp.data.issuer; this.issuer = resp.data.issuer;
this.name = resp.data.claim?.name || "(no name)"; this.name = resp.data.claim?.name || "(no name)";
this.description = resp.data.claim?.description || "(no description)"; this.description = resp.data.claim?.description || "(no description)";

75
src/views/ProjectsView.vue

@ -86,12 +86,12 @@
:key="offer.handleId" :key="offer.handleId"
> >
<div class="block py-4 flex gap-4"> <div class="block py-4 flex gap-4">
<div v-if="offer.fulfillsPlanHandleId" class="flex-none w-12"> <div v-if="offer.fulfillsPlanHandleId" class="flex-none">
<ProjectIcon <ProjectIcon
:entityId="offer.fulfillsPlanHandleId" :entityId="offer.fulfillsPlanHandleId"
:iconSize="48" :iconSize="48"
class="inline-block align-middle border border-slate-300 rounded-md" class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12"
></ProjectIcon> />
</div> </div>
<div v-if="offer.recipientDid" class="flex-none w-12"> <div v-if="offer.recipientDid" class="flex-none w-12">
<EntityIcon <EntityIcon
@ -189,12 +189,13 @@
@click="onClickLoadProject(project.handleId)" @click="onClickLoadProject(project.handleId)"
class="block py-4 flex gap-4" class="block py-4 flex gap-4"
> >
<div class="flex-none w-12"> <div class="flex-none">
<ProjectIcon <ProjectIcon
:entityId="project.handleId" :entityId="project.handleId"
:iconSize="48" :iconSize="48"
class="inline-block align-middle border border-slate-300 rounded-md" :imageUrl="project.image"
></ProjectIcon> class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12"
/>
</div> </div>
<div class="grow overflow-hidden"> <div class="grow overflow-hidden">
@ -211,6 +212,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { AxiosRequestConfig } from "axios";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
@ -231,6 +233,12 @@ import EntityIcon from "@/components/EntityIcon.vue";
}) })
export default class ProjectsView extends Vue { export default class ProjectsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
errNote(message) {
this.$notify(
{ group: "alert", type: "danger", title: "Error", text: message },
5000,
);
}
apiServer = ""; apiServer = "";
projects: PlanData[] = []; projects: PlanData[] = [];
@ -256,30 +264,14 @@ export default class ProjectsView extends Vue {
this.numAccounts = await accountsDB.accounts.count(); this.numAccounts = await accountsDB.accounts.count();
if (this.numAccounts === 0) { if (this.numAccounts === 0) {
console.error("No accounts found."); console.error("No accounts found.");
this.$notify( this.errNote("You need an identifier to load your projects.");
{
group: "alert",
type: "danger",
title: "Error",
text: "You need an identifier to load your projects.",
},
-1,
);
} else { } else {
this.currentIid = await this.getIdentity(activeDid); this.currentIid = await this.getIdentity(activeDid);
await this.loadOffers(); await this.loadOffers();
} }
} catch (err) { } catch (err) {
console.error("Error initializing:", err); console.error("Error initializing:", err);
this.$notify( this.errNote("Something went wrong loading your projects.");
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong loading your projects.",
},
-1,
);
} }
} }
@ -296,12 +288,19 @@ export default class ProjectsView extends Vue {
try { try {
this.isLoading = true; this.isLoading = true;
const resp = await this.axios.get(url, { headers }); const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
if (resp.status === 200 && resp.data.data) { if (resp.status === 200 && resp.data.data) {
const plans: PlanData[] = resp.data.data; const plans: PlanData[] = resp.data.data;
for (const plan of plans) { for (const plan of plans) {
const { name, description, handleId, issuerDid, rowid } = plan; const { name, description, handleId, image, issuerDid, rowid } = plan;
this.projects.push({ name, description, handleId, issuerDid, rowid }); this.projects.push({
name,
description,
image,
handleId,
issuerDid,
rowid,
});
} }
} else { } else {
console.error( console.error(
@ -309,28 +308,12 @@ export default class ProjectsView extends Vue {
resp.status, resp.status,
resp.data, resp.data,
); );
this.$notify( this.errNote("Failed to get projects from the server.");
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to get projects from the server. Try again later.",
},
-1,
);
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (error: any) {
console.error("Got error loading plans:", error.message || error); console.error("Got error loading plans:", error.message || error);
this.$notify( this.errNote("Got an error loading projects.");
{
group: "alert",
type: "danger",
title: "Error",
text: "Got an error loading projects.",
},
-1,
);
} finally { } finally {
this.isLoading = false; this.isLoading = false;
} }
@ -421,7 +404,7 @@ export default class ProjectsView extends Vue {
try { try {
this.isLoading = true; this.isLoading = true;
const resp = await this.axios.get(url, { headers }); const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
if (resp.status === 200 && resp.data.data) { if (resp.status === 200 && resp.data.data) {
this.offers = this.offers.concat(resp.data.data); this.offers = this.offers.concat(resp.data.data);
} else { } else {

Loading…
Cancel
Save