Matthew Raymer
1 year ago
25 changed files with 953 additions and 143 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> |
|||
<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> |
|||
|
|||
<script lang="ts"> |
|||
import * as R from "ramda"; |
|||
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({ |
|||
components: { |
|||
HelloWorld, |
|||
}, |
|||
components: { GiftedDialog }, |
|||
}) |
|||
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 |
|||
} |
|||
} |
|||
}) |
|||
export default class HomeView extends Vue {} |
|||
.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> |
|||
|
@ -0,0 +1,126 @@ |
|||
<template> |
|||
<!-- 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 text-slate-500"> |
|||
<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 bg-slate-400 text-white"> |
|||
<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"> |
|||
<!-- Heading --> |
|||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> |
|||
Your Identity |
|||
</h1> |
|||
|
|||
<div class="flex justify-center py-12"> |
|||
<span /> |
|||
<span v-if="loading"> |
|||
<span class="text-xl">Creating... </span> |
|||
<fa |
|||
icon="spinner" |
|||
class="fa-spin fa-spin-pulse" |
|||
color="green" |
|||
size="128" |
|||
></fa> |
|||
</span> |
|||
<span v-else> |
|||
<span class="text-xl">Created!</span> |
|||
<fa |
|||
icon="burst" |
|||
class="fa-beat px-12" |
|||
color="green" |
|||
style=" |
|||
--fa-animation-duration: 1s; |
|||
--fa-animation-direction: reverse; |
|||
--fa-animation-iteration-count: 1; |
|||
--fa-beat-scale: 6; |
|||
" |
|||
></fa> |
|||
</span> |
|||
<span /> |
|||
</div> |
|||
</section> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import "dexie-export-import"; |
|||
import { Component, Vue } from "vue-facing-decorator"; |
|||
import { accountsDB, db } from "@/db"; |
|||
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto"; |
|||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; |
|||
|
|||
@Component |
|||
export default class AccountViewView extends Vue { |
|||
loading = true; |
|||
|
|||
async mounted() { |
|||
await accountsDB.open(); |
|||
const mnemonic = generateSeed(); |
|||
// address is 0x... ETH address, without "did:eth:" |
|||
const [address, privateHex, publicHex, derivationPath] = |
|||
deriveAddress(mnemonic); |
|||
|
|||
const newId = newIdentifier(address, publicHex, privateHex, derivationPath); |
|||
const identity = JSON.stringify(newId); |
|||
await accountsDB.accounts.add({ |
|||
dateCreated: new Date().toISOString(), |
|||
derivationPath: derivationPath, |
|||
did: newId.did, |
|||
identity: identity, |
|||
mnemonic: mnemonic, |
|||
publicKeyHex: newId.keys[0].publicKeyHex, |
|||
}); |
|||
|
|||
await db.settings.update(MASTER_SETTINGS_KEY, { |
|||
activeDid: newId.did, |
|||
}); |
|||
|
|||
this.loading = false; |
|||
setTimeout(() => { |
|||
this.$router.push({ name: "account" }); |
|||
}, 1000); |
|||
} |
|||
} |
|||
</script> |
Loading…
Reference in new issue