Matthew Aaron Raymer
1 year ago
23 changed files with 739 additions and 58 deletions
Before Width: | Height: | Size: 4.5 MiB |
After Width: | Height: | Size: 705 KiB |
@ -0,0 +1,104 @@ |
|||||
|
<template> |
||||
|
<div v-if="visible" class="dialog-overlay"> |
||||
|
<div class="dialog"> |
||||
|
<h1 class="text-lg text-center"> |
||||
|
Received from {{ contact?.name || "nobody in particular" }} |
||||
|
</h1> |
||||
|
<p class="py-2">{{ message }}</p> |
||||
|
<input |
||||
|
type="text" |
||||
|
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2" |
||||
|
placeholder="What you received" |
||||
|
v-model="description" |
||||
|
/> |
||||
|
<div class="flex flex-row"> |
||||
|
<span class="py-4">Hours</span> |
||||
|
<input |
||||
|
type="text" |
||||
|
class="block w-8 rounded border border-slate-400 ml-4 text-center" |
||||
|
v-model="hours" |
||||
|
/> |
||||
|
<div class="flex flex-col px-1"> |
||||
|
<div> |
||||
|
<fa icon="square-caret-up" size="2xl" @click="increment()" /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<fa icon="square-caret-down" size="2xl" @click="decrement()" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="text-right"> |
||||
|
<button class="rounded border border-slate-400" @click="confirm"> |
||||
|
<span class="m-2">Confirm</span> |
||||
|
</button> |
||||
|
|
||||
|
<button class="rounded border border-slate-400" @click="cancel"> |
||||
|
<span class="m-2">Cancel</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
export default { |
||||
|
props: ["message"], |
||||
|
data() { |
||||
|
return { |
||||
|
contact: null, |
||||
|
description: "", |
||||
|
hours: "0", |
||||
|
visible: false, |
||||
|
}; |
||||
|
}, |
||||
|
methods: { |
||||
|
open(contact) { |
||||
|
this.contact = contact; |
||||
|
this.visible = true; |
||||
|
}, |
||||
|
close() { |
||||
|
this.visible = false; |
||||
|
}, |
||||
|
increment() { |
||||
|
this.hours = `${(parseFloat(this.hours) || 0) + 1}`; |
||||
|
}, |
||||
|
decrement() { |
||||
|
this.hours = `${Math.max(0, (parseFloat(this.hours) || 1) - 1)}`; |
||||
|
}, |
||||
|
confirm() { |
||||
|
this.close(); |
||||
|
this.$emit("dialog-result", { |
||||
|
action: "confirm", |
||||
|
contact: this.contact, |
||||
|
hours: parseFloat(this.hours), |
||||
|
description: this.description, |
||||
|
}); |
||||
|
}, |
||||
|
cancel() { |
||||
|
this.close(); |
||||
|
this.$emit("dialog-result", { action: "cancel" }); |
||||
|
}, |
||||
|
}, |
||||
|
}; |
||||
|
</script> |
||||
|
|
||||
|
<style> |
||||
|
.dialog-overlay { |
||||
|
position: fixed; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
background-color: rgba(0, 0, 0, 0.5); |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
} |
||||
|
|
||||
|
.dialog { |
||||
|
background-color: white; |
||||
|
padding: 1rem; |
||||
|
border-radius: 0.5rem; |
||||
|
width: 50%; |
||||
|
} |
||||
|
</style> |
@ -1,15 +1,360 @@ |
|||||
<template> |
<template> |
||||
<section></section> |
<!-- QUICK NAV --> |
||||
|
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50"> |
||||
|
<ul class="flex text-2xl p-2 gap-2"> |
||||
|
<!-- Home Feed --> |
||||
|
<li class="basis-1/5 rounded-md bg-slate-400 text-white"> |
||||
|
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1" |
||||
|
><fa icon="house-chimney" class="fa-fw"></fa |
||||
|
></router-link> |
||||
|
</li> |
||||
|
<!-- Search --> |
||||
|
<li class="basis-1/5 rounded-md text-slate-500"> |
||||
|
<router-link |
||||
|
:to="{ name: 'discover' }" |
||||
|
class="block text-center py-3 px-1" |
||||
|
><fa icon="magnifying-glass" class="fa-fw"></fa |
||||
|
></router-link> |
||||
|
</li> |
||||
|
<!-- Projects --> |
||||
|
<li class="basis-1/5 rounded-md text-slate-500"> |
||||
|
<router-link |
||||
|
:to="{ name: 'projects' }" |
||||
|
class="block text-center py-3 px-1" |
||||
|
><fa icon="folder-open" class="fa-fw"></fa |
||||
|
></router-link> |
||||
|
</li> |
||||
|
<!-- Contacts --> |
||||
|
<li class="basis-1/5 rounded-md text-slate-500"> |
||||
|
<router-link |
||||
|
:to="{ name: 'contacts' }" |
||||
|
class="block text-center py-3 px-1" |
||||
|
><fa icon="users" class="fa-fw"></fa |
||||
|
></router-link> |
||||
|
</li> |
||||
|
<!-- Profile --> |
||||
|
<li class="basis-1/5 rounded-md text-slate-500"> |
||||
|
<router-link |
||||
|
:to="{ name: 'account' }" |
||||
|
class="block text-center py-3 px-1" |
||||
|
><fa icon="circle-user" class="fa-fw"></fa |
||||
|
></router-link> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</nav> |
||||
|
|
||||
|
<!-- CONTENT --> |
||||
|
<section id="Content" class="p-6 pb-24"> |
||||
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> |
||||
|
Time Safari |
||||
|
</h1> |
||||
|
|
||||
|
<div> |
||||
|
<h1 class="text-2xl">Quick Action</h1> |
||||
|
<p>Choose a contact to whom to show appreciation:</p> |
||||
|
<div class="px-4"> |
||||
|
<button |
||||
|
v-for="contact in allContacts" |
||||
|
:key="contact.did" |
||||
|
@click="openDialog(contact)" |
||||
|
class="text-blue-500" |
||||
|
> |
||||
|
{{ contact.name }}, |
||||
|
</button> |
||||
|
or |
||||
|
<button @click="openDialog()" class="text-blue-500"> |
||||
|
nobody in particular |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<GiftedDialog |
||||
|
ref="customDialog" |
||||
|
@dialog-result="handleDialogResult" |
||||
|
message="Confirm to publish to the world." |
||||
|
> |
||||
|
</GiftedDialog> |
||||
|
|
||||
|
<div class="py-4"> |
||||
|
<h1 class="text-2xl">Latest Activity</h1> |
||||
|
<span :class="{ hidden: isHiddenSpinner }"> |
||||
|
<fa icon="spinner" class="fa-fw"></fa> |
||||
|
Loading… |
||||
|
</span> |
||||
|
<ul class=""> |
||||
|
<li |
||||
|
class="border-b border-slate-300" |
||||
|
v-for="record in feedData" |
||||
|
:key="record.jwtId" |
||||
|
> |
||||
|
<div |
||||
|
class="border-b text-orange-400 px-8 py-4" |
||||
|
v-if="record.jwtId == feedLastViewedId" |
||||
|
> |
||||
|
You've seen all claims below. |
||||
|
</div> |
||||
|
{{ this.giveDescription(record) }} |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</section> |
||||
|
|
||||
|
<!-- This same popup code is in many files. --> |
||||
|
<div v-bind:class="computedAlertClassNames()"> |
||||
|
<button |
||||
|
class="close-button bg-slate-200 w-8 leading-loose rounded-full absolute top-2 right-2" |
||||
|
@click="onClickClose()" |
||||
|
> |
||||
|
<fa icon="xmark"></fa> |
||||
|
</button> |
||||
|
<h4 class="font-bold pr-5">{{ alertTitle }}</h4> |
||||
|
<p>{{ alertMessage }}</p> |
||||
|
</div> |
||||
</template> |
</template> |
||||
|
|
||||
<script lang="ts"> |
<script lang="ts"> |
||||
|
import * as R from "ramda"; |
||||
import { Options, Vue } from "vue-class-component"; |
import { Options, Vue } from "vue-class-component"; |
||||
import HelloWorld from "@/components/HelloWorld.vue"; // @ is an alias to /src |
|
||||
|
import GiftedDialog from "@/components/GiftedDialog.vue"; |
||||
|
import { db, accountsDB } from "@/db"; |
||||
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; |
||||
|
import { accessToken } from "@/libs/crypto"; |
||||
|
import { createAndSubmitGive, didInfo } from "@/libs/endorserServer"; |
||||
|
import { Account } from "@/db/tables/accounts"; |
||||
|
import { Contact } from "@/db/tables/contacts"; |
||||
|
|
||||
@Options({ |
@Options({ |
||||
components: { |
components: { GiftedDialog }, |
||||
HelloWorld, |
|
||||
}, |
|
||||
}) |
}) |
||||
export default class HomeView extends Vue {} |
export default class HomeView extends Vue { |
||||
|
activeDid = ""; |
||||
|
allAccounts: Array<Account> = []; |
||||
|
allContacts: Array<Contact> = []; |
||||
|
apiServer = ""; |
||||
|
feedAllLoaded = false; |
||||
|
feedData = []; |
||||
|
feedPreviousOldestId = null; |
||||
|
feedLastViewedId = null; |
||||
|
isHiddenSpinner = true; |
||||
|
|
||||
|
// 'created' hook runs when the Vue instance is first created |
||||
|
async created() { |
||||
|
await accountsDB.open(); |
||||
|
this.allAccounts = await accountsDB.accounts.toArray(); |
||||
|
await db.open(); |
||||
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY); |
||||
|
this.activeDid = settings?.activeDid || ""; |
||||
|
this.allContacts = await db.contacts.toArray(); |
||||
|
this.feedLastViewedId = settings?.lastViewedClaimId; |
||||
|
} |
||||
|
|
||||
|
// 'mounted' hook runs after initial render |
||||
|
async mounted() { |
||||
|
try { |
||||
|
await db.open(); |
||||
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY); |
||||
|
this.apiServer = settings?.apiServer || ""; |
||||
|
|
||||
|
this.updateAllFeed(); |
||||
|
} catch (err) { |
||||
|
console.log("Error in mounted():", err); |
||||
|
this.alertTitle = "Error"; |
||||
|
this.alertMessage = |
||||
|
err.userMessage || |
||||
|
"There was an error retrieving the latest sweet, sweet action."; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
updateAllFeed = async () => { |
||||
|
this.isHiddenSpinner = false; |
||||
|
|
||||
|
await this.retrieveClaims(this.apiServer, null, this.feedPreviousOldestId) |
||||
|
.then(async (results) => { |
||||
|
if (results.data.length > 0) { |
||||
|
this.feedData = this.feedData.concat(results.data); |
||||
|
//console.log("Feed data:", this.feedData); |
||||
|
this.feedAllLoaded = results.hitLimit; |
||||
|
this.feedPreviousOldestId = |
||||
|
results.data[results.data.length - 1].jwtId; |
||||
|
if ( |
||||
|
this.feedLastViewedId == null || |
||||
|
this.feedLastViewedId < results.data[0].jwtId |
||||
|
) { |
||||
|
// save it to storage |
||||
|
await db.open(); |
||||
|
db.settings.update(MASTER_SETTINGS_KEY, { |
||||
|
lastViewedClaimId: results.data[0].jwtId, |
||||
|
}); |
||||
|
// but not for this page because we need to remember what it was before |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
|
.catch((e) => { |
||||
|
console.log("Error with feed load:", e); |
||||
|
this.alertMessage = |
||||
|
e.userMessage || "There was an error retrieving feed data."; |
||||
|
}); |
||||
|
|
||||
|
this.isHiddenSpinner = true; |
||||
|
}; |
||||
|
|
||||
|
retrieveClaims = async (endorserApiServer, identifier, beforeId) => { |
||||
|
//const afterQuery = afterId == null ? "" : "&afterId=" + afterId; |
||||
|
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId; |
||||
|
const headers = { "Content-Type": "application/json" }; |
||||
|
if (this.activeDid) { |
||||
|
const account = R.find( |
||||
|
(acc) => acc.did === this.activeDid, |
||||
|
this.allAccounts |
||||
|
); |
||||
|
//console.log("about to parse from", this.activeDid, account?.identity); |
||||
|
const identity = JSON.parse(account?.identity || "null"); |
||||
|
if (!identity) { |
||||
|
throw new Error("No identity found."); |
||||
|
} |
||||
|
const token = await accessToken(identity); |
||||
|
headers["Authorization"] = "Bearer " + token; |
||||
|
} else { |
||||
|
// it's OK without auth... we just won't get any identifiers |
||||
|
} |
||||
|
return fetch(this.apiServer + "/api/v2/report/gives?" + beforeQuery, { |
||||
|
method: "GET", |
||||
|
headers: headers, |
||||
|
}) |
||||
|
.then(async (response) => { |
||||
|
if (response.status !== 200) { |
||||
|
const details = await response.text(); |
||||
|
throw details; |
||||
|
} |
||||
|
return response.json(); |
||||
|
}) |
||||
|
.then((results) => { |
||||
|
if (results.data) { |
||||
|
return results; |
||||
|
} else { |
||||
|
throw JSON.stringify(results); |
||||
|
} |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
giveDescription(giveRecord) { |
||||
|
let claim = giveRecord.fullClaim; |
||||
|
if (claim.claim) { |
||||
|
// it's probably a Verified Credential |
||||
|
claim = claim.claim; |
||||
|
} |
||||
|
|
||||
|
// agent.did is for legacy data, before March 2023 |
||||
|
const giver = |
||||
|
claim.agent?.identifier || claim.agent?.did || giveRecord.issuer; |
||||
|
const giverInfo = didInfo(giver, this.allAccounts, this.allContacts); |
||||
|
const gaveAmount = claim.object?.amountOfThisGood |
||||
|
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood) |
||||
|
: claim.description || "something unknown"; |
||||
|
// recipient.did is for legacy data, before March 2023 |
||||
|
const gaveRecipientId = claim.recipient?.identifier || claim.recipient?.did; |
||||
|
const gaveRecipientInfo = gaveRecipientId |
||||
|
? " to " + didInfo(gaveRecipientId, this.allAccounts, this.allContacts) |
||||
|
: ""; |
||||
|
return giverInfo + " gave " + gaveAmount + gaveRecipientInfo; |
||||
|
} |
||||
|
|
||||
|
displayAmount(code, amt) { |
||||
|
return "" + amt + " " + this.currencyShortWordForCode(code, amt === 1); |
||||
|
} |
||||
|
|
||||
|
currencyShortWordForCode(unitCode, single) { |
||||
|
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode; |
||||
|
} |
||||
|
|
||||
|
openDialog(contact) { |
||||
|
this.$refs.customDialog.open(contact); |
||||
|
} |
||||
|
handleDialogResult(result) { |
||||
|
if (result.action === "confirm") { |
||||
|
return new Promise((resolve) => { |
||||
|
this.recordGive(result.contact, result.description, result.hours); |
||||
|
resolve(); |
||||
|
}); |
||||
|
} else { |
||||
|
// action was "cancel" so do nothing |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* |
||||
|
* @param contact may be null |
||||
|
* @param description may be an empty string |
||||
|
* @param hours may be 0 |
||||
|
*/ |
||||
|
recordGive(contact, description, hours) { |
||||
|
if (this.activeDid == null) { |
||||
|
this.alertTitle = "Error"; |
||||
|
this.alertMessage = |
||||
|
"You must select an identity before you can record a give."; |
||||
|
return; |
||||
|
} |
||||
|
const account = R.find( |
||||
|
(acc) => acc.did === this.activeDid, |
||||
|
this.allAccounts |
||||
|
); |
||||
|
//console.log("about to parse from", this.activeDid, account?.identity); |
||||
|
const identity = JSON.parse(account?.identity || "null"); |
||||
|
if (!identity) { |
||||
|
throw new Error("No identity found."); |
||||
|
} |
||||
|
createAndSubmitGive( |
||||
|
this.axios, |
||||
|
this.apiServer, |
||||
|
identity, |
||||
|
contact?.did, |
||||
|
this.activeDid, |
||||
|
description, |
||||
|
hours |
||||
|
) |
||||
|
.then((result) => { |
||||
|
if (result.status != 201 || result.data?.error) { |
||||
|
console.log("Error with give result:", result); |
||||
|
this.alertTitle = "Error"; |
||||
|
this.alertMessage = |
||||
|
result.data?.message || "There was an error recording the give."; |
||||
|
} else { |
||||
|
this.alertTitle = "Success"; |
||||
|
this.alertMessage = "That gift was recorded."; |
||||
|
//this.updateAllFeed(); // full update is overkill but we should show something |
||||
|
} |
||||
|
}) |
||||
|
.catch((e) => { |
||||
|
console.log("Error with give caught:", e); |
||||
|
this.alertTitle = "Error"; |
||||
|
this.alertMessage = |
||||
|
e.userMessage || "There was an error recording the give."; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// This same popup code is in many files. |
||||
|
alertMessage = ""; |
||||
|
alertTitle = ""; |
||||
|
public onClickClose() { |
||||
|
this.alertTitle = ""; |
||||
|
this.alertMessage = ""; |
||||
|
} |
||||
|
public computedAlertClassNames() { |
||||
|
return { |
||||
|
hidden: !this.alertMessage, |
||||
|
"dismissable-alert": true, |
||||
|
"bg-slate-100": true, |
||||
|
"p-5": true, |
||||
|
rounded: true, |
||||
|
"drop-shadow-lg": true, |
||||
|
fixed: true, |
||||
|
"top-3": true, |
||||
|
"inset-x-3": true, |
||||
|
"transition-transform": true, |
||||
|
"ease-in": true, |
||||
|
"duration-300": true, |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
</script> |
</script> |
||||
|
Loading…
Reference in new issue