forked from trent_larson/crowd-funder-for-time-pwa
Changes: - Move v-model directives before other attributes - Move v-bind directives before event handlers - Reorder attributes for better readability - Fix template attribute ordering across components - Improve eslint rules - add default vite config for testing (handles nostr error too) This follows Vue.js style guide recommendations for attribute ordering and improves template consistency.
449 lines
14 KiB
Vue
449 lines
14 KiB
Vue
<template>
|
|
<div v-if="visible" class="dialog-overlay z-[60]">
|
|
<div class="dialog relative">
|
|
<div class="text-lg text-center font-light relative z-50">
|
|
<div
|
|
id="ViewHeading"
|
|
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
|
|
>
|
|
<span v-if="uploading"> Uploading... </span>
|
|
<span v-else-if="blob"> Look Good? </span>
|
|
<span v-else> Say "Cheese"! </span>
|
|
</div>
|
|
|
|
<div
|
|
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
|
|
@click="close()"
|
|
>
|
|
<font-awesome icon="xmark" class="w-[1em]"></font-awesome>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="uploading" class="flex justify-center">
|
|
<font-awesome
|
|
icon="spinner"
|
|
class="fa-spin fa-3x text-center block px-12 py-12"
|
|
/>
|
|
</div>
|
|
<div v-else-if="blob">
|
|
<div v-if="crop">
|
|
<VuePictureCropper
|
|
:box-style="{
|
|
backgroundColor: '#f8f8f8',
|
|
margin: 'auto',
|
|
}"
|
|
:img="createBlobURL(blob)"
|
|
:options="{
|
|
viewMode: 1,
|
|
dragMode: 'crop',
|
|
aspectRatio: 9 / 9,
|
|
}"
|
|
class="max-h-[90vh] max-w-[90vw] object-contain"
|
|
/>
|
|
<!-- This gives a round cropper.
|
|
:presetMode="{
|
|
mode: 'round',
|
|
}"
|
|
-->
|
|
</div>
|
|
<div v-else>
|
|
<div class="flex justify-center">
|
|
<img
|
|
:src="createBlobURL(blob)"
|
|
class="mt-2 rounded max-h-[90vh] max-w-[90vw] object-contain"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="absolute bottom-[1rem] left-[1rem] px-2 py-1">
|
|
<button
|
|
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"
|
|
@click="uploadImage"
|
|
>
|
|
<span>Upload</span>
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-if="showRetry"
|
|
class="absolute bottom-[1rem] right-[1rem] px-2 py-1"
|
|
>
|
|
<button
|
|
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"
|
|
@click="retryImage"
|
|
>
|
|
<span>Retry</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-else ref="cameraContainer">
|
|
<!--
|
|
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
|
|
ref="camera"
|
|
facing-mode="environment"
|
|
autoplay
|
|
@started="cameraStarted()"
|
|
>
|
|
<div
|
|
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"
|
|
>
|
|
<button
|
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
|
@click="takeImage()"
|
|
>
|
|
<font-awesome icon="camera" class="w-[1em]"></font-awesome>
|
|
</button>
|
|
</div>
|
|
<div
|
|
class="absolute portrait:bottom-2 portrait:right-16 landscape:right-0 landscape:bottom-16 landscape:pr-4 flex justify-center items-center"
|
|
>
|
|
<button
|
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
|
@click="swapMirrorClass()"
|
|
>
|
|
<font-awesome icon="left-right" class="w-[1em]"></font-awesome>
|
|
</button>
|
|
</div>
|
|
<div v-if="numDevices > 1" class="absolute bottom-2 right-4">
|
|
<button
|
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
|
@click="switchCamera()"
|
|
>
|
|
<font-awesome icon="rotate" class="w-[1em]"></font-awesome>
|
|
</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 VuePictureCropper, { cropper } from "vue-picture-cropper";
|
|
|
|
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
|
|
import { retrieveSettingsForActiveAccount } from "../db/index";
|
|
import { accessToken } from "../libs/crypto";
|
|
|
|
@Component({ components: { Camera, VuePictureCropper } })
|
|
export default class PhotoDialog extends Vue {
|
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
|
|
activeDeviceNumber = 0;
|
|
activeDid = "";
|
|
blob?: Blob;
|
|
claimType = "";
|
|
crop = false;
|
|
fileName?: string;
|
|
mirror = false;
|
|
numDevices = 0;
|
|
setImageCallback: (arg: string) => void = () => {};
|
|
showRetry = true;
|
|
uploading = false;
|
|
visible = false;
|
|
|
|
URL = window.URL || window.webkitURL;
|
|
|
|
async mounted() {
|
|
try {
|
|
const settings = await retrieveSettingsForActiveAccount();
|
|
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,
|
|
claimType: string,
|
|
crop?: boolean,
|
|
blob?: Blob, // for image upload, just to use the cropping function
|
|
inputFileName?: string,
|
|
) {
|
|
this.visible = true;
|
|
this.claimType = claimType;
|
|
this.crop = !!crop;
|
|
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
|
|
if (bottomNav) {
|
|
bottomNav.style.display = "none";
|
|
}
|
|
this.setImageCallback = setImageFn;
|
|
if (blob) {
|
|
this.blob = blob;
|
|
this.fileName = inputFileName;
|
|
// doesn't make sense to retry the file upload; they can cancel if they picked the wrong one
|
|
this.showRetry = false;
|
|
} else {
|
|
this.blob = undefined;
|
|
this.fileName = undefined;
|
|
this.showRetry = true;
|
|
}
|
|
}
|
|
|
|
close() {
|
|
this.visible = false;
|
|
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
|
|
if (bottomNav) {
|
|
bottomNav.style.display = "";
|
|
}
|
|
this.blob = undefined;
|
|
}
|
|
|
|
async cameraStarted() {
|
|
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
|
|
if (cameraComponent) {
|
|
this.numDevices = (await cameraComponent.devices(["videoinput"])).length;
|
|
this.mirror = cameraComponent.facingMode === "user";
|
|
// figure out which device is active
|
|
const currentDeviceId = cameraComponent.currentDeviceID();
|
|
const devices = await cameraComponent.devices(["videoinput"]);
|
|
this.activeDeviceNumber = devices.findIndex(
|
|
(device) => device.deviceId === currentDeviceId,
|
|
);
|
|
}
|
|
}
|
|
|
|
async switchCamera() {
|
|
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
|
|
this.activeDeviceNumber = (this.activeDeviceNumber + 1) % this.numDevices;
|
|
const devices = await cameraComponent?.devices(["videoinput"]);
|
|
await cameraComponent?.changeCamera(
|
|
devices[this.activeDeviceNumber].deviceId,
|
|
);
|
|
}
|
|
|
|
async takeImage(/* payload: MouseEvent */) {
|
|
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
|
|
|
|
/**
|
|
* 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;
|
|
const windowRatio = window.innerWidth / window.innerHeight;
|
|
if (initialImageRatio > 1 && windowRatio < 1) {
|
|
// the image is wider than it is tall, and the window is taller than it is wide
|
|
// For some reason, mobile in portrait orientation renders a horizontally-stretched image.
|
|
// We're gonna force it opposite.
|
|
imageHeight = cameraComponent?.resolution?.width;
|
|
imageWidth = cameraComponent?.resolution?.height;
|
|
} else if (initialImageRatio < 1 && windowRatio > 1) {
|
|
// the image is taller than it is wide, and the window is wider than it is tall
|
|
// Haven't seen this happen, but we'll do it just in case.
|
|
imageHeight = cameraComponent?.resolution?.width;
|
|
imageWidth = cameraComponent?.resolution?.height;
|
|
}
|
|
const newImageRatio = imageWidth / imageHeight;
|
|
if (newImageRatio < windowRatio) {
|
|
// the image is a taller ratio than the window, so fit the height first
|
|
imageHeight = window.innerHeight / 2;
|
|
imageWidth = imageHeight * newImageRatio;
|
|
} else {
|
|
// the image is a wider ratio than the window, so fit the width first
|
|
imageWidth = window.innerWidth / 2;
|
|
imageHeight = imageWidth / newImageRatio;
|
|
}
|
|
|
|
// The resolution is only necessary because of that mobile portrait-orientation case.
|
|
// The mobile emulation in a browser shows something stretched vertically, but real devices work fine.
|
|
this.blob =
|
|
(await cameraComponent?.snapshot({
|
|
height: imageHeight,
|
|
width: imageWidth,
|
|
})) || undefined;
|
|
// png is default
|
|
this.fileName = "snapshot.png";
|
|
if (!this.blob) {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "There was an error taking the picture. Please try again.",
|
|
},
|
|
5000,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
private createBlobURL(blob: Blob): string {
|
|
return URL.createObjectURL(blob);
|
|
}
|
|
|
|
async retryImage() {
|
|
this.blob = undefined;
|
|
}
|
|
|
|
/****
|
|
|
|
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.
|
|
|
|
<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);
|
|
// ... or set the blob:
|
|
// canvas?.toBlob(
|
|
// (blob) => {
|
|
// this.blob = blob;
|
|
// },
|
|
// "image/jpeg",
|
|
// 1,
|
|
// );
|
|
|
|
// data url of the image
|
|
const image_data_url = canvas?.toDataURL("image/jpeg");
|
|
}
|
|
}
|
|
****/
|
|
|
|
async uploadImage() {
|
|
this.uploading = true;
|
|
|
|
if (this.crop) {
|
|
this.blob = (await cropper?.getBlob()) || undefined;
|
|
}
|
|
|
|
const token = await accessToken(this.activeDid);
|
|
const headers = {
|
|
Authorization: "Bearer " + token,
|
|
// axios fills in Content-Type of multipart/form-data
|
|
};
|
|
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, this.fileName || "snapshot.png");
|
|
formData.append("claimType", this.claimType);
|
|
try {
|
|
if (
|
|
window.location.hostname === "localhost" &&
|
|
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
|
|
) {
|
|
console.log(
|
|
"Using shared image API server, so only users on that server can play with images.",
|
|
);
|
|
}
|
|
const response = await axios.post(
|
|
DEFAULT_IMAGE_API_SERVER + "/image",
|
|
formData,
|
|
{ headers },
|
|
);
|
|
this.uploading = false;
|
|
|
|
this.close();
|
|
this.setImageCallback(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.",
|
|
},
|
|
5000,
|
|
);
|
|
this.uploading = false;
|
|
this.blob = undefined;
|
|
}
|
|
}
|
|
|
|
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",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
.dialog-overlay {
|
|
z-index: 60;
|
|
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;
|
|
}
|
|
|
|
.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 */
|
|
}
|
|
</style>
|