@ -3,19 +3,49 @@
< div class = "dialog relative" >
< div class = "text-lg text-center font-bold relative" >
< h1 id = "ViewHeading" class = "text-center font-bold" >
{ { dialogHeading } }
{ { dialogHeading } }
< span v-if ="uploading" > Uploading Image & hellip ; < / span >
< span v -else -if = " blob " > { {
crop ? "Crop Image" : "Preview Image"
} } < / span >
< span v -else -if = " showCameraPreview " > Upload Image < / span >
< span v-else > Add Photo < / span >
< / h1 >
< div :class ="closeButtonClasses" @click ="close()" >
< div :class ="closeButtonClasses" @click ="close()" >
< 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]" > < / f o n t - a w e s o m e >
< / div >
< / div >
<!-- FEEDBACK : Show if camera preview is not visible after mounting -- >
< div v-if ="shouldShowCameraFeedback" :class ="cameraFeedbackClasses" >
< div
v - if = "!showCameraPreview && !blob && isRegistered"
class = "bg-red-100 text-red-700 border border-red-400 rounded px-4 py-3 my-4 text-sm"
>
< strong > Camera preview not started . < / strong >
< div > < b > Status : < / b > { { cameraFeedbackMessage } } < / div >
< div v-if ="cameraState === 'off'" >
< span v-if ="platformCapabilities.isMobile" >
< b > Note : < / b > This mobile browser may not support direct camera
access , or the app is treating it as a native app . < br / >
< b > Tip : < / b > Try using a desktop browser , or check if your browser
supports camera access for web apps . < br / >
< b > Developer : < / b > The platform detection logic may be skipping
camera preview for mobile browsers . < br / >
< b > Action : < / b > Review < code > platformCapabilities . isMobile < / code > and
ensure web browsers on mobile are not treated as native apps .
< / span >
< span v-else >
< b > Tip : < / b > Your browser supports camera APIs , but the preview did
not start . Try refreshing the page or checking browser permissions .
< / span >
< / div >
< div v -else -if = " cameraState = = = ' error ' " >
< b > Error : < / b > { { error || cameraStateMessage } }
< / div >
< div v-else >
< b > Status : < / b > { { cameraStateMessage || "Unknown reason." } }
< / div >
< / div >
< div class = "mt-4" >
@ -26,9 +56,12 @@
Take a photo with your camera
< / span >
< / div >
< div v-if ="shouldSho wCameraPreview" :class ="cameraPreviewClasses" >
< div v-if ="showCameraPreview" :class ="cameraPreviewClasses" >
<!-- Diagnostic Panel -- >
< div v-if ="showDiagnostics" :class ="diagnosticsPanelClasses" >
< div
v - if = "showDiagnostics"
: class = "diagnosticsPanelClasses"
>
< div class = "grid grid-cols-2 gap-2" >
< div >
< p > < strong > Camera State : < / strong > { { cameraState } } < / p >
@ -73,7 +106,7 @@
: class = "diagnosticsToggleClasses"
@ click = "toggleDiagnostics"
>
{ { diagnosticsToggleText } }
{ { showDiagnostics ? "Hide Diagnostics" : "Show Diagnostics" } }
< / button >
< div class = "camera-container w-full h-full relative" >
< video
@ -84,14 +117,11 @@
muted
> < / video >
< div :class ="cameraControlsClasses" >
< button
: class = "cameraControlButtonClasses"
@ click = "capturePhoto"
>
< button :class ="cameraControlButtonClasses" @click ="capturePhoto" >
< font -awesome icon = "camera" class = "w-[1em]" / >
< / button >
< button
v - if = "shouldShowCameraRotation "
v - if = "platformCapabilities.isMobile "
: class = "cameraControlButtonClasses"
@ click = "rotateCamera"
>
@ -148,7 +178,7 @@
backgroundColor : '#f8f8f8' ,
margin : 'auto' ,
} "
: img = "blobUrl "
: img = "createBlobURL(blob) "
: options = " {
viewMode : 1 ,
dragMode : 'crop' ,
@ -159,16 +189,24 @@
< / div >
< div v-else >
< div class = "flex justify-center" >
< img :src ="blobUrl" :class ="imageContainerClasses" / >
< img
: src = "createBlobURL(blob)"
: class = "imageContainerClasses"
/ >
< / div >
< / div >
< div :class ="buttonGridClasses" >
< button :class ="primaryButtonClasses" @click ="uploadImage" >
< div
: class = "buttonGridClasses"
>
< 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 = "secondaryButtonClasses"
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 >
@ -180,7 +218,7 @@
< template v-else >
< div
id = "noticeBeforeUpload"
: class = "registrationNoticeClasses "
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"
>
@ -188,7 +226,7 @@
Before you can upload a photo , a friend needs to register you .
< / p >
< button
: class = "registrationButtonClasses "
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 "
@ click = "handleQRCodeClick"
>
Share Your Info
@ -206,22 +244,25 @@ import { ref } from "vue";
import { Component , Vue } from "vue-facing-decorator" ;
import VuePictureCropper , { cropper } from "vue-picture-cropper" ;
import { Capacitor } from "@capacitor/core" ;
import { DEFAULT_IMAGE_API_SERVER , NotificationIface } from "../constants/app" ;
import { DEFAULT_IMAGE_API_SERVER } from "../constants/app" ;
import { accessToken } from "../libs/crypto" ;
import { logger } from "../utils/logger" ;
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin" ;
import { Prop } from "vue-facing-decorator" ;
import { Router } from "vue-router" ;
import {
NOTIFY_IMAGE_DIALOG_SETTINGS_ERROR ,
NOTIFY_IMAGE_DIALOG_RETRIEVAL_ERROR ,
NOTIFY_IMAGE_DIALOG_CAPTURE_ERROR ,
NOTIFY_IMAGE_DIALOG_NO_IMAGE_ERROR ,
NOTIFY_IMAGE_DIALOG_UPLOAD_ERROR ,
NOTIFY_IMAGE_DIALOG_AUTH_ERROR ,
NOTIFY_IMAGE_DIALOG_SERVER_ERROR ,
NOTIFY_IMAGE_DIALOG_FILE_TOO_LARGE ,
NOTIFY_IMAGE_DIALOG_UNSUPPORTED_FORMAT ,
createImageDialogCameraErrorMessage ,
createImageDialogUploadErrorMessage ,
IMAGE_DIALOG_TIMEOUT_LONG ,
IMAGE_DIALOG_TIMEOUT_MODAL ,
} from "../constants/notifications" ;
import { accessToken } from "../libs/crypto" ;
import { logger } from "../utils/logger" ;
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin" ;
import { Prop } from "vue-facing-decorator" ;
import { Router } from "vue-router" ;
import { createNotifyHelpers , TIMEOUTS } from "../utils/notify" ;
const inputImageFileNameRef = ref < Blob > ( ) ;
@ -230,8 +271,9 @@ const inputImageFileNameRef = ref<Blob>();
mixins : [ PlatformServiceMixin ] ,
} )
export default class ImageMethodDialog extends Vue {
$notify ! : ( notification : NotificationIface , timeout ? : number ) => void ;
$notify ! : ( notification : any , timeout ? : number ) => void ;
$router ! : Router ;
notify = createNotifyHelpers ( this . $notify ) ;
/** Active DID for user authentication */
activeDid = "" ;
@ -272,9 +314,10 @@ export default class ImageMethodDialog extends Vue {
/** Current camera facing mode */
private currentFacingMode : "environment" | "user" = "environment" ;
URL = window . URL || window . webkitURL ;
private platformCapabilities = this . platformService . getCapabilities ( ) ;
/** Platform capabilities (from mixin) */
get platformCapabilities ( ) {
return this . platformService . getCapabilities ( ) ;
}
/ / A d d d i a g n o s t i c p r o p e r t i e s
showDiagnostics = false ;
@ -296,104 +339,15 @@ export default class ImageMethodDialog extends Vue {
cameraStateMessage ? : string ;
error : string | null = null ;
/ / P r o p s
@ Prop ( { default : true } ) isRegistered ! : boolean ;
@ Prop ( {
default : "environment" ,
validator : ( value : string ) => [ "environment" , "user" ] . includes ( value ) ,
} )
defaultCameraMode ! : string ;
/ * *
* Computed property for dialog heading text
* Determines the appropriate heading based on current state
* /
get dialogHeading ( ) : string {
if ( this . uploading ) return "Uploading Image…" ;
if ( this . blob ) return this . crop ? "Crop Image" : "Preview Image" ;
if ( this . showCameraPreview ) return "Upload Image" ;
return "Add Photo" ;
}
/ * *
* Computed property for camera preview visibility
* Determines if camera preview should be shown
* /
get shouldShowCameraPreview ( ) : boolean {
return this . showCameraPreview && ! this . blob ;
}
/ * *
* Computed property for camera feedback visibility
* Shows feedback when camera preview is not visible after mounting
* /
get shouldShowCameraFeedback ( ) : boolean {
return ! this . showCameraPreview && ! this . blob && this . isRegistered ;
}
/ * *
* Computed property for camera feedback message
* Provides appropriate feedback based on camera state
* /
get cameraFeedbackMessage ( ) : string {
if ( this . cameraState === "off" ) {
if ( this . platformCapabilities . isMobile ) {
return "This mobile browser may not support direct camera access, or the app is treating it as a native app. Try using a desktop browser, or check if your browser supports camera access for web apps." ;
}
return "Your browser supports camera APIs, but the preview did not start. Try refreshing the page or checking browser permissions." ;
}
if ( this . cameraState === "error" ) {
return this . error || this . cameraStateMessage || "Unknown error occurred." ;
}
return this . cameraStateMessage || "Unknown reason." ;
}
/ * *
* Computed property for button grid classes
* Determines grid layout based on retry button visibility
* /
get buttonGridClasses ( ) : string {
return ` grid gap-2 mt-2 ${ this . showRetry ? "grid-cols-2" : "grid-cols-1" } ` ;
}
/ * *
* Computed property for blob URL
* Creates object URL for blob display
* /
get blobUrl ( ) : string {
return this . blob ? this . createBlobURL ( this . blob ) : "" ;
}
/ * *
* Computed property for diagnostics toggle button text
* Determines button text based on diagnostics visibility
* /
get diagnosticsToggleText ( ) : string {
return this . showDiagnostics ? "Hide Diagnostics" : "Show Diagnostics" ;
}
/ * *
* Computed property for camera rotation button visibility
* Shows rotation button only on mobile platforms
* /
get shouldShowCameraRotation ( ) : boolean {
return this . platformCapabilities . isMobile ;
}
/ * *
* Computed property for close button classes
* Provides consistent styling for the close button
* /
get closeButtonClasses ( ) : string {
return "text-2xl text-center px-1 py-0.5 leading-none absolute -right-1 top-0" ;
}
/ * *
* Computed property for camera feedback container classes
* Provides consistent styling for camera feedback messages
* /
get cameraFeedbackClasses ( ) : string {
return "bg-red-100 text-red-700 border border-red-400 rounded px-4 py-3 my-4 text-sm" ;
return [
'grid gap-2 mt-2' ,
this . showRetry ? 'grid-cols-2' : 'grid-cols-1' ,
] . join ( ' ' ) ;
}
/ * *
@ -401,7 +355,7 @@ export default class ImageMethodDialog extends Vue {
* Provides consistent styling for section dividers
* /
get sectionDividerClasses ( ) : string {
return "border-b border-dashed border-slate-300 text-orange-400 mb-4 font-bold text-sm" ;
return 'border-b border-dashed border-slate-300 text-orange-400 mb-4 font-bold text-sm' ;
}
/ * *
@ -409,7 +363,7 @@ export default class ImageMethodDialog extends Vue {
* Provides consistent styling for divider labels
* /
get sectionDividerSpanClasses ( ) : string {
return "block w-fit mx-auto -mb-2.5 bg-white px-2" ;
return 'block w-fit mx-auto -mb-2.5 bg-white px-2' ;
}
/ * *
@ -417,7 +371,7 @@ export default class ImageMethodDialog extends Vue {
* Provides consistent styling for camera preview
* /
get cameraPreviewClasses ( ) : string {
return "camera-preview relative flex bg-black overflow-hidden mb-4" ;
return 'camera-preview relative flex bg-black overflow-hidden mb-4' ;
}
/ * *
@ -425,7 +379,7 @@ export default class ImageMethodDialog extends Vue {
* Provides consistent styling for diagnostics overlay
* /
get diagnosticsPanelClasses ( ) : string {
return " absolute top-0 left-0 right-0 bg-black/80 text-white text-xs p-2 pt-8 z-20 overflow-auto max-h-[50vh]" ;
return ' absolute top-0 left-0 right-0 bg-black/80 text-white text-xs p-2 pt-8 z-20 overflow-auto max-h-[50vh]' ;
}
/ * *
@ -433,7 +387,7 @@ export default class ImageMethodDialog extends Vue {
* Provides consistent styling for diagnostics toggle
* /
get diagnosticsToggleClasses ( ) : string {
return "absolute top-2 right-2 bg-black/50 text-white px-2 py-1 rounded text-xs z-30" ;
return 'absolute top-2 right-2 bg-black/50 text-white px-2 py-1 rounded text-xs z-30' ;
}
/ * *
@ -441,7 +395,7 @@ export default class ImageMethodDialog extends Vue {
* Provides consistent styling for camera control buttons
* /
get cameraControlButtonClasses ( ) : string {
return "bg-white text-slate-800 p-3 rounded-full text-2xl leading-none" ;
return 'bg-white text-slate-800 p-3 rounded-full text-2xl leading-none' ;
}
/ * *
@ -449,7 +403,7 @@ export default class ImageMethodDialog extends Vue {
* Provides consistent styling for camera controls
* /
get cameraControlsClasses ( ) : string {
return "absolute bottom-4 inset-x-0 flex items-center justify-center gap-4" ;
return 'absolute bottom-4 inset-x-0 flex items-center justify-center gap-4' ;
}
/ * *
@ -457,7 +411,7 @@ export default class ImageMethodDialog extends Vue {
* Provides consistent styling for file input with custom button
* /
get fileInputClasses ( ) : string {
return " 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" ;
return ' 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' ;
}
/ * *
@ -465,7 +419,7 @@ export default class ImageMethodDialog extends Vue {
* Provides consistent styling for URL input field
* /
get urlInputClasses ( ) : string {
return "block w-full rounded border border-slate-400 px-4 py-2" ;
return 'block w-full rounded border border-slate-400 px-4 py-2' ;
}
/ * *
@ -473,56 +427,32 @@ export default class ImageMethodDialog extends Vue {
* Provides consistent styling for accept URL button
* /
get acceptUrlButtonClasses ( ) : 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 px-3 py-2 rounded-md cursor-pointer" ;
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 px-3 py-2 rounded-md cursor-pointer' ;
}
/ * *
* Computed property for image container classes
* Provides consistent styling for image display
* Provides consistent styling for image preview
* /
get imageContainerClasses ( ) : string {
return "mt-2 rounded max-h-[50vh] max-w-[90vw] object-contain" ;
return 'mt-2 rounded max-h-[50vh] max-w-[90vw] object-contain' ;
}
/ * *
* Computed property for cropper container c lasses
* Provides consistent styling for image cropper
* Computed property for cropper classes
* Provides consistent styling for cropper
* /
get cropperClasses ( ) : string {
return "max-h-[50vh] max-w-[90vw] object-contain" ;
return 'max-h-[50vh] max-w-[90vw] object-contain' ;
}
/ * *
* Computed property for primary button classes
* Provides consistent styling for primary action buttons
* /
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" ;
}
/ * *
* Computed property for secondary button classes
* Provides consistent styling for secondary action buttons
* /
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" ;
}
/ * *
* Computed property for registration notice classes
* Provides consistent styling for registration notice
* /
get registrationNoticeClasses ( ) : string {
return "bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3" ;
}
/ * *
* Computed property for registration button classes
* Provides consistent styling for registration button
* /
get registrationButtonClasses ( ) : string {
return "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" ;
}
/ / P r o p s
@ Prop ( { default : true } ) isRegistered ! : boolean ;
@ Prop ( {
default : "environment" ,
validator : ( value : string ) => [ "environment" , "user" ] . includes ( value ) ,
} )
defaultCameraMode ! : string ;
/ * *
* Lifecycle hook : Initializes component and retrieves user settings
@ -532,19 +462,11 @@ export default class ImageMethodDialog extends Vue {
try {
const settings = await this . $accountSettings ( ) ;
this . activeDid = settings . activeDid || "" ;
} catch ( error : unknown ) {
} catch ( error ) {
logger . error ( "Error retrieving settings from database:" , error ) ;
this . $notify (
{
group : "alert" ,
type : "danger" ,
title : "Error" ,
text :
error instanceof Error
? error . message
: NOTIFY_IMAGE_DIALOG_SETTINGS_ERROR . message ,
} ,
IMAGE_DIALOG_TIMEOUT_MODAL ,
this . notify . error (
NOTIFY_IMAGE_DIALOG_SETTINGS_ERROR . message ,
TIMEOUTS . MODAL ,
) ;
}
}
@ -604,14 +526,9 @@ export default class ImageMethodDialog extends Vue {
this . fileName = fileName ;
this . showRetry = false ;
} catch ( error ) {
this . $notify (
{
group : "alert" ,
type : "danger" ,
title : "Error" ,
text : NOTIFY_IMAGE_DIALOG_RETRIEVAL_ERROR . message ,
} ,
IMAGE_DIALOG_TIMEOUT_LONG ,
this . notify . error (
NOTIFY_IMAGE_DIALOG_RETRIEVAL_ERROR . message ,
TIMEOUTS . LONG ,
) ;
}
} else {
@ -684,21 +601,29 @@ export default class ImageMethodDialog extends Vue {
}
} catch ( error ) {
logger . error ( "Error starting camera preview:" , error ) ;
const errorMessage = createImageDialogCameraErrorMessage (
error instanceof Error ? error : new Error ( "Unknown camera error" ) ,
) ;
let errorMessage =
error instanceof Error ? error . message : "Failed to access camera" ;
if (
error instanceof Error &&
( error . name === "NotReadableError" || error . name === "TrackStartError" )
) {
errorMessage =
"Camera is in use by another application. Please close any other apps or browser tabs using the camera and try again." ;
} else if (
error instanceof Error &&
( error . name === "NotAllowedError" ||
error . name === "PermissionDeniedError" )
) {
errorMessage =
"Camera access was denied. Please allow camera access in your browser settings." ;
}
this . cameraState = "error" ;
this . cameraStateMessage = errorMessage ;
this . error = errorMessage ;
this . showCameraPreview = false ;
this . $notify (
{
group : "alert" ,
type : "danger" ,
title : "Error" ,
text : errorMessage ,
} ,
IMAGE_DIALOG_TIMEOUT_LONG ,
this . notify . error (
createImageDialogCameraErrorMessage ( error as Error ) ,
TIMEOUTS . LONG ,
) ;
}
}
@ -739,14 +664,9 @@ export default class ImageMethodDialog extends Vue {
) ;
} catch ( error ) {
logger . error ( "Error capturing photo:" , error ) ;
this . $notify (
{
group : "alert" ,
type : "danger" ,
title : "Error" ,
text : NOTIFY_IMAGE_DIALOG_CAPTURE_ERROR . message ,
} ,
IMAGE_DIALOG_TIMEOUT_LONG ,
this . notify . error (
NOTIFY_IMAGE_DIALOG_CAPTURE_ERROR . message ,
TIMEOUTS . LONG ,
) ;
}
}
@ -790,14 +710,9 @@ export default class ImageMethodDialog extends Vue {
} ;
const formData = new FormData ( ) ;
if ( ! this . blob ) {
this . $notify (
{
group : "alert" ,
type : "danger" ,
title : "Error" ,
text : NOTIFY_IMAGE_DIALOG_NO_IMAGE_ERROR . message ,
} ,
IMAGE_DIALOG_TIMEOUT_LONG ,
this . notify . error (
NOTIFY_IMAGE_DIALOG_NO_IMAGE_ERROR . message ,
TIMEOUTS . LONG ,
) ;
this . uploading = false ;
this . close ( ) ;
@ -824,17 +739,48 @@ export default class ImageMethodDialog extends Vue {
this . close ( ) ;
this . imageCallback ( response . data . url as string ) ;
} catch ( error : unknown ) {
const errorMessage = createImageDialogUploadErrorMessage ( error ) ;
this . $notify (
{
group : "alert" ,
type : "danger" ,
title : "Error" ,
text : errorMessage ,
} ,
IMAGE_DIALOG_TIMEOUT_LONG ,
) ;
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 ) {
this . notify . error (
NOTIFY_IMAGE_DIALOG_AUTH_ERROR . message ,
TIMEOUTS . LONG ,
) ;
} else if ( status === 413 ) {
this . notify . error (
NOTIFY_IMAGE_DIALOG_FILE_TOO_LARGE . message ,
TIMEOUTS . LONG ,
) ;
} else if ( status === 415 ) {
this . notify . error (
NOTIFY_IMAGE_DIALOG_UNSUPPORTED_FORMAT . message ,
TIMEOUTS . LONG ,
) ;
} else if ( status && status >= 500 ) {
this . notify . error (
NOTIFY_IMAGE_DIALOG_SERVER_ERROR . message ,
TIMEOUTS . LONG ,
) ;
} else if ( data ? . message ) {
errorMessage = data . message ;
this . notify . error ( errorMessage , TIMEOUTS . LONG ) ;
} else {
this . notify . error (
NOTIFY_IMAGE_DIALOG_UPLOAD_ERROR . message ,
TIMEOUTS . LONG ,
) ;
}
} else {
this . notify . error (
NOTIFY_IMAGE_DIALOG_UPLOAD_ERROR . message ,
TIMEOUTS . LONG ,
) ;
}
this . uploading = false ;
this . blob = undefined ;
this . close ( ) ;