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.
 
 
 
 
 
 

692 lines
20 KiB

/** * PhotoDialog.vue - Cross-platform photo capture and selection component * *
This component provides a unified interface for taking photos and selecting
images * across different platforms (web, mobile) using the PlatformService. It
supports: * - Taking photos using device camera * - Selecting images from device
gallery * - Image cropping functionality * - Image upload to server * - Error
handling and user feedback * * Features: * - Responsive design with mobile-first
approach * - Cross-platform compatibility through PlatformService * - Image
cropping with aspect ratio control * - Progress feedback during upload * -
Comprehensive error handling * * @author Matthew Raymer * @version 1.0.0 * @file
PhotoDialog.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="headingClasses">
<span v-if="uploading"> Uploading... </span>
<span v-else-if="blob"> Look Good? </span>
<span v-else-if="showCameraPreview"> Take Photo </span>
<span v-else> Say "Cheese"! </span>
</div>
<div :class="closeButtonClasses" @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="cropperBoxStyle"
:img="blobUrl"
:options="cropperOptions"
class="max-h-[90vh] max-w-[90vw] object-contain"
/>
</div>
<div v-else>
<div class="flex justify-center">
<img :src="blobUrl" :class="imageDisplayClasses" />
</div>
</div>
<div class="grid grid-cols-2 gap-2 mt-2">
<button :class="primaryButtonClasses" @click="uploadImage">
<span>Upload</span>
</button>
<button
v-if="showRetry"
:class="secondaryButtonClasses"
@click="retryImage"
>
<span>Retry</span>
</button>
</div>
</div>
<div v-else-if="showCameraPreview" class="camera-preview">
<div class="camera-container">
<video
ref="videoElement"
class="camera-video"
autoplay
playsinline
muted
></video>
<button :class="cameraButtonClasses" @click="capturePhoto">
<font-awesome icon="camera" class="w-[1em]" />
</button>
</div>
</div>
<div v-else>
<div class="flex flex-col items-center justify-center gap-4 p-4">
<button
v-if="isRegistered"
:class="actionButtonClasses"
@click="startCameraPreview"
>
<font-awesome icon="camera" class="w-[1em]" />
</button>
<button :class="actionButtonClasses" @click="pickPhoto">
<font-awesome icon="image" class="w-[1em]" />
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import {
NOTIFY_PHOTO_SETTINGS_ERROR,
NOTIFY_PHOTO_CAPTURE_ERROR,
NOTIFY_PHOTO_CAMERA_ERROR,
NOTIFY_PHOTO_UPLOAD_ERROR,
NOTIFY_PHOTO_UNSUPPORTED_FORMAT,
NOTIFY_PHOTO_SIZE_ERROR,
NOTIFY_PHOTO_PROCESSING_ERROR,
} from "@/constants/notifications";
@Component({
components: { VuePictureCropper },
mixins: [PlatformServiceMixin],
})
export default class PhotoDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
notify!: ReturnType<typeof createNotifyHelpers>;
/** Active DID for user authentication */
activeDid = "";
/** Current image blob being processed */
blob?: Blob;
/** Type of claim for the image */
claimType = "";
/** Whether to show cropping interface */
crop = false;
/** Name of the selected file */
fileName?: string;
/** Callback function to set image URL after upload */
setImageCallback: (arg: string) => void = () => {};
/** 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;
URL = window.URL || window.webkitURL;
isRegistered = false;
// =================================================
// COMPUTED PROPERTIES - Template Logic Streamlining
// =================================================
/**
* CSS classes for the dialog heading section
* Reduces template complexity for absolute positioning and styling
*/
get headingClasses(): string {
return "text-center font-bold absolute top-0 inset-x-0 px-4 py-2 bg-black/50 text-white leading-none pointer-events-none";
}
/**
* CSS classes for the close button
* Reduces template complexity for absolute positioning and styling
*/
get closeButtonClasses(): string {
return "text-lg text-center px-2 py-2 leading-none absolute right-0 top-0 text-white cursor-pointer";
}
/**
* CSS classes for the primary action button (Upload)
* Reduces template complexity for gradient button styling
*/
get primaryButtonClasses(): string {
return "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";
}
/**
* CSS classes for the secondary action button (Retry)
* Reduces template complexity for gradient button styling
*/
get secondaryButtonClasses(): string {
return "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";
}
/**
* CSS classes for the camera capture button
* Reduces template complexity for absolute positioning and circular styling
*/
get cameraButtonClasses(): string {
return "absolute bottom-4 left-1/2 -translate-x-1/2 bg-white text-slate-800 p-3 rounded-full text-2xl leading-none";
}
/**
* CSS classes for action buttons (camera/image selection)
* Reduces template complexity for button styling
*/
get actionButtonClasses(): string {
return "bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none";
}
/**
* CSS classes for image display
* Reduces template complexity for image styling
*/
get imageDisplayClasses(): string {
return "mt-2 rounded max-h-[90vh] max-w-[90vw] object-contain";
}
/**
* Picture cropper box style configuration
* Consolidates complex configuration object from template
*/
get cropperBoxStyle(): object {
return {
backgroundColor: "#f8f8f8",
margin: "auto",
};
}
/**
* Picture cropper options configuration
* Consolidates complex configuration object from template
*/
get cropperOptions(): object {
return {
viewMode: 1,
dragMode: "crop",
aspectRatio: 1 / 1,
};
}
/**
* Blob URL for displaying images
* Encapsulates blob URL creation logic
*/
get blobUrl(): string {
return this.blob ? this.createBlobURL(this.blob) : "";
}
/**
* Platform capabilities accessor
* Provides cached access to platform capabilities
*/
get platformCapabilities() {
return this.$platformService.getCapabilities();
}
// =================================================
// COMPONENT METHODS
// =================================================
/**
* Lifecycle hook: Initializes component and retrieves user settings
* @throws {Error} When settings retrieval fails
*/
async mounted() {
this.notify = createNotifyHelpers(this.$notify);
// logger.log("PhotoDialog mounted");
try {
const settings = await this.$accountSettings();
this.activeDid = settings.activeDid || "";
this.isRegistered = !!settings.isRegistered;
logger.log("isRegistered:", this.isRegistered);
} catch (error: unknown) {
logger.error("Error retrieving settings from database:", error);
this.notify.error(
error instanceof Error
? error.message
: NOTIFY_PHOTO_SETTINGS_ERROR.message,
TIMEOUTS.MODAL,
);
}
}
/**
* Lifecycle hook: Cleans up camera stream when component is destroyed
*/
beforeDestroy() {
this.stopCameraPreview();
}
/**
* Opens the photo dialog with specified configuration
* @param setImageFn - Callback function to handle image URL after upload
* @param claimType - Type of claim for the image
* @param crop - Whether to enable cropping
* @param blob - Optional existing image blob
* @param inputFileName - Optional filename for the image
*/
async open(
setImageFn: (arg: string) => void,
claimType: string,
crop?: boolean,
blob?: Blob,
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;
this.showRetry = false;
} else {
this.blob = undefined;
this.fileName = undefined;
this.showRetry = true;
// Start camera preview automatically if no blob is provided
if (!this.platformCapabilities.isMobile) {
await this.startCameraPreview();
}
}
}
/**
* Closes the photo dialog and resets state
*/
close() {
logger.debug(
"Dialog closing, current showCameraPreview:",
this.showCameraPreview,
);
this.visible = false;
this.stopCameraPreview();
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) {
bottomNav.style.display = "";
}
this.blob = undefined;
}
/**
* Starts the camera preview
*/
async startCameraPreview() {
logger.debug("startCameraPreview called");
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
logger.debug("Platform capabilities:", this.platformCapabilities);
// If we're on a mobile device or using Capacitor, use the platform service
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.error(
NOTIFY_PHOTO_CAPTURE_ERROR.message,
TIMEOUTS.STANDARD,
);
}
return;
}
// For desktop web browsers, use our custom preview
logger.debug("Starting camera preview for desktop browser");
try {
// Set state before requesting camera access
this.showCameraPreview = true;
logger.debug("showCameraPreview set to:", this.showCameraPreview);
// Force a re-render
await this.$nextTick();
logger.debug(
"After nextTick, showCameraPreview is:",
this.showCameraPreview,
);
logger.debug("Requesting camera access...");
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
});
logger.debug("Camera access granted, setting up video element");
this.cameraStream = stream;
// Force another re-render after getting the stream
await this.$nextTick();
logger.debug(
"After getting stream, showCameraPreview is:",
this.showCameraPreview,
);
const videoElement = this.$refs.videoElement as HTMLVideoElement;
if (videoElement) {
logger.debug("Video element found, setting srcObject");
videoElement.srcObject = stream;
// Wait for video to be ready
await new Promise((resolve) => {
videoElement.onloadedmetadata = () => {
logger.debug("Video metadata loaded");
videoElement.play().then(() => {
logger.debug("Video playback started");
resolve(true);
});
};
});
} else {
logger.error("Video element not found");
}
} catch (error) {
logger.error("Error starting camera preview:", error);
this.notify.error(NOTIFY_PHOTO_CAMERA_ERROR.message, TIMEOUTS.STANDARD);
this.showCameraPreview = false;
}
}
/**
* Stops the camera preview and cleans up resources
*/
stopCameraPreview() {
logger.debug(
"Stopping camera preview, current showCameraPreview:",
this.showCameraPreview,
);
if (this.cameraStream) {
this.cameraStream.getTracks().forEach((track) => track.stop());
this.cameraStream = null;
}
this.showCameraPreview = false;
logger.debug(
"After stopping, showCameraPreview is:",
this.showCameraPreview,
);
}
/**
* Captures a photo from the camera preview
*/
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.stopCameraPreview();
}
},
"image/jpeg",
0.95,
);
} catch (error) {
logger.error("Error capturing photo:", error);
this.notify.error(NOTIFY_PHOTO_CAPTURE_ERROR.message, TIMEOUTS.STANDARD);
}
}
/**
* Captures a photo using device camera
* @throws {Error} When camera access fails
*/
async takePhoto() {
try {
const result = await this.platformService.takePicture();
this.blob = result.blob;
this.fileName = result.fileName;
} catch (error: unknown) {
logger.error("Error taking picture:", error);
this.notify.error(NOTIFY_PHOTO_CAPTURE_ERROR.message, TIMEOUTS.STANDARD);
}
}
/**
* Selects an image from device gallery
* @throws {Error} When gallery access fails
*/
async pickPhoto() {
try {
const result = await this.platformService.pickImage();
this.blob = result.blob;
this.fileName = result.fileName;
} catch (error: unknown) {
logger.error("Error picking image:", error);
this.notify.error(
NOTIFY_PHOTO_PROCESSING_ERROR.message,
TIMEOUTS.STANDARD,
);
}
}
/**
* Creates a blob URL for image preview
* @param blob - Image blob to create URL for
* @returns {string} Blob URL for the image
*/
private createBlobURL(blob: Blob): string {
return URL.createObjectURL(blob);
}
/**
* Resets the current image selection and restarts camera preview
*/
async retryImage() {
this.blob = undefined;
if (!this.platformCapabilities.isMobile) {
await this.startCameraPreview();
}
}
/**
* Uploads the current image to the server
* Handles cropping if enabled and manages upload state
* @throws {Error} When upload fails or server returns error
*/
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.error(
NOTIFY_PHOTO_PROCESSING_ERROR.message,
TIMEOUTS.STANDARD,
);
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.setImageCallback(response.data.url as string);
} catch (error: unknown) {
// Log the raw error first
logger.error("Raw error object:", JSON.stringify(error, null, 2));
let errorMessage = NOTIFY_PHOTO_UPLOAD_ERROR.message;
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const statusText = error.response?.statusText;
const data = error.response?.data;
// Log detailed error information
logger.error("Upload error details:", {
status,
statusText,
data: JSON.stringify(data, null, 2),
message: error.message,
config: {
url: error.config?.url,
method: error.config?.method,
headers: error.config?.headers,
},
});
if (status === 401) {
errorMessage = "Authentication failed. Please try logging in again.";
} else if (status === 413) {
errorMessage = NOTIFY_PHOTO_SIZE_ERROR.message;
} else if (status === 415) {
errorMessage = NOTIFY_PHOTO_UNSUPPORTED_FORMAT.message;
} else if (status && status >= 500) {
errorMessage = "Server error. Please try again later.";
} else if (data?.message) {
errorMessage = data.message;
}
} else if (error instanceof Error) {
// Log non-Axios error with full details
logger.error("Non-Axios error details:", {
name: error.name,
message: error.message,
stack: error.stack,
error: JSON.stringify(error, Object.getOwnPropertyNames(error), 2),
});
} else {
// Log any other type of error
logger.error("Unknown error type:", {
error: JSON.stringify(error, null, 2),
type: typeof error,
});
}
this.notify.error(errorMessage, TIMEOUTS.STANDARD);
this.uploading = false;
this.blob = undefined;
}
}
}
</script>
<style>
/* Dialog overlay styling */
.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 container styling */
.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;
}
/* Camera preview styling */
.camera-preview {
flex: 1;
background-color: #000;
overflow: hidden;
position: relative;
}
.camera-container {
width: 100%;
height: 100%;
position: relative;
}
.camera-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.capture-button {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(to bottom, #60a5fa, #2563eb);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 9999px;
box-shadow: inset 0 -1px 0 0 rgba(0, 0, 0, 0.5);
border: none;
cursor: pointer;
}
</style>