Browse Source

Merge pull request 'photo-upload' (#105) from photo-upload into master

Reviewed-on: https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/pulls/105
kb/add-usage-guide
trentlarson 8 months ago
parent
commit
85ad295eb9
  1. 6
      .env.development
  2. 4
      .env.production
  3. 9
      CHANGELOG.md
  4. 8
      README.md
  5. 6
      package-lock.json
  6. 2
      package.json
  7. 23
      project.task.yaml
  8. 31
      src/App.vue
  9. 37
      src/components/GiftedDialog.vue
  10. 211
      src/components/GiftedPhotoDialog.vue
  11. 13
      src/constants/app.ts
  12. 22
      src/libs/endorserServer.ts
  13. 6
      src/main.ts
  14. 8
      src/router/index.ts
  15. 71
      src/views/AccountViewView.vue
  16. 428
      src/views/GiftedDetails.vue
  17. 7
      src/views/HomeView.vue
  18. 9
      src/views/ProjectViewView.vue

6
.env.development

@ -0,0 +1,6 @@
# Only the variables that start with VUE_APP_ are seen in the application process.env in Vue.
# this won't resolve as a URL on production; it's a URN only found in the test system
VUE_APP_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK
VUE_APP_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
VUE_APP_DEFAULT_IMAGE_API_SERVER=http://localhost:3002

4
.env.production

@ -0,0 +1,4 @@
# Only the variables that start with VUE_APP_ are seen in the application process.env in Vue.
VUE_APP_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
VUE_APP_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VUE_APP_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app

9
CHANGELOG.md

@ -6,8 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Changed in DB ### Added
- ? - Photo on gift records
### Fixed
- Environment variable for BVC meetings project
### Changed in DB or environment
- Test that a new browser session will get the right default API
- Test that a new browser session will send the right BVC meetings project
## [0.2.17] - 2024.03.01 - 3612ea42240c5e1b7d7eff29a39ff18f1b869b36 ## [0.2.17] - 2024.03.01 - 3612ea42240c5e1b7d7eff29a39ff18f1b869b36

8
README.md

@ -34,9 +34,9 @@ npm run lint
* Update the project.task.yaml & CHANGELOG.md & the version in package.json, run `npm install`. * Update the project.task.yaml & CHANGELOG.md & the version in package.json, run `npm install`.
* If production: change src/constants/app.ts DEFAULT_*_SERVER to be "PROD" and package.json to remove "_Test". Also record what version is on production. * If production: change package.json to remove "_Test". Also record what version is on production.
* `npm run build` * `npm run build-dev` for test servers or `npm run build` for production.
* Get on the server and back up the time-safari folder. * Get on the server and back up the time-safari folder.
@ -44,9 +44,9 @@ npm run lint
* Revert src/constants/app.ts and package.json (if that was prod). * Revert src/constants/app.ts and package.json (if that was prod).
* Commit changes. Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Tag if you didn't before. Also record what version is on production. * Commit changes. Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
* [Tag wth the new version.](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) * [Tag with the new version.](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases)

6
package-lock.json

@ -54,6 +54,7 @@
"readable-stream": "^4.4.2", "readable-stream": "^4.4.2",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"register-service-worker": "^1.7.2", "register-service-worker": "^1.7.2",
"simple-vue-camera": "^1.1.3",
"three": "^0.156.1", "three": "^0.156.1",
"ua-parser-js": "^1.0.37", "ua-parser-js": "^1.0.37",
"util": "^0.12.5", "util": "^0.12.5",
@ -25438,6 +25439,11 @@
"node": ">= 5.10.0" "node": ">= 5.10.0"
} }
}, },
"node_modules/simple-vue-camera": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/simple-vue-camera/-/simple-vue-camera-1.1.3.tgz",
"integrity": "sha512-GVAYq1BMI9cHt+h24tu2dfIFFvhjVQ1M8IkK5LmrKcYoBA8FZlLNlhrHC2NnTPbMAXIvJn1Bqx8X6Q31+Y2+jA=="
},
"node_modules/sirv": { "node_modules/sirv": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.3.tgz", "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.3.tgz",

2
package.json

@ -5,6 +5,7 @@
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build": "vue-cli-service build", "build": "vue-cli-service build",
"build-dev": "vue-cli-service build --mode development",
"lint": "vue-cli-service lint" "lint": "vue-cli-service lint"
}, },
"dependencies": { "dependencies": {
@ -54,6 +55,7 @@
"readable-stream": "^4.4.2", "readable-stream": "^4.4.2",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"register-service-worker": "^1.7.2", "register-service-worker": "^1.7.2",
"simple-vue-camera": "^1.1.3",
"three": "^0.156.1", "three": "^0.156.1",
"ua-parser-js": "^1.0.37", "ua-parser-js": "^1.0.37",
"util": "^0.12.5", "util": "^0.12.5",

23
project.task.yaml

@ -1,6 +1,12 @@
tasks : tasks :
- alert & stop if give amount < 0
- onboarding video
- .1 on feed, don't show "to someone anonymous" if it's to a project
- .1 on ideas, put an "x" to close it
- .2 fix give dialog from "more contacts" off home page to allow giving to this user - .2 fix give dialog from "more contacts" off home page to allow giving to this user
- .2 fix bottom of project selection map, where the icons are hidden but a tap goes to the icon's page - .2 fix bottom of project selection map, where the icons are hidden but a tap goes to the icon's page
- .5 stop from seeing an error on the first page when browser doesn't support service workers (which I've seen on iPhone; visible in Firefox private window) - .5 stop from seeing an error on the first page when browser doesn't support service workers (which I've seen on iPhone; visible in Firefox private window)
@ -9,14 +15,14 @@ tasks :
- .2 list the "show more" contacts alphabetically - .2 list the "show more" contacts alphabetically
- 32 image on give : - 32 image on give :
- Show a camera to take a picture - upload a photo from elsewhere
- Scale the image to a reasonable size - go-live - create cert for new image domain, set up haproxy redirect, test-image-api.timesafari.app
- Upload to a public readable place - make Time Safari a share_target for images
- check the rate limits
- use CID (hash?) - 08 add image on profile
- put the image URL in the claim
- Rates - images erased? - ask to detect location & record it in settings
- image not associated with JWT ULID since that's assigned later - if personal location is set, show potential local affiliations
- 24 compelling UI for credential presentations - 24 compelling UI for credential presentations
- discover who in my network has activity on a project - discover who in my network has activity on a project
@ -37,6 +43,7 @@ tasks :
- randomize (not show in order) - randomize (not show in order)
- checkboxes - show non-person-oriented messages, show only contacts, show only projects - checkboxes - show non-person-oriented messages, show only contacts, show only projects
- .5 add a notice on the front page if their notifications are off
- 08 allow user to add a time when they want their daily notification - 08 allow user to add a time when they want their daily notification
- .5 prompt for the name directly when they visit the QR scan page - .5 prompt for the name directly when they visit the QR scan page

31
src/App.vue

@ -148,6 +148,37 @@
class="w-full" class="w-full"
role="alert" role="alert"
> >
<div
v-if="notification.type === 'confirm'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<div
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
>
<div class="w-full px-6 py-6 text-slate-900 text-center">
<p class="text-lg mb-4">
{{ notification.title }}
</p>
<button
@click="
notification.onYes();
close(notification.id);
"
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
>
Yes
</button>
<button
@click="close(notification.id)"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
>
Cancel
</button>
</div>
</div>
</div>
<div <div
v-if="notification.type === 'notification-permission'" v-if="notification.type === 'notification-permission'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50" class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"

37
src/components/GiftedDialog.vue

@ -10,23 +10,22 @@
placeholder="What was received" placeholder="What was received"
v-model="description" v-model="description"
/> />
<div class="flex flex-row"> <div class="flex flex-row justify-center">
<span <span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 w-1/3 text-center text-blue-500 px-2 py-2" class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
@click="changeUnitCode()" @click="changeUnitCode()"
> >
{{ libsUtil.UNIT_SHORT[unitCode] }} {{ libsUtil.UNIT_SHORT[unitCode] }}
</span> </span>
<div <div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2" class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="decrement()" @click="amountInput === '0' ? null : decrement()"
v-if="amountInput !== '0'"
> >
<fa icon="chevron-left" /> <fa icon="chevron-left" />
</div> </div>
<input <input
type="number" type="number"
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center" class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput" v-model="amountInput"
/> />
<div <div
@ -36,14 +35,26 @@
<fa icon="chevron-right" /> <fa icon="chevron-right" />
</div> </div>
</div> </div>
<div class="mt-2 text-right"> <div class="mt-4 flex justify-center">
<span v-if="showGivenToUser" class="mr-16">
<input type="checkbox" class="mr-2" v-model="givenToUser" />
<label class="text-sm">Given to you</label>
</span>
<span> <span>
<input type="checkbox" class="mr-2" v-model="isTrade" /> <router-link
<label class="text-sm">Trade (not a gift)</label> :to="{
name: 'gifted-details',
query: {
amountInput,
description,
giverDid: giver?.did,
giverName: giver?.name,
message,
offerId,
projectId,
unitCode,
},
}"
class="text-blue-500"
>
More Options
</router-link>
</span> </span>
</div> </div>
<p class="text-center mb-2 mt-6 italic"> <p class="text-center mb-2 mt-6 italic">
@ -93,9 +104,9 @@ export default class GiftedDialog extends Vue {
apiServer = ""; apiServer = "";
amountInput = "0"; amountInput = "0";
giver?: GiverInputInfo; // undefined means no identified giver agent
description = ""; description = "";
givenToUser = false; givenToUser = false;
giver?: GiverInputInfo; // undefined means no identified giver agent
isTrade = false; isTrade = false;
offerId = ""; offerId = "";
unitCode = "HUR"; unitCode = "HUR";

211
src/components/GiftedPhotoDialog.vue

@ -0,0 +1,211 @@
<template>
<div v-if="visible" class="dialog-overlay">
<!-- Breadcrumb -->
<div class="dialog">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -right-2 -top-1"
@click="close()"
>
<fa icon="xmark" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
<span v-if="uploading"> Uploading... </span>
<span v-else-if="blob"> Look Good? </span>
<span v-else> Say "Cheese"! </span>
</h1>
<div v-if="uploading" class="flex justify-center">
<fa icon="spinner" class="fa-spin fa-3x text-center block" />
</div>
<div v-else-if="blob">
<div class="flex justify-around">
<button
@click="uploadImage"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-2 rounded-full"
>
<span>Upload</span>
</button>
<button
@click="retryImage"
class="bg-slate-500 hover:bg-slate-700 text-white font-bold py-2 px-2 rounded-full"
>
<span>Retry</span>
</button>
</div>
<img :src="URL.createObjectURL(blob)" class="mt-2 w-full" />
</div>
<div v-else>
<!--
Camera "resolution" doesn't change how it shows on screen but rather stretches the result, eg the following which just stretches it vertically:
:resolution="{ width: 375, height: 812 }"
-->
<camera facingMode="environment" autoplay ref="camera">
<div class="absolute bottom-0 w-full flex justify-center pb-4">
<!-- Button -->
<button
@click="takeImage"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-2 rounded-full"
>
<fa icon="camera" class="fa-fw"></fa>
</button>
</div>
</camera>
</div>
</div>
</div>
</template>
<script lang="ts">
import axios from "axios";
import Camera from "simple-vue-camera";
import { Component, Vue } from "vue-facing-decorator";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import { getIdentity } from "@/libs/util";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
@Component({ components: { Camera } })
export default class GiftedPhotoDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
blob: Blob | null = null;
setImage: (arg: string) => void = () => {};
uploading = false;
visible = false;
URL = window.URL || window.webkitURL;
async mounted() {
try {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.activeDid = settings?.activeDid || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings from database:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: err.message || "There was an error retrieving your settings.",
},
-1,
);
}
}
open(setImageFn: (arg: string) => void) {
this.visible = true;
this.setImage = setImageFn;
}
close() {
this.visible = false;
this.blob = null;
}
async takeImage(/* payload: MouseEvent */) {
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
this.blob = await cameraComponent?.snapshot(); // png is default; if that changes, change extension in formData.append
if (!this.blob) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error taking the picture. Please try again.",
},
5000,
);
return;
}
}
async retryImage() {
this.blob = null;
}
async uploadImage() {
this.uploading = true;
const identifier = await getIdentity(this.activeDid);
const token = await accessToken(identifier);
const headers = {
Authorization: "Bearer " + token,
};
const formData = new FormData();
if (!this.blob) {
// yeah, this should never happen, but it helps with subsequent type checking
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error finding the picture. Please try again.",
},
5000,
);
this.uploading = false;
return;
}
formData.append("image", this.blob, "snapshot.png"); // png is set in snapshot()
formData.append("claimType", "GiveAction");
try {
const response = await axios.post(
DEFAULT_IMAGE_API_SERVER + "/image",
formData,
{ headers },
);
this.uploading = false;
this.visible = false;
this.blob = null;
this.setImage(response.data.url as string);
} catch (error) {
console.error("Error uploading the image", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error saving the picture. Please try again.",
},
5000,
);
this.uploading = false;
this.blob = null;
}
}
}
</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;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 700px;
}
</style>

13
src/constants/app.ts

@ -12,10 +12,20 @@ export enum AppString {
TEST1_PUSH_SERVER = "https://test.timesafari.app", TEST1_PUSH_SERVER = "https://test.timesafari.app",
TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com", TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com",
PROD_IMAGE_API_SERVER = "https://image-api.timesafari.app",
TEST_IMAGE_API_SERVER = "https://test-image-api.timesafari.app",
LOCAL_IMAGE_API_SERVER = "http://localhost:3001",
NO_CONTACT_NAME = "(no name)", NO_CONTACT_NAME = "(no name)",
} }
export const DEFAULT_ENDORSER_API_SERVER = AppString.TEST_ENDORSER_API_SERVER; export const DEFAULT_ENDORSER_API_SERVER =
process.env.VUE_APP_DEFAULT_ENDORSER_API_SERVER ||
AppString.TEST_ENDORSER_API_SERVER;
export const DEFAULT_IMAGE_API_SERVER =
process.env.VUE_APP_DEFAULT_IMAGE_API_SERVER ||
AppString.TEST_IMAGE_API_SERVER;
export const DEFAULT_PUSH_SERVER = export const DEFAULT_PUSH_SERVER =
window.location.protocol + "//" + window.location.host; window.location.protocol + "//" + window.location.host;
@ -29,4 +39,5 @@ export interface NotificationIface {
type: string; // "toast" | "info" | "success" | "warning" | "danger" type: string; // "toast" | "info" | "success" | "warning" | "danger"
title: string; title: string;
text: string; text: string;
onYes?: () => Promise<void>;
} }

22
src/libs/endorserServer.ts

@ -67,6 +67,7 @@ export const BLANK_GENERIC_SERVER_RECORD: GenericServerRecord = {
issuer: "", issuer: "",
}; };
// a summary record; the VC is found the fullClaim field
export interface GiveServerRecord { export interface GiveServerRecord {
agentDid: string; agentDid: string;
amount: number; amount: number;
@ -81,6 +82,7 @@ export interface GiveServerRecord {
unit: string; unit: string;
} }
// a summary record; the VC is found the fullClaim field
export interface OfferServerRecord { export interface OfferServerRecord {
amount: number; amount: number;
amountGiven: number; amountGiven: number;
@ -98,13 +100,14 @@ export interface OfferServerRecord {
validThrough: string; validThrough: string;
} }
// a summary record; the VC is not currently part of this record
export interface PlanServerRecord { export interface PlanServerRecord {
agentDid?: string; // optional, if the issuer wants someone else to manage as well agentDid?: string; // optional, if the issuer wants someone else to manage as well
description: string; description: string;
endTime?: string; endTime?: string;
fulfillsPlanHandleId: string; fulfillsPlanHandleId: string;
issuerDid: string;
handleId: string; handleId: string;
issuerDid: string;
locLat?: number; locLat?: number;
locLon?: number; locLon?: number;
startTime?: string; startTime?: string;
@ -120,6 +123,7 @@ export interface GiveVerifiableCredential {
description?: string; description?: string;
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[]; fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[];
identifier?: string; identifier?: string;
image?: string;
object?: { amountOfThisGood: number; unitCode: string }; object?: { amountOfThisGood: number; unitCode: string };
recipient?: { identifier: string }; recipient?: { identifier: string };
} }
@ -183,7 +187,7 @@ export interface PlanData {
rowid?: string; rowid?: string;
} }
export interface RateLimits { export interface EndorserRateLimits {
doneClaimsThisWeek: string; doneClaimsThisWeek: string;
doneRegistrationsThisMonth: string; doneRegistrationsThisMonth: string;
maxClaimsPerWeek: string; maxClaimsPerWeek: string;
@ -192,6 +196,12 @@ export interface RateLimits {
nextWeekBeginDateTime: string; nextWeekBeginDateTime: string;
} }
export interface ImageRateLimits {
doneImagesThisWeek: string;
maxImagesPerWeek: string;
nextWeekBeginDateTime: string;
}
export interface VerifiableCredential { export interface VerifiableCredential {
"@context": string; "@context": string;
"@type": string; "@type": string;
@ -434,6 +444,7 @@ export async function createAndSubmitGive(
fulfillsProjectHandleId?: string, fulfillsProjectHandleId?: string,
fulfillsOfferHandleId?: string, fulfillsOfferHandleId?: string,
isTrade: boolean = false, isTrade: boolean = false,
imageUrl?: string,
): Promise<CreateAndSubmitClaimResult> { ): Promise<CreateAndSubmitClaimResult> {
const vcClaim: GiveVerifiableCredential = { const vcClaim: GiveVerifiableCredential = {
"@context": "https://schema.org", "@context": "https://schema.org",
@ -460,6 +471,9 @@ export async function createAndSubmitGive(
identifier: fulfillsOfferHandleId, identifier: fulfillsOfferHandleId,
}); });
} }
if (imageUrl) {
vcClaim.image = imageUrl;
}
return createAndSubmitClaim( return createAndSubmitClaim(
vcClaim as GenericServerRecord, vcClaim as GenericServerRecord,
identity, identity,
@ -780,8 +794,8 @@ export const claimSpecialDescription = (
}; };
export const BVC_MEETUPS_PROJECT_CLAIM_ID = export const BVC_MEETUPS_PROJECT_CLAIM_ID =
//"https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H"; process.env.VUE_APP_BVC_MEETUPS_PROJECT_CLAIM_ID ||
"https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK"; "https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK"; // this won't resolve as a URL on production; it's a URN only found in the test system
export const bvcMeetingJoinClaim = (did: string, startTime: string) => { export const bvcMeetingJoinClaim = (did: string, startTime: string) => {
return { return {

6
src/main.ts

@ -18,6 +18,8 @@ import {
faBitcoinSign, faBitcoinSign,
faBurst, faBurst,
faCalendar, faCalendar,
faCamera,
faCheck,
faChevronLeft, faChevronLeft,
faChevronRight, faChevronRight,
faCircle, faCircle,
@ -76,6 +78,8 @@ library.add(
faBitcoinSign, faBitcoinSign,
faBurst, faBurst,
faCalendar, faCalendar,
faCamera,
faCheck,
faChevronLeft, faChevronLeft,
faChevronRight, faChevronRight,
faCircle, faCircle,
@ -127,9 +131,11 @@ library.add(
); );
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import Camera from "simple-vue-camera";
createApp(App) createApp(App)
.component("fa", FontAwesomeIcon) .component("fa", FontAwesomeIcon)
.component("camera", Camera)
.use(createPinia()) .use(createPinia())
.use(VueAxios, axios) .use(VueAxios, axios)
.use(router) .use(router)

8
src/router/index.ts

@ -84,6 +84,14 @@ const routes: Array<RouteRecordRaw> = [
component: () => component: () =>
import(/* webpackChunkName: "discover" */ "../views/DiscoverView.vue"), import(/* webpackChunkName: "discover" */ "../views/DiscoverView.vue"),
}, },
{
path: "/gifted-details",
name: "gifted-details",
component: () =>
import(
/* webpackChunkName: "gifted-details" */ "../views/GiftedDetails.vue"
),
},
{ {
path: "/help", path: "/help",
name: "help", name: "help",

71
src/views/AccountViewView.vue

@ -96,7 +96,7 @@
<!-- Registration notice --> <!-- Registration notice -->
<!-- We won't show any loading indicator because it usually doesn't change anything. We'll just pop the message in only if we discover that they need it. --> <!-- We won't show any loading indicator because it usually doesn't change anything. We'll just pop the message in only if we discover that they need it. -->
<div <div
v-if="!loadingLimits && !limits?.nextWeekBeginDateTime" v-if="!loadingLimits && !endorserLimits?.nextWeekBeginDateTime"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4" class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4"
> >
<p class="mb-4"> <p class="mb-4">
@ -157,28 +157,39 @@
<div> <div>
{{ limitsMessage }} {{ limitsMessage }}
</div> </div>
<div v-if="!!limits?.nextWeekBeginDateTime"> <div v-if="!!endorserLimits?.nextWeekBeginDateTime">
<p class="mb-3 text-sm"> <p class="text-sm">
You have done <b>{{ limits.doneClaimsThisWeek }}</b> claims out of You have done
<b>{{ limits.maxClaimsPerWeek }}</b> for this week. Your claims <b>{{ endorserLimits.doneClaimsThisWeek }} claims</b> out of
counter resets at <b>{{ endorserLimits.maxClaimsPerWeek }}</b> for this week. Your
claims counter resets at
<b class="whitespace-nowrap">{{ <b class="whitespace-nowrap">{{
readableTime(limits.nextWeekBeginDateTime) readableDate(endorserLimits.nextWeekBeginDateTime)
}}</b> }}</b>
</p> </p>
<p class="text-sm"> <p class="mt-3 text-sm">
You have done You have done
<b>{{ limits.doneRegistrationsThisMonth }}</b> registrations out of <b>{{ endorserLimits.doneRegistrationsThisMonth }} registrations</b>
<b>{{ limits.maxRegistrationsPerMonth }}</b> for this month. out of <b>{{ endorserLimits.maxRegistrationsPerMonth }}</b> for this
month.
<i <i
>(You can register nobody on your first day, and after that only one >(You can register nobody on your first day, and after that only one
a day in your first month.)</i a day in your first month.)</i
> >
Your registration counter resets at Your registration counter resets at
<b class="whitespace-nowrap"> <b class="whitespace-nowrap">
{{ readableTime(limits.nextMonthBeginDateTime) }} {{ readableDate(endorserLimits.nextMonthBeginDateTime) }}
</b> </b>
</p> </p>
<p class="mt-3 text-sm" v-if="!!imageLimits">
You have uploaded
<b>{{ imageLimits?.doneImagesThisWeek }} images</b> out of
<b>{{ imageLimits?.maxImagesPerWeek }}</b> for this week. Your image
counter resets at
<b class="whitespace-nowrap">{{
readableDate(imageLimits?.nextWeekBeginDateTime)
}}</b>
</p>
</div> </div>
<button <button
class="block float-right w-fit text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mt-2" class="block float-right w-fit text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mt-2"
@ -509,6 +520,7 @@ import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue"; import TopMessage from "@/components/TopMessage.vue";
import { import {
AppString, AppString,
DEFAULT_IMAGE_API_SERVER,
DEFAULT_PUSH_SERVER, DEFAULT_PUSH_SERVER,
NotificationIface, NotificationIface,
} from "@/constants/app"; } from "@/constants/app";
@ -516,7 +528,11 @@ import { db, accountsDB } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto"; import { accessToken } from "@/libs/crypto";
import { IIdentifier } from "@veramo/core"; import { IIdentifier } from "@veramo/core";
import { ErrorResponse, RateLimits } from "@/libs/endorserServer"; import {
ErrorResponse,
EndorserRateLimits,
ImageRateLimits,
} from "@/libs/endorserServer";
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const Buffer = require("buffer/").Buffer; const Buffer = require("buffer/").Buffer;
@ -542,7 +558,9 @@ export default class AccountViewView extends Vue {
apiServerInput = ""; apiServerInput = "";
derivationPath = ""; derivationPath = "";
downloadUrl = ""; // because DuckDuckGo doesn't download on automated call to "click" on the anchor downloadUrl = ""; // because DuckDuckGo doesn't download on automated call to "click" on the anchor
endorserLimits: EndorserRateLimits | null = null;
givenName = ""; givenName = "";
imageLimits: ImageRateLimits | null = null;
isRegistered = false; isRegistered = false;
isSubscribed = false; isSubscribed = false;
notificationMaybeChanged = false; notificationMaybeChanged = false;
@ -550,7 +568,6 @@ export default class AccountViewView extends Vue {
publicBase64 = ""; publicBase64 = "";
webPushServer = ""; webPushServer = "";
webPushServerInput = ""; webPushServerInput = "";
limits: RateLimits | null = null;
limitsMessage = ""; limitsMessage = "";
loadingLimits = false; loadingLimits = false;
showContactGives = false; showContactGives = false;
@ -697,7 +714,7 @@ export default class AccountViewView extends Vue {
this.updateShowShortcutBvc(this.showShortcutBvc); this.updateShowShortcutBvc(this.showShortcutBvc);
} }
readableTime(timeStr: string) { readableDate(timeStr: string) {
return timeStr.substring(0, timeStr.indexOf("T")); return timeStr.substring(0, timeStr.indexOf("T"));
} }
@ -1038,11 +1055,11 @@ export default class AccountViewView extends Vue {
this.limitsMessage = ""; this.limitsMessage = "";
try { try {
const resp = await this.fetchRateLimits(identity); const resp = await this.fetchEndorserRateLimits(identity);
if (resp.status === 200) { if (resp.status === 200) {
this.limits = resp.data; this.endorserLimits = resp.data;
if (!this.isRegistered) { if (!this.isRegistered) {
// the user is not known to be registered, but they are so let's record it // the user was not known to be registered, but now they are (because we got no error) so let's record it
try { try {
await db.open(); await db.open();
db.settings.update(MASTER_SETTINGS_KEY, { db.settings.update(MASTER_SETTINGS_KEY, {
@ -1062,6 +1079,10 @@ export default class AccountViewView extends Vue {
); );
} }
} }
const imageResp = await this.fetchImageRateLimits(identity);
if (imageResp.status === 200) {
this.imageLimits = imageResp.data;
}
} }
} catch (error) { } catch (error) {
this.handleRateLimitsError(error); this.handleRateLimitsError(error);
@ -1082,17 +1103,29 @@ export default class AccountViewView extends Vue {
} }
/** /**
* Fetches rate limits from the server. * Fetches rate limits from the Endorser server.
* *
* @param {IIdentifier} identity - The identity object to check rate limits for. * @param {IIdentifier} identity - The identity object to check rate limits for.
* @returns {Promise<AxiosResponse>} The Axios response object. * @returns {Promise<AxiosResponse>} The Axios response object.
*/ */
private async fetchRateLimits(identity: IIdentifier) { private async fetchEndorserRateLimits(identity: IIdentifier) {
const url = `${this.apiServer}/api/report/rateLimits`; const url = `${this.apiServer}/api/report/rateLimits`;
const headers = await this.getHeaders(identity); const headers = await this.getHeaders(identity);
return await this.axios.get(url, { headers } as AxiosRequestConfig); return await this.axios.get(url, { headers } as AxiosRequestConfig);
} }
/**
* Fetches rate limits from the image server.
*
* @param {IIdentifier} identity - The identity object to check rate limits for.
* @returns {Promise<AxiosResponse>} The Axios response object.
*/
private async fetchImageRateLimits(identity: IIdentifier) {
const url = DEFAULT_IMAGE_API_SERVER + "/image-limits";
const headers = await this.getHeaders(identity);
return await this.axios.get(url, { headers } as AxiosRequestConfig);
}
/** /**
* Handles errors that occur while fetching rate limits. * Handles errors that occur while fetching rate limits.
* *

428
src/views/GiftedDetails.vue

@ -0,0 +1,428 @@
<template>
<QuickNav />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="cancel()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 class="text-4xl text-center font-light px-4 mb-4">What Was Given</h1>
<h1 class="text-xl font-bold text-center mb-4">
{{ message }} {{ giverName || "somebody not named" }}
</h1>
<textarea
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="What was received"
v-model="description"
/>
<div class="flex flex-row justify-center">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
@click="changeUnitCode()"
>
{{ libsUtil.UNIT_SHORT[unitCode] }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="amountInput === '0' ? null : decrement()"
>
<fa icon="chevron-left" />
</div>
<input
type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput"
/>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<fa icon="chevron-right" />
</div>
</div>
<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-blue-500 text-white px-2 py-2 rounded-md"
@click="openPhotoDialog"
/>
</span>
</div>
<GiftedPhotoDialog ref="photoDialog" />
<div v-if="projectId" class="mt-4">
<fa
icon="check"
class="bg-slate-500 text-white h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
/>
<label class="text-sm">This is given to a project</label>
</div>
<div v-if="!projectId" class="mt-4">
<input type="checkbox" class="h-6 w-6 mr-2" v-model="givenToUser" />
<label class="text-sm">Given to you</label>
</div>
<div class="mt-4">
<input type="checkbox" class="h-6 w-6 mr-2" v-model="isTrade" />
<label class="text-sm">Trade (not a gift)</label>
</div>
<p class="text-center mb-2 mt-6 italic">
Sign & Send to publish to the world
</p>
<button
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
@click="confirm"
>
Sign &amp; Send
</button>
<button
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
@click="cancel"
>
Cancel
</button>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { createAndSubmitGive } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { accessToken } from "@/libs/crypto";
import GiftedDialog from "@/components/GiftedDialog.vue";
import GiftedPhotoDialog from "@/components/GiftedPhotoDialog.vue";
@Component({
components: {
GiftedDialog,
GiftedPhotoDialog,
QuickNav,
TopMessage,
},
})
export default class GiftedDetails extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
apiServer = "";
amountInput = "0";
description = "";
givenToUser = false;
giverDid: string | undefined;
giverName = "";
imageUrl = "";
isTrade = false;
message = "";
offerId = "";
projectId = "";
unitCode = "HUR";
libsUtil = libsUtil;
async mounted() {
this.amountInput = this.$route.query.amountInput as string;
this.description = this.$route.query.description as string;
this.giverDid = this.$route.query.giverDid as string;
this.giverName = this.$route.query.giverName as string;
this.message = this.$route.query.message as string;
this.offerId = this.$route.query.offerId as string;
this.projectId = this.$route.query.projectId as string;
this.unitCode = this.$route.query.unitCode as string;
this.imageUrl = localStorage.getItem("imageUrl") || "";
this.givenToUser = !this.projectId;
try {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings from database:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: err.message || "There was an error retrieving your settings.",
},
-1,
);
}
}
changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.unitCode);
this.unitCode = units[(index + 1) % units.length];
}
increment() {
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
}
decrement() {
this.amountInput = `${Math.max(
0,
(parseFloat(this.amountInput) || 1) - 1,
)}`;
}
cancel() {
this.deleteImage(); // not awaiting, so they'll go back immediately
this.$router.back();
}
openPhotoDialog() {
(this.$refs.photoDialog as GiftedPhotoDialog).open((imgUrl) => {
this.imageUrl = imgUrl;
});
}
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("Non-success deleting image:", response);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem deleting the image.",
},
5000,
);
// keep the imageUrl in localStorage so the user can try again if they want
return;
}
localStorage.removeItem("imageUrl");
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);
localStorage.removeItem("imageUrl");
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,
);
}
}
}
async confirm() {
this.$notify(
{
group: "alert",
type: "toast",
text: "Recording the give...",
title: "",
},
1000,
);
// this is asynchronous, but we don't need to wait for it to complete
await this.recordGive();
}
/**
*
* @param giverDid may be null
* @param description may be an empty string
* @param amountInput may be 0
* @param unitCode may be omitted, defaults to "HUR"
*/
public async recordGive() {
if (!this.activeDid) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identifier before you can record a give.",
},
-1,
);
return;
}
if (!this.description && !this.amountInput) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: `You must enter a description or some number of ${
this.libsUtil.UNIT_LONG[this.unitCode]
}.`,
},
-1,
);
return;
}
try {
const identity = await libsUtil.getIdentity(this.activeDid);
const result = await createAndSubmitGive(
this.axios,
this.apiServer,
identity,
this.giverDid,
this.givenToUser ? this.activeDid : undefined,
this.description,
parseFloat(this.amountInput),
this.unitCode,
this.projectId,
this.offerId,
this.isTrade,
this.imageUrl,
);
if (
result.type === "error" ||
this.isGiveCreationError(result.response)
) {
const errorMessage = this.getGiveCreationErrorMessage(result);
console.error("Error with give creation result:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage || "There was an error creating the give.",
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
},
5000,
);
localStorage.removeItem("imageUrl");
this.$router.back();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error with give recordation caught:", error);
const message =
error.userMessage ||
error.response?.data?.error?.message ||
"There was an error recording the give.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: message,
},
-1,
);
}
}
// Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isGiveCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGiveCreationErrorMessage(result: any) {
return (
result.error?.userMessage ||
result.error?.error ||
result.response?.data?.error?.message
);
}
}
</script>

7
src/views/HomeView.vue

@ -225,6 +225,11 @@
</router-link> </router-link>
</span> </span>
</div> </div>
<div v-if="record.image" class="flex justify-center">
<a :href="record.image" target="_blank">
<img :src="record.image" class="h-24 mt-2 rounded-xl" />
</a>
</div>
</li> </li>
</ul> </ul>
</InfiniteScroll> </InfiniteScroll>
@ -267,6 +272,7 @@ interface GiveRecordWithContactInfo extends GiveServerRecord {
displayName: string; displayName: string;
known: boolean; known: boolean;
}; };
image: string;
receiver: { receiver: {
displayName: string; displayName: string;
known: boolean; known: boolean;
@ -427,6 +433,7 @@ export default class HomeView extends Vue {
contactForDid(giverDid, this.allContacts), contactForDid(giverDid, this.allContacts),
this.allMyDids, this.allMyDids,
), ),
image: claim.image,
receiver: didInfoForContact( receiver: didInfoForContact(
recipientDid, recipientDid,
this.activeDid, this.activeDid,

9
src/views/ProjectViewView.vue

@ -437,15 +437,6 @@ export default class ProjectViewView extends Vue {
return identity; return identity;
} }
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
onEditClick() { onEditClick() {
localStorage.setItem("projectId", this.projectId as string); localStorage.setItem("projectId", this.projectId as string);
const route = { const route = {

Loading…
Cancel
Save