Browse Source
Reviewed-on: https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/pulls/105gifted-camera-improvements
trentlarson
8 months ago
18 changed files with 842 additions and 61 deletions
@ -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 |
@ -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 |
@ -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> |
@ -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 & 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> |
Loading…
Reference in new issue