@@ -188,7 +226,7 @@
Before you can upload a photo, a friend needs to register you.
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();
@@ -230,8 +271,9 @@ const inputImageFileNameRef = ref();
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();
+ }
// Add diagnostic properties
showDiagnostics = false;
@@ -296,104 +339,15 @@ export default class ImageMethodDialog extends Vue {
cameraStateMessage?: string;
error: string | null = null;
- // Props
- @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 classes
- * 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";
- }
+ // Props
+ @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();