You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
433 lines
13 KiB
433 lines
13 KiB
11 months ago
|
<template>
|
||
10 months ago
|
<div v-if="visible" class="dialog-overlay z-[60]">
|
||
10 months ago
|
<div class="dialog relative">
|
||
10 months ago
|
<div class="text-lg text-center font-light relative z-50">
|
||
10 months ago
|
<div
|
||
10 months ago
|
id="ViewHeading"
|
||
9 months ago
|
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
|
||
11 months ago
|
>
|
||
10 months ago
|
<span v-if="uploading"> Uploading... </span>
|
||
|
<span v-else-if="blob"> Look Good? </span>
|
||
|
<span v-else> Say "Cheese"! </span>
|
||
10 months ago
|
</div>
|
||
11 months ago
|
|
||
10 months ago
|
<div
|
||
9 months ago
|
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
|
||
10 months ago
|
@click="close()"
|
||
11 months ago
|
>
|
||
10 months ago
|
<fa icon="xmark" class="w-[1em]"></fa>
|
||
10 months ago
|
</div>
|
||
11 months ago
|
</div>
|
||
|
|
||
10 months ago
|
<div v-if="uploading" class="flex justify-center">
|
||
9 months ago
|
<fa
|
||
|
icon="spinner"
|
||
|
class="fa-spin fa-3x text-center block px-12 py-12"
|
||
|
/>
|
||
11 months ago
|
</div>
|
||
10 months ago
|
<div v-else-if="blob">
|
||
9 months ago
|
<div v-if="crop">
|
||
|
<VuePictureCropper
|
||
|
:boxStyle="{
|
||
|
width: '100%',
|
||
|
height: '100%',
|
||
|
backgroundColor: '#f8f8f8',
|
||
|
margin: 'auto',
|
||
|
}"
|
||
8 months ago
|
:img="createBlobURL(blob)"
|
||
9 months ago
|
:options="{
|
||
|
viewMode: 1,
|
||
|
dragMode: 'crop',
|
||
|
aspectRatio: 9 / 9,
|
||
|
}"
|
||
|
/>
|
||
|
<!-- This gives a round cropper.
|
||
|
:presetMode="{
|
||
|
mode: 'round',
|
||
|
}"
|
||
|
-->
|
||
|
</div>
|
||
|
<div v-else>
|
||
|
<div class="flex justify-center">
|
||
8 months ago
|
<img :src="createBlobURL(blob)" class="mt-2 rounded" />
|
||
9 months ago
|
</div>
|
||
|
</div>
|
||
|
<div class="absolute bottom-[1rem] left-[1rem] px-2 py-1">
|
||
11 months ago
|
<button
|
||
10 months ago
|
@click="uploadImage"
|
||
9 months ago
|
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
|
||
11 months ago
|
>
|
||
10 months ago
|
<span>Upload</span>
|
||
|
</button>
|
||
9 months ago
|
</div>
|
||
8 months ago
|
<div
|
||
|
v-if="showRetry"
|
||
|
class="absolute bottom-[1rem] right-[1rem] px-2 py-1"
|
||
|
>
|
||
10 months ago
|
<button
|
||
|
@click="retryImage"
|
||
9 months ago
|
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
|
||
10 months ago
|
>
|
||
|
<span>Retry</span>
|
||
11 months ago
|
</button>
|
||
|
</div>
|
||
10 months ago
|
</div>
|
||
10 months ago
|
<div v-else ref="cameraContainer">
|
||
10 months ago
|
<!--
|
||
|
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 }"
|
||
|
-->
|
||
10 months ago
|
<camera
|
||
|
facingMode="environment"
|
||
|
autoplay
|
||
|
ref="camera"
|
||
10 months ago
|
@started="cameraStarted()"
|
||
10 months ago
|
>
|
||
10 months ago
|
<div
|
||
10 months ago
|
class="absolute portrait:bottom-0 portrait:left-0 portrait:right-0 portrait:pb-2 landscape:right-0 landscape:top-0 landscape:bottom-0 landscape:pr-4 flex landscape:flex-row justify-center items-center"
|
||
10 months ago
|
>
|
||
10 months ago
|
<button
|
||
10 months ago
|
@click="takeImage()"
|
||
10 months ago
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
||
10 months ago
|
>
|
||
10 months ago
|
<fa icon="camera" class="w-[1em]"></fa>
|
||
10 months ago
|
</button>
|
||
|
</div>
|
||
10 months ago
|
<div
|
||
|
class="absolute portrait:bottom-2 portrait:right-16 landscape:right-0 landscape:bottom-16 landscape:pr-4 flex justify-center items-center"
|
||
|
>
|
||
|
<button
|
||
|
@click="swapMirrorClass()"
|
||
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
||
|
>
|
||
|
<fa icon="left-right" class="w-[1em]"></fa>
|
||
|
</button>
|
||
|
</div>
|
||
10 months ago
|
<div v-if="numDevices > 1" class="absolute bottom-2 right-4">
|
||
10 months ago
|
<button
|
||
10 months ago
|
@click="switchCamera()"
|
||
10 months ago
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
||
|
>
|
||
|
<fa icon="rotate" class="w-[1em]"></fa>
|
||
|
</button>
|
||
|
</div>
|
||
10 months ago
|
</camera>
|
||
|
</div>
|
||
11 months ago
|
</div>
|
||
10 months ago
|
</div>
|
||
11 months ago
|
</template>
|
||
|
|
||
|
<script lang="ts">
|
||
|
import axios from "axios";
|
||
|
import Camera from "simple-vue-camera";
|
||
|
import { Component, Vue } from "vue-facing-decorator";
|
||
9 months ago
|
import VuePictureCropper, { cropper } from "vue-picture-cropper";
|
||
11 months ago
|
|
||
11 months ago
|
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
|
||
11 months ago
|
import { getIdentity } from "@/libs/util";
|
||
|
import { db } from "@/db/index";
|
||
|
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||
|
import { accessToken } from "@/libs/crypto";
|
||
11 months ago
|
|
||
9 months ago
|
@Component({ components: { Camera, VuePictureCropper } })
|
||
8 months ago
|
export default class PhotoDialog extends Vue {
|
||
11 months ago
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||
11 months ago
|
|
||
10 months ago
|
activeDeviceNumber = 0;
|
||
11 months ago
|
activeDid = "";
|
||
8 months ago
|
blob?: Blob;
|
||
9 months ago
|
claimType = "GiveAction";
|
||
|
crop = false;
|
||
8 months ago
|
fileName?: string;
|
||
10 months ago
|
mirror = false;
|
||
10 months ago
|
numDevices = 0;
|
||
8 months ago
|
setImageCallback: (arg: string) => void = () => {};
|
||
|
showRetry = true;
|
||
11 months ago
|
uploading = false;
|
||
10 months ago
|
visible = false;
|
||
11 months ago
|
|
||
|
URL = window.URL || window.webkitURL;
|
||
11 months ago
|
|
||
11 months ago
|
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,
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
|
||
8 months ago
|
open(
|
||
|
setImageFn: (arg: string) => void,
|
||
|
crop?: boolean,
|
||
|
claimType?: string,
|
||
|
blob?: Blob, // for image upload, just to use the cropping function
|
||
|
inputFileName?: string,
|
||
|
) {
|
||
10 months ago
|
this.visible = true;
|
||
9 months ago
|
this.crop = !!crop;
|
||
9 months ago
|
this.claimType = claimType || "GiveAction";
|
||
10 months ago
|
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
|
||
|
if (bottomNav) {
|
||
|
bottomNav.style.display = "none";
|
||
|
}
|
||
8 months ago
|
this.setImageCallback = setImageFn;
|
||
|
if (blob) {
|
||
|
this.blob = blob;
|
||
|
this.fileName = inputFileName;
|
||
|
this.showRetry = false;
|
||
|
} else {
|
||
|
this.blob = undefined;
|
||
|
this.fileName = undefined;
|
||
|
this.showRetry = true;
|
||
|
}
|
||
10 months ago
|
}
|
||
|
|
||
|
close() {
|
||
|
this.visible = false;
|
||
10 months ago
|
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
|
||
|
if (bottomNav) {
|
||
|
bottomNav.style.display = "";
|
||
|
}
|
||
8 months ago
|
this.blob = undefined;
|
||
10 months ago
|
}
|
||
|
|
||
10 months ago
|
async cameraStarted() {
|
||
|
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
|
||
|
if (cameraComponent) {
|
||
|
this.numDevices = (await cameraComponent.devices(["videoinput"])).length;
|
||
10 months ago
|
this.mirror = cameraComponent.facingMode === "user";
|
||
10 months ago
|
}
|
||
|
}
|
||
|
|
||
|
async switchCamera() {
|
||
|
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
|
||
|
this.activeDeviceNumber = (this.activeDeviceNumber + 1) % this.numDevices;
|
||
|
const devices = await cameraComponent?.devices(["videoinput"]);
|
||
|
cameraComponent?.changeCamera(devices[this.activeDeviceNumber].deviceId);
|
||
|
}
|
||
|
|
||
11 months ago
|
async takeImage(/* payload: MouseEvent */) {
|
||
11 months ago
|
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
|
||
10 months ago
|
|
||
|
/**
|
||
|
* This logic to set the image height & width correctly.
|
||
|
* Without it, the portrait orientation ends up with an image that is stretched horizontally.
|
||
|
* Note that it's the same with raw browser Javascript; see the "drawImage" example below.
|
||
|
* Now that I've done it, I can't explain why it works.
|
||
|
*/
|
||
|
let imageHeight = cameraComponent?.resolution?.height;
|
||
|
let imageWidth = cameraComponent?.resolution?.width;
|
||
|
const initialImageRatio = imageWidth / imageHeight;
|
||
10 months ago
|
const windowRatio = window.innerWidth / window.innerHeight;
|
||
|
if (initialImageRatio > 1 && windowRatio < 1) {
|
||
10 months ago
|
// the image is wider than it is tall, and the window is taller than it is wide
|
||
10 months ago
|
// For some reason, mobile in portrait orientation renders a horizontally-stretched image.
|
||
|
// We're gonna force it opposite.
|
||
10 months ago
|
imageHeight = cameraComponent?.resolution?.width;
|
||
|
imageWidth = cameraComponent?.resolution?.height;
|
||
10 months ago
|
} else if (initialImageRatio < 1 && windowRatio > 1) {
|
||
10 months ago
|
// the image is taller than it is wide, and the window is wider than it is tall
|
||
10 months ago
|
// Haven't seen this happen, but we'll do it just in case.
|
||
|
imageHeight = cameraComponent?.resolution?.width;
|
||
|
imageWidth = cameraComponent?.resolution?.height;
|
||
10 months ago
|
}
|
||
10 months ago
|
const newImageRatio = imageWidth / imageHeight;
|
||
10 months ago
|
if (newImageRatio < windowRatio) {
|
||
10 months ago
|
// the image is a taller ratio than the window, so fit the height first
|
||
10 months ago
|
imageHeight = window.innerHeight / 2;
|
||
|
imageWidth = imageHeight * newImageRatio;
|
||
10 months ago
|
} else {
|
||
10 months ago
|
// the image is a wider ratio than the window, so fit the width first
|
||
10 months ago
|
imageWidth = window.innerWidth / 2;
|
||
|
imageHeight = imageWidth / newImageRatio;
|
||
10 months ago
|
}
|
||
10 months ago
|
|
||
10 months ago
|
// The resolution is only necessary because of that mobile portrait-orientation case.
|
||
10 months ago
|
// The mobile emulation in a browser shows something stretched vertically, but real devices work fine.
|
||
8 months ago
|
this.blob =
|
||
|
(await cameraComponent?.snapshot({
|
||
|
height: imageHeight,
|
||
|
width: imageWidth,
|
||
|
})) || undefined;
|
||
|
// png is default
|
||
|
this.fileName = "snapshot.png";
|
||
11 months ago
|
if (!this.blob) {
|
||
11 months ago
|
this.$notify(
|
||
|
{
|
||
|
group: "alert",
|
||
|
type: "danger",
|
||
|
title: "Error",
|
||
|
text: "There was an error taking the picture. Please try again.",
|
||
|
},
|
||
11 months ago
|
5000,
|
||
11 months ago
|
);
|
||
|
return;
|
||
|
}
|
||
11 months ago
|
}
|
||
|
|
||
8 months ago
|
private createBlobURL(blob: Blob): string {
|
||
|
console.log("blob", blob);
|
||
|
return URL.createObjectURL(blob);
|
||
|
}
|
||
|
|
||
11 months ago
|
async retryImage() {
|
||
8 months ago
|
this.blob = undefined;
|
||
11 months ago
|
}
|
||
11 months ago
|
|
||
10 months ago
|
/****
|
||
|
|
||
10 months ago
|
Here's an approach to photo capture without a library. It has similar quirks.
|
||
|
Now that we've fixed styling for simple-vue-camera, it's not critical to refactor. Maybe someday.
|
||
10 months ago
|
|
||
|
<button id="start-camera" @click="cameraClicked">Start Camera</button>
|
||
|
<video id="video" width="320" height="240" autoplay></video>
|
||
|
<button id="snap-photo" @click="photoSnapped">Snap Photo</button>
|
||
|
<canvas id="canvas" width="320" height="240"></canvas>
|
||
|
|
||
|
async cameraClicked() {
|
||
|
const video = document.querySelector("#video");
|
||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||
|
video: true,
|
||
|
audio: false,
|
||
|
});
|
||
|
if (video instanceof HTMLVideoElement) {
|
||
|
video.srcObject = stream;
|
||
|
}
|
||
|
}
|
||
|
photoSnapped() {
|
||
|
const video = document.querySelector("#video");
|
||
|
const canvas = document.querySelector("#canvas");
|
||
|
if (
|
||
|
canvas instanceof HTMLCanvasElement &&
|
||
|
video instanceof HTMLVideoElement
|
||
|
) {
|
||
|
canvas
|
||
|
?.getContext("2d")
|
||
|
?.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||
10 months ago
|
// ... or set the blob:
|
||
|
// canvas?.toBlob(
|
||
|
// (blob) => {
|
||
|
// this.blob = blob;
|
||
|
// },
|
||
|
// "image/jpeg",
|
||
|
// 1,
|
||
|
// );
|
||
10 months ago
|
|
||
|
// data url of the image
|
||
10 months ago
|
const image_data_url = canvas?.toDataURL("image/jpeg");
|
||
10 months ago
|
}
|
||
|
}
|
||
|
****/
|
||
|
|
||
11 months ago
|
async uploadImage() {
|
||
|
this.uploading = true;
|
||
9 months ago
|
|
||
|
if (this.crop) {
|
||
8 months ago
|
this.blob = (await cropper?.getBlob()) || undefined;
|
||
9 months ago
|
}
|
||
|
|
||
11 months ago
|
const identifier = await getIdentity(this.activeDid);
|
||
|
const token = await accessToken(identifier);
|
||
|
const headers = {
|
||
|
Authorization: "Bearer " + token,
|
||
|
};
|
||
11 months ago
|
const formData = new FormData();
|
||
11 months ago
|
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;
|
||
|
}
|
||
8 months ago
|
formData.append("image", this.blob, this.fileName || "snapshot.png");
|
||
9 months ago
|
formData.append("claimType", this.claimType);
|
||
11 months ago
|
try {
|
||
|
const response = await axios.post(
|
||
11 months ago
|
DEFAULT_IMAGE_API_SERVER + "/image",
|
||
11 months ago
|
formData,
|
||
11 months ago
|
{ headers },
|
||
11 months ago
|
);
|
||
11 months ago
|
this.uploading = false;
|
||
11 months ago
|
|
||
10 months ago
|
this.visible = false;
|
||
8 months ago
|
this.blob = undefined;
|
||
|
this.setImageCallback(response.data.url as string);
|
||
11 months ago
|
} catch (error) {
|
||
|
console.error("Error uploading the image", error);
|
||
11 months ago
|
this.$notify(
|
||
|
{
|
||
|
group: "alert",
|
||
|
type: "danger",
|
||
|
title: "Error",
|
||
|
text: "There was an error saving the picture. Please try again.",
|
||
|
},
|
||
|
5000,
|
||
|
);
|
||
|
this.uploading = false;
|
||
8 months ago
|
this.blob = undefined;
|
||
11 months ago
|
}
|
||
|
}
|
||
10 months ago
|
|
||
|
swapMirrorClass() {
|
||
|
this.mirror = !this.mirror;
|
||
|
if (this.mirror) {
|
||
|
(this.$refs.cameraContainer as HTMLElement).classList.add("mirror-video");
|
||
|
} else {
|
||
|
(this.$refs.cameraContainer as HTMLElement).classList.remove(
|
||
|
"mirror-video",
|
||
|
);
|
||
|
}
|
||
|
}
|
||
11 months ago
|
}
|
||
|
</script>
|
||
10 months ago
|
|
||
|
<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;
|
||
|
}
|
||
10 months ago
|
|
||
|
.mirror-video {
|
||
|
transform: scaleX(-1);
|
||
|
-webkit-transform: scaleX(-1); /* For Safari */
|
||
|
-moz-transform: scaleX(-1); /* For Firefox */
|
||
|
-ms-transform: scaleX(-1); /* For IE */
|
||
|
-o-transform: scaleX(-1); /* For Opera */
|
||
|
}
|
||
10 months ago
|
</style>
|