You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

842 lines
26 KiB

<template>
<QuickNav />
<TopMessage />
<!-- 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">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</button>
Idea
</h1>
</div>
<!-- Project Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<div>
<div class="block pb-4 flex gap-4">
<div class="flex-none w-16 pt-1">
<ProjectIcon
:entityId="projectId"
:iconSize="64"
class="block border border-slate-300 rounded-md"
></ProjectIcon>
</div>
<div class="overflow-hidden">
<h2 class="text-xl font-semibold">{{ name }}</h2>
<div class="text-sm mb-3">
<div class="truncate">
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{
serverUtil.didInfo(issuer, activeDid, allMyDids, allContacts)
}}
<span v-if="!serverUtil.isEmptyOrHiddenDid(issuer)">
<button
@click="
libsUtil.doCopyTwoSecRedo(
issuer,
() => (showDidCopy = !showDidCopy),
)
"
class="ml-2 mr-2"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showDidCopy">Copied DID</span>
</span>
</div>
<div v-if="timeSince">
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
{{ timeSince }}
</div>
<div v-if="latitude || longitude">
<fa icon="location-dot" class="fa-fw text-slate-400"></fa>
<a
:href="getOpenStreetMapUrl()"
target="_blank"
class="underline"
>Map View
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</a>
</div>
<div v-if="url">
<fa icon="globe" class="fa-fw text-slate-400"></fa>
<a :href="addScheme(url)" target="_blank" class="underline"
>{{ domainForWebsite(this.url) }}
</a>
</div>
</div>
</div>
</div>
<div class="text-sm text-slate-500">
<div v-if="!expanded">
{{ truncatedDesc }}
<a
v-if="description.length >= truncateLength"
@click="expandText"
class="uppercase text-xs font-semibold text-slate-700"
>... Read More</a
>
</div>
<div v-else>
{{ description }}
<a
@click="collapseText"
class="uppercase text-xs font-semibold text-slate-700"
>- Read Less</a
>
</div>
</div>
<a @click="onClickLoadClaim(projectId)" class="cursor-pointer">
<fa icon="circle-info" class="pl-2 pt-1 text-blue-500" />
</a>
</div>
<button
v-if="activeDid === issuer || activeDid === agentDid"
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
@click="onEditClick()"
>
Edit
</button>
</div>
<div v-if="activeDid" class="mb-4">
<div class="text-center">
<button
@click="openOfferDialog()"
class="block w-full text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
>
Offer (maybe with conditions)...
</button>
</div>
</div>
<OfferDialog ref="customOfferDialog" :projectId="this.projectId" />
<div v-if="activeDid">
<GiftedDialog
ref="customGiveDialog"
message="Received from"
:projectId="this.projectId"
>
</GiftedDialog>
<div class="text-center">
<p class="mt-2 mb-4 text-center">Record a contribution from:</p>
</div>
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
<li @click="openGiftDialog({ name: 'you', did: activeDid })">
<fa icon="hand" class="fa-fw text-slate-400 text-5xl" />
<h3
class="mt-5 text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
You
</h3>
</li>
<li @click="openGiftDialog()">
<img
src="../assets/blank-square.svg"
class="mx-auto border border-slate-300 rounded-md mb-1"
/>
<h3
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
Anonymous/Unnamed
</h3>
</li>
<li
v-for="contact in allContacts.slice(0, 6)"
:key="contact.did"
@click="openGiftDialog(contact)"
>
<EntityIcon
:entityId="contact.did"
:iconSize="64"
class="mx-auto border border-slate-300 rounded-md mb-1"
/>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
{{ contact.name || "(no name)" }}
</h3>
</li>
</ul>
<!-- Ideally, this button should only be visible when the active account has more than 7 or 11 contacts in their list (we want to limit the grid count above to 8 or 12 accounts to keep it compact) -->
<a
v-if="allContacts.length >= 7"
@click="onClickAllContactsGifting()"
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
>
Show More Contacts&hellip;
</a>
</div>
<!-- Gifts to & from this -->
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4">
<div class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3">
Offered To This Idea
</h3>
<div v-if="offersToThis.length === 0">
(None yet. Wanna
<span @click="openOfferDialog()" class="cursor-pointer text-blue-500"
>offer something... especially if others join you</span
>?)
</div>
<ul v-else class="text-sm border-t border-slate-300">
<li
v-for="offer in offersToThis"
:key="offer.id"
class="py-1.5 border-b border-slate-300"
>
<div class="flex justify-between gap-4">
<span>
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{
serverUtil.didInfo(
offer.offeredByDid,
activeDid,
allMyDids,
allContacts,
)
}}
</span>
<span v-if="offer.amount" class="whitespace-nowrap">
<fa
:icon="libsUtil.iconForUnitCode(offer.unit)"
class="fa-fw text-slate-400"
/>{{ offer.amount }}
</span>
</div>
<div v-if="offer.objectDescription" class="text-slate-500">
<fa icon="comment" class="fa-fw text-slate-400" />
{{ offer.objectDescription }}
</div>
<div class="flex justify-between">
<a
@click="onClickLoadClaim(offer.jwtId as string)"
class="cursor-pointer"
>
<fa icon="circle-info" class="pl-2 pt-1 text-blue-500" />
</a>
<a
v-if="checkIsFulfillable(offer)"
@click="onClickFulfillGiveToOffer(offer)"
>
<fa
icon="hand-holding-heart"
class="text-blue-500 cursor-pointer"
/>
</a>
</div>
</li>
</ul>
</div>
<div class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3">Given To This Idea</h3>
<div v-if="givesToThis.length === 0">
(None yet. If you've seen something, say something by clicking a
contact above.)
</div>
<ul v-else class="text-sm border-t border-slate-300">
<li
v-for="give in givesToThis"
:key="give.id"
class="py-1.5 border-b border-slate-300"
>
<div class="flex justify-between gap-4">
<span
><fa icon="user" class="fa-fw text-slate-400"></fa>
{{
serverUtil.didInfo(
give.agentDid,
activeDid,
allMyDids,
allContacts,
)
}}
</span>
<span v-if="give.amount" class="whitespace-nowrap">
<fa
:icon="libsUtil.iconForUnitCode(give.unit)"
class="fa-fw text-slate-400"
/>{{ give.amount }}
</span>
</div>
<div class="text-slate-500">
<fa icon="calendar" class="fa-fw text-slate-400" />
{{ give.issuedAt?.substring(0, 10) }}
</div>
<div v-if="give.description" class="text-slate-500">
<fa icon="comment" class="fa-fw text-slate-400" />
{{ give.description }}
</div>
<div class="flex justify-between">
<a @click="onClickLoadClaim(give.jwtId)">
<fa icon="circle-info" class="text-blue-500 cursor-pointer" />
</a>
<a v-if="checkIsConfirmable(give)" @click="confirmClaim(give)">
<fa icon="circle-check" class="text-blue-500 cursor-pointer" />
</a>
</div>
</li>
</ul>
</div>
<div class="grid items-start grid-cols-1 gap-4">
<div
v-if="fulfillersToThis.length > 0"
class="bg-slate-100 px-4 py-3 rounded-md"
>
<h3 class="text-sm uppercase font-semibold mb-3">
Contributions To This Idea
</h3>
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
<div class="text-center">
<div v-for="plan in fulfillersToThis" :key="plan.handleId">
<button
@click="onClickLoadProject(plan.handleId)"
class="text-blue-500"
>
{{ plan.name }}
</button>
</div>
</div>
</div>
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3">
Contributions From This Idea
</h3>
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
<div class="text-center">
<button
@click="onClickLoadProject(fulfilledByThis.handleId)"
class="text-blue-500"
>
{{ fulfilledByThis.name }}
</button>
</div>
</div>
</div>
</div>
</section>
</template>
<script lang="ts">
import { AxiosError, RawAxiosRequestHeaders } from "axios";
import * as moment from "moment";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
import GiftedDialog from "@/components/GiftedDialog.vue";
import OfferDialog from "@/components/OfferDialog.vue";
import TopMessage from "@/components/TopMessage.vue";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import ProjectIcon from "@/components/ProjectIcon.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import * as libsUtil from "@/libs/util";
import {
BLANK_GENERIC_SERVER_RECORD,
GenericServerRecord,
GiverInputInfo,
GiveServerRecord,
OfferServerRecord,
PlanServerRecord,
} from "@/libs/endorserServer";
import * as serverUtil from "@/libs/endorserServer";
@Component({
components: {
EntityIcon,
GiftedDialog,
OfferDialog,
ProjectIcon,
QuickNav,
TopMessage,
},
})
export default class ProjectViewView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
agentDid = "";
allMyDids: Array<string> = [];
allContacts: Array<Contact> = [];
apiServer = "";
description = "";
expanded = false;
fulfilledByThis: PlanServerRecord | null = null;
fulfillersToThis: Array<PlanServerRecord> = [];
givesToThis: Array<GiveServerRecord> = [];
issuer = "";
latitude = 0;
longitude = 0;
name = "";
offersToThis: Array<OfferServerRecord> = [];
projectId = localStorage.getItem("projectId") || ""; // handle ID
showDidCopy = false;
timeSince = "";
truncatedDesc = "";
truncateLength = 40;
url = "";
libsUtil = libsUtil;
serverUtil = serverUtil;
async created() {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.allContacts = await db.contacts.toArray();
await accountsDB.open();
const accounts = accountsDB.accounts;
const accountsArr: Account[] = await accounts?.toArray();
this.allMyDids = accountsArr.map((acc) => acc.did);
const account = accountsArr.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse(account?.identity || "null");
const pathParam = window.location.pathname.substring("/project/".length);
if (pathParam) {
this.projectId = decodeURIComponent(pathParam);
}
this.loadProject(this.projectId, identity);
}
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
return identity;
}
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
onEditClick() {
localStorage.setItem("projectId", this.projectId as string);
const route = {
name: "new-edit-project",
};
this.$router.push(route);
}
// Isn't there a better way to make this available to the template?
expandText() {
this.expanded = true;
}
collapseText() {
this.expanded = false;
}
async loadProject(projectId: string, identity: IIdentifier) {
this.projectId = projectId;
const url =
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId);
const headers: RawAxiosRequestHeaders = {
"Content-Type": "application/json",
};
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
try {
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
const startTime = resp.data.startTime;
if (startTime != null) {
const eventDate = new Date(startTime);
const now = moment.now();
this.timeSince = moment.utc(now).to(eventDate);
}
this.agentDid = resp.data.claim?.agent?.identifier;
this.issuer = resp.data.issuer;
this.name = resp.data.claim?.name || "(no name)";
this.description = resp.data.claim?.description || "(no description)";
this.truncatedDesc = this.description.slice(0, this.truncateLength);
this.latitude = resp.data.claim?.location?.geo?.latitude || 0;
this.longitude = resp.data.claim?.location?.geo?.longitude || 0;
this.url = resp.data.claim?.url || "";
} else {
// actually, axios throws an error on 404 so we probably never get here
console.error("Error getting project:", resp);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem getting that project. See logs for more info.",
},
-1,
);
}
} catch (error: unknown) {
console.error("Error retrieving project:", error);
const serverError = error as AxiosError;
if (serverError.response?.status === 404) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "That project does not exist.",
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving that project. See logs for more info.",
},
-1,
);
}
}
const givesInUrl =
this.apiServer +
"/api/v2/report/givesToPlans?planIds=" +
encodeURIComponent(JSON.stringify([projectId]));
try {
const resp = await this.axios.get(givesInUrl, { headers });
if (resp.status === 200 && resp.data.data) {
this.givesToThis = resp.data.data;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve gives to this project.",
},
-1,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving gives to this project.",
},
-1,
);
console.error(
"Error retrieving gives to this project:",
serverError.message,
);
}
const offersToUrl =
this.apiServer +
"/api/v2/report/offersToPlans?planIds=" +
encodeURIComponent(JSON.stringify([projectId]));
try {
const resp = await this.axios.get(offersToUrl, { headers });
if (resp.status === 200 && resp.data.data) {
this.offersToThis = resp.data.data;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve offers to this project.",
},
-1,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving offers to this project.",
},
-1,
);
console.error(
"Error retrieving offers to this project:",
serverError.message,
);
}
const fulfilledByUrl =
this.apiServer +
"/api/v2/report/planFulfilledByPlan?planHandleId=" +
encodeURIComponent(projectId);
try {
const resp = await this.axios.get(fulfilledByUrl, { headers });
if (resp.status === 200) {
this.fulfilledByThis = resp.data.data;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve plans fulfilled by this project.",
},
-1,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving plans fulfilled by this project.",
},
-1,
);
console.error(
"Error retrieving plans fulfilled by this project:",
serverError.message,
);
}
const fulfillersToUrl =
this.apiServer +
"/api/v2/report/planFulfillersToPlan?planHandleId=" +
encodeURIComponent(projectId);
try {
const resp = await this.axios.get(fulfillersToUrl, { headers });
if (resp.status === 200) {
this.fulfillersToThis = resp.data.data;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve plan fulfillers to this project.",
},
-1,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving plan fulfillers to this project.",
},
-1,
);
console.error(
"Error retrieving plan fulfillers to this project:",
serverError.message,
);
}
}
/**
* Handle clicking on a project entry found in the list
* @param id of the project
**/
async onClickLoadProject(projectId: string) {
localStorage.setItem("projectId", projectId);
const route = {
path: "/project/" + encodeURIComponent(projectId),
};
this.$router.push(route);
this.loadProject(projectId, await this.getIdentity(this.activeDid));
}
getOpenStreetMapUrl() {
// Google URL is https://maps.google.com/?q=LAT,LONG
return (
"https://www.openstreetmap.org/?mlat=" +
this.latitude +
"&mlon=" +
this.longitude +
"#map=15/" +
this.latitude +
"/" +
this.longitude
);
}
openGiftDialog(contact?: GiverInputInfo) {
(this.$refs.customGiveDialog as GiftedDialog).open(contact);
}
openOfferDialog() {
(this.$refs.customOfferDialog as OfferDialog).open();
}
onClickAllContactsGifting() {
localStorage.setItem("projectId", this.projectId);
const route = {
name: "contact-gives",
};
this.$router.push(route);
}
onClickLoadClaim(jwtId: string) {
const route = {
path: "/claim/" + encodeURIComponent(jwtId),
};
this.$router.push(route);
}
checkIsFulfillable(offer: OfferServerRecord) {
const offerRecord: GenericServerRecord = {
...BLANK_GENERIC_SERVER_RECORD,
claim: offer.fullClaim,
claimType: "Offer",
issuer: offer.offeredByDid,
};
return libsUtil.canFulfillOffer(offerRecord);
}
onClickFulfillGiveToOffer(offer: OfferServerRecord) {
const offerRecord: GenericServerRecord = {
...BLANK_GENERIC_SERVER_RECORD,
claim: offer.fullClaim,
issuer: offer.offeredByDid,
};
const giver: GiverInputInfo = {
did: libsUtil.offerGiverDid(offerRecord),
};
(this.$refs.customGiveDialog as GiftedDialog).open(giver, offer.handleId);
}
// return an HTTPS URL if it's not a global URL
addScheme(url: string) {
if (!libsUtil.isGlobalUri(url)) {
return "https://" + url;
}
return url;
}
// return just the domain for display, if possible
domainForWebsite(url: string) {
try {
const hostname = new URL(url).hostname;
if (!hostname) {
// happens for non-http URLs
return url;
} else if (url.endsWith(hostname)) {
// it's just the domain
return hostname;
} else {
// there's more, but don't bother displaying the whole thing
return hostname + "...";
}
} catch (error: unknown) {
// must not be a valid URL
return url;
}
}
checkIsConfirmable(give: GiveServerRecord) {
const giveDetails: GenericServerRecord = {
...BLANK_GENERIC_SERVER_RECORD,
claim: give.fullClaim,
claimType: "GiveAction",
issuer: give.agentDid,
};
return libsUtil.isGiveRecordTheUserCanConfirm(giveDetails, this.activeDid);
}
// similar code is found in ClaimView
async confirmClaim(give: GiveServerRecord) {
if (confirm("Do you personally confirm that this is true?")) {
// similar logic is found in endorser-mobile
const goodClaim = serverUtil.removeSchemaContext(
serverUtil.removeVisibleToDids(
serverUtil.addLastClaimOrHandleAsIdIfMissing(
give.fullClaim,
give.jwtId,
give.handleId,
),
),
);
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
};
const result = await serverUtil.createAndSubmitClaim(
confirmationClaim,
await this.getIdentity(this.activeDid),
this.apiServer,
this.axios,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
5000,
);
} else {
console.error("Got error submitting the confirmation:", result);
const message =
(result.error?.error as string) ||
"There was a problem submitting the confirmation. See logs for more info.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: message,
},
-1,
);
}
}
}
}
</script>