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.
556 lines
16 KiB
556 lines
16 KiB
<template>
|
|
<div v-if="visible" class="dialog-overlay z-[60]">
|
|
<div class="dialog relative">
|
|
<div class="text-lg text-center font-bold relative">
|
|
<h1
|
|
id="ViewHeading"
|
|
class="text-center font-bold"
|
|
>
|
|
<span v-if="uploading">Uploading Image…</span>
|
|
<span v-else-if="blob">Crop Image</span>
|
|
<span v-else-if="showCameraPreview">Upload Image</span>
|
|
<span v-else>Add Photo</span>
|
|
</h1>
|
|
<div
|
|
class="text-2xl text-center px-1 py-0.5 leading-none absolute -right-1 top-0"
|
|
@click="close()"
|
|
>
|
|
<font-awesome icon="xmark" class="w-[1em]"></font-awesome>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-4">
|
|
<template v-if="isRegistered">
|
|
<div v-if="!blob">
|
|
<div
|
|
class="border-b border-dashed border-slate-300 text-orange-400 mb-4 font-bold text-sm"
|
|
>
|
|
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
|
|
Take a photo with your camera
|
|
</span>
|
|
</div>
|
|
<div v-if="showCameraPreview" class="camera-preview relative flex bg-black overflow-hidden mb-4">
|
|
<div class="camera-container w-full h-full relative">
|
|
<video
|
|
ref="videoElement"
|
|
class="camera-video w-full h-full object-cover"
|
|
autoplay
|
|
playsinline
|
|
muted
|
|
></video>
|
|
<button
|
|
class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
|
@click="capturePhoto"
|
|
>
|
|
<font-awesome icon="camera" class="w-[1em]" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-4 font-bold text-sm"
|
|
>
|
|
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
|
|
OR choose a file from your device
|
|
</span>
|
|
</div>
|
|
<div class="mt-4">
|
|
<input
|
|
type="file"
|
|
@change="uploadImageFile"
|
|
class="w-full file:text-center file:bg-gradient-to-b file:from-slate-400 file:to-slate-700 file:shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] file:text-white file:px-3 file:py-2 file:rounded-md file:border-none file:cursor-pointer file:me-2"
|
|
/>
|
|
</div>
|
|
<div
|
|
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-4 font-bold text-sm"
|
|
>
|
|
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
|
|
OR paste an image URL
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center gap-2 mt-4">
|
|
<input
|
|
v-model="imageUrl"
|
|
type="text"
|
|
class="block w-full rounded border border-slate-400 px-4 py-2"
|
|
placeholder="https://example.com/image.jpg"
|
|
/>
|
|
<button
|
|
v-if="imageUrl"
|
|
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 px-3 py-2 rounded-md cursor-pointer"
|
|
@click="acceptUrl"
|
|
>
|
|
<font-awesome icon="check" class="fa-fw" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else>
|
|
<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>
|
|
<div v-if="crop">
|
|
<VuePictureCropper
|
|
:box-style="{
|
|
backgroundColor: '#f8f8f8',
|
|
margin: 'auto',
|
|
}"
|
|
:img="createBlobURL(blob)"
|
|
:options="{
|
|
viewMode: 1,
|
|
dragMode: 'crop',
|
|
aspectRatio: 1 / 1,
|
|
}"
|
|
class="max-h-[90vh] max-w-[90vw] object-contain"
|
|
/>
|
|
</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="['grid gap-2 mt-2', showRetry ? 'grid-cols-2' : 'grid-cols-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-2 px-3 rounded-md"
|
|
@click="uploadImage"
|
|
>
|
|
<span>Upload</span>
|
|
</button>
|
|
<button
|
|
v-if="showRetry"
|
|
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-2 px-3 rounded-md"
|
|
@click="retryImage"
|
|
>
|
|
<span>Retry</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<div
|
|
id="noticeBeforeUpload"
|
|
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3"
|
|
role="alert"
|
|
aria-live="polite"
|
|
>
|
|
<p class="mb-2">
|
|
Before you can upload a photo, a friend needs to register you.
|
|
</p>
|
|
<router-link
|
|
:to="{ name: 'contact-qr' }"
|
|
class="inline-block text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
|
>
|
|
Share Your Info
|
|
</router-link>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import axios from "axios";
|
|
import { ref } from "vue";
|
|
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";
|
|
import { logger } from "../utils/logger";
|
|
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
|
|
|
const inputImageFileNameRef = ref<Blob>();
|
|
|
|
@Component({
|
|
components: { VuePictureCropper },
|
|
props: {
|
|
isRegistered: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
},
|
|
})
|
|
export default class ImageMethodDialog extends Vue {
|
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
|
|
/** Active DID for user authentication */
|
|
activeDid = "";
|
|
|
|
/** Current image blob being processed */
|
|
blob?: Blob;
|
|
|
|
/** Type of claim for the image */
|
|
claimType: string = "";
|
|
|
|
/** Whether to show cropping interface */
|
|
crop: boolean = false;
|
|
|
|
/** Name of the selected file */
|
|
fileName?: string;
|
|
|
|
/** Callback function to set image URL after upload */
|
|
imageCallback: (imageUrl?: string) => void = () => {};
|
|
|
|
/** URL for image input */
|
|
imageUrl?: string;
|
|
|
|
/** Whether to show retry button */
|
|
showRetry = true;
|
|
|
|
/** Upload progress state */
|
|
uploading = false;
|
|
|
|
/** Dialog visibility state */
|
|
visible = false;
|
|
|
|
/** Whether to show camera preview */
|
|
showCameraPreview = false;
|
|
|
|
/** Camera stream reference */
|
|
private cameraStream: MediaStream | null = null;
|
|
|
|
private platformService = PlatformServiceFactory.getInstance();
|
|
URL = window.URL || window.webkitURL;
|
|
|
|
private platformCapabilities = this.platformService.getCapabilities();
|
|
|
|
/**
|
|
* Lifecycle hook: Initializes component and retrieves user settings
|
|
* @throws {Error} When settings retrieval fails
|
|
*/
|
|
async mounted() {
|
|
console.log('ImageMethodDialog mounted');
|
|
try {
|
|
const settings = await retrieveSettingsForActiveAccount();
|
|
this.activeDid = settings.activeDid || "";
|
|
} catch (error: unknown) {
|
|
logger.error("Error retrieving settings from database:", error);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text:
|
|
error instanceof Error
|
|
? error.message
|
|
: "There was an error retrieving your settings.",
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lifecycle hook: Cleans up camera stream when component is destroyed
|
|
*/
|
|
beforeDestroy() {
|
|
this.stopCameraPreview();
|
|
}
|
|
|
|
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
|
|
this.claimType = claimType;
|
|
this.crop = !!crop;
|
|
this.imageCallback = setImageFn;
|
|
this.visible = true;
|
|
|
|
// Start camera preview immediately if not on mobile
|
|
if (!this.platformCapabilities.isMobile) {
|
|
this.startCameraPreview();
|
|
}
|
|
}
|
|
|
|
async uploadImageFile(event: Event) {
|
|
const target = event.target as HTMLInputElement;
|
|
if (!target.files) return;
|
|
|
|
inputImageFileNameRef.value = target.files[0];
|
|
const file = inputImageFileNameRef.value;
|
|
if (file != null) {
|
|
const reader = new FileReader();
|
|
reader.onload = async (e) => {
|
|
const data = e.target?.result as ArrayBuffer;
|
|
if (data) {
|
|
const blob = new Blob([new Uint8Array(data)], {
|
|
type: file.type,
|
|
});
|
|
this.blob = blob;
|
|
this.fileName = file.name;
|
|
this.showRetry = false;
|
|
}
|
|
};
|
|
reader.readAsArrayBuffer(file as Blob);
|
|
}
|
|
}
|
|
|
|
async acceptUrl() {
|
|
if (this.crop) {
|
|
try {
|
|
const urlBlobResponse = await axios.get(this.imageUrl as string, {
|
|
responseType: "blob",
|
|
});
|
|
const fullUrl = new URL(this.imageUrl as string);
|
|
const fileName = fullUrl.pathname.split("/").pop() as string;
|
|
this.blob = urlBlobResponse.data as Blob;
|
|
this.fileName = fileName;
|
|
this.showRetry = false;
|
|
} catch (error) {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "There was an error retrieving that image.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
} else {
|
|
this.imageCallback(this.imageUrl);
|
|
this.close();
|
|
}
|
|
}
|
|
|
|
close() {
|
|
this.visible = false;
|
|
this.stopCameraPreview();
|
|
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
|
|
if (bottomNav) {
|
|
bottomNav.style.display = "";
|
|
}
|
|
this.blob = undefined;
|
|
this.showCameraPreview = false;
|
|
}
|
|
|
|
async startCameraPreview() {
|
|
logger.debug("startCameraPreview called");
|
|
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
|
|
logger.debug("Platform capabilities:", this.platformCapabilities);
|
|
|
|
if (this.platformCapabilities.isMobile) {
|
|
logger.debug("Using platform service for mobile device");
|
|
try {
|
|
const result = await this.platformService.takePicture();
|
|
this.blob = result.blob;
|
|
this.fileName = result.fileName;
|
|
} catch (error) {
|
|
logger.error("Error taking picture:", error);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Failed to take picture. Please try again.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
logger.debug("Starting camera preview for desktop browser");
|
|
try {
|
|
this.showCameraPreview = true;
|
|
await this.$nextTick();
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: "environment" },
|
|
});
|
|
this.cameraStream = stream;
|
|
|
|
await this.$nextTick();
|
|
|
|
const videoElement = this.$refs.videoElement as HTMLVideoElement;
|
|
if (videoElement) {
|
|
videoElement.srcObject = stream;
|
|
await new Promise((resolve) => {
|
|
videoElement.onloadedmetadata = () => {
|
|
videoElement.play().then(() => {
|
|
resolve(true);
|
|
});
|
|
};
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logger.error("Error starting camera preview:", error);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Failed to access camera. Please try again.",
|
|
},
|
|
5000,
|
|
);
|
|
this.showCameraPreview = false;
|
|
}
|
|
}
|
|
|
|
stopCameraPreview() {
|
|
if (this.cameraStream) {
|
|
this.cameraStream.getTracks().forEach((track) => track.stop());
|
|
this.cameraStream = null;
|
|
}
|
|
this.showCameraPreview = false;
|
|
}
|
|
|
|
async capturePhoto() {
|
|
if (!this.cameraStream) return;
|
|
|
|
try {
|
|
const videoElement = this.$refs.videoElement as HTMLVideoElement;
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = videoElement.videoWidth;
|
|
canvas.height = videoElement.videoHeight;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx?.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
|
|
|
|
canvas.toBlob((blob) => {
|
|
if (blob) {
|
|
this.blob = blob;
|
|
this.fileName = `photo_${Date.now()}.jpg`;
|
|
this.showRetry = true;
|
|
this.stopCameraPreview();
|
|
}
|
|
}, "image/jpeg", 0.95);
|
|
} catch (error) {
|
|
logger.error("Error capturing photo:", error);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Failed to capture photo. Please try again.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
}
|
|
|
|
private createBlobURL(blob: Blob): string {
|
|
return URL.createObjectURL(blob);
|
|
}
|
|
|
|
async retryImage() {
|
|
this.blob = undefined;
|
|
if (!this.platformCapabilities.isMobile) {
|
|
await this.startCameraPreview();
|
|
}
|
|
}
|
|
|
|
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,
|
|
};
|
|
const formData = new FormData();
|
|
if (!this.blob) {
|
|
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 || "photo.jpg");
|
|
formData.append("claimType", this.claimType);
|
|
try {
|
|
if (
|
|
window.location.hostname === "localhost" &&
|
|
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
|
|
) {
|
|
logger.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.imageCallback(response.data.url as string);
|
|
} catch (error: unknown) {
|
|
let errorMessage = "There was an error saving the picture.";
|
|
|
|
if (axios.isAxiosError(error)) {
|
|
const status = error.response?.status;
|
|
const data = error.response?.data;
|
|
|
|
if (status === 401) {
|
|
errorMessage = "Authentication failed. Please try logging in again.";
|
|
} else if (status === 413) {
|
|
errorMessage = "Image file is too large. Please try a smaller image.";
|
|
} else if (status === 415) {
|
|
errorMessage =
|
|
"Unsupported image format. Please try a different image.";
|
|
} else if (status && status >= 500) {
|
|
errorMessage = "Server error. Please try again later.";
|
|
} else if (data?.message) {
|
|
errorMessage = data.message;
|
|
}
|
|
}
|
|
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: errorMessage,
|
|
},
|
|
5000,
|
|
);
|
|
this.uploading = false;
|
|
this.blob = undefined;
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
.dialog-overlay {
|
|
z-index: 50;
|
|
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;
|
|
max-height: 90vh;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
</style>
|
|
|