Browse Source

refactor: extract long CSS classes to computed properties in ImageMethodDialog

- Replace 20 long class strings with computed properties for better maintainability
- Improve template readability and semantic structure
- Centralize styling logic for consistent UI patterns
- Maintain all existing functionality while improving code organization
pull/142/head
Matthew Raymer 3 weeks ago
parent
commit
596f6059ce
  1. 411
      src/components/ImageMethodDialog.vue
  2. 234
      src/constants/notifications.ts
  3. 82
      src/views/NewEditProjectView.vue

411
src/components/ImageMethodDialog.vue

@ -3,70 +3,30 @@
<div class="dialog relative"> <div class="dialog relative">
<div class="text-lg text-center font-bold relative"> <div class="text-lg text-center font-bold relative">
<h1 id="ViewHeading" class="text-center font-bold"> <h1 id="ViewHeading" class="text-center font-bold">
<span v-if="uploading">Uploading Image&hellip;</span> {{ dialogHeading }}
<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> </h1>
<div <div :class="closeButtonClasses" @click="close()">
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> <font-awesome icon="xmark" class="w-[1em]"></font-awesome>
</div> </div>
</div> </div>
<!-- FEEDBACK: Show if camera preview is not visible after mounting --> <!-- FEEDBACK: Show if camera preview is not visible after mounting -->
<div <div v-if="shouldShowCameraFeedback" :class="cameraFeedbackClasses">
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> <strong>Camera preview not started.</strong>
<div v-if="cameraState === 'off'"> <div><b>Status:</b> {{ cameraFeedbackMessage }}</div>
<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>
<div class="mt-4"> <div class="mt-4">
<template v-if="isRegistered"> <template v-if="isRegistered">
<div v-if="!blob"> <div v-if="!blob">
<div <div :class="sectionDividerClasses">
class="border-b border-dashed border-slate-300 text-orange-400 mb-4 font-bold text-sm" <span :class="sectionDividerSpanClasses">
>
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
Take a photo with your camera Take a photo with your camera
</span> </span>
</div> </div>
<div <div v-if="shouldShowCameraPreview" :class="cameraPreviewClasses">
v-if="showCameraPreview"
class="camera-preview relative flex bg-black overflow-hidden mb-4"
>
<!-- Diagnostic Panel --> <!-- Diagnostic Panel -->
<div <div v-if="showDiagnostics" :class="diagnosticsPanelClasses">
v-if="showDiagnostics"
class="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]"
>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<div> <div>
<p><strong>Camera State:</strong> {{ cameraState }}</p> <p><strong>Camera State:</strong> {{ cameraState }}</p>
@ -108,10 +68,10 @@
<!-- Toggle Diagnostics Button --> <!-- Toggle Diagnostics Button -->
<button <button
class="absolute top-2 right-2 bg-black/50 text-white px-2 py-1 rounded text-xs z-30" :class="diagnosticsToggleClasses"
@click="toggleDiagnostics" @click="toggleDiagnostics"
> >
{{ showDiagnostics ? "Hide Diagnostics" : "Show Diagnostics" }} {{ diagnosticsToggleText }}
</button> </button>
<div class="camera-container w-full h-full relative"> <div class="camera-container w-full h-full relative">
<video <video
@ -121,18 +81,16 @@
playsinline playsinline
muted muted
></video> ></video>
<div <div :class="cameraControlsClasses">
class="absolute bottom-4 inset-x-0 flex items-center justify-center gap-4"
>
<button <button
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none" :class="cameraControlButtonClasses"
@click="capturePhoto" @click="capturePhoto"
> >
<font-awesome icon="camera" class="w-[1em]" /> <font-awesome icon="camera" class="w-[1em]" />
</button> </button>
<button <button
v-if="platformCapabilities.isMobile" v-if="shouldShowCameraRotation"
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none" :class="cameraControlButtonClasses"
@click="rotateCamera" @click="rotateCamera"
> >
<font-awesome icon="rotate" class="w-[1em]" /> <font-awesome icon="rotate" class="w-[1em]" />
@ -140,24 +98,20 @@
</div> </div>
</div> </div>
</div> </div>
<div <div :class="sectionDividerClasses">
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-4 font-bold text-sm" <span :class="sectionDividerSpanClasses">
>
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
OR choose a file from your device OR choose a file from your device
</span> </span>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<input <input
type="file" type="file"
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" :class="fileInputClasses"
@change="uploadImageFile" @change="uploadImageFile"
/> />
</div> </div>
<div <div :class="sectionDividerClasses">
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-4 font-bold text-sm" <span :class="sectionDividerSpanClasses">
>
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
OR paste an image URL OR paste an image URL
</span> </span>
</div> </div>
@ -165,12 +119,12 @@
<input <input
v-model="imageUrl" v-model="imageUrl"
type="text" type="text"
class="block w-full rounded border border-slate-400 px-4 py-2" :class="urlInputClasses"
placeholder="https://example.com/image.jpg" placeholder="https://example.com/image.jpg"
/> />
<button <button
v-if="imageUrl" 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" :class="acceptUrlButtonClasses"
@click="acceptUrl" @click="acceptUrl"
> >
<font-awesome icon="check" class="fa-fw" /> <font-awesome icon="check" class="fa-fw" />
@ -192,38 +146,27 @@
backgroundColor: '#f8f8f8', backgroundColor: '#f8f8f8',
margin: 'auto', margin: 'auto',
}" }"
:img="createBlobURL(blob)" :img="blobUrl"
:options="{ :options="{
viewMode: 1, viewMode: 1,
dragMode: 'crop', dragMode: 'crop',
aspectRatio: 1 / 1, aspectRatio: 1 / 1,
}" }"
class="max-h-[50vh] max-w-[90vw] object-contain" :class="cropperClasses"
/> />
</div> </div>
<div v-else> <div v-else>
<div class="flex justify-center"> <div class="flex justify-center">
<img <img :src="blobUrl" :class="imageContainerClasses" />
:src="createBlobURL(blob)"
class="mt-2 rounded max-h-[50vh] max-w-[90vw] object-contain"
/>
</div> </div>
</div> </div>
<div <div :class="buttonGridClasses">
:class="[ <button :class="primaryButtonClasses" @click="uploadImage">
'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> <span>Upload</span>
</button> </button>
<button <button
v-if="showRetry" 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" :class="secondaryButtonClasses"
@click="retryImage" @click="retryImage"
> >
<span>Retry</span> <span>Retry</span>
@ -235,7 +178,7 @@
<template v-else> <template v-else>
<div <div
id="noticeBeforeUpload" 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" :class="registrationNoticeClasses"
role="alert" role="alert"
aria-live="polite" aria-live="polite"
> >
@ -243,7 +186,7 @@
Before you can upload a photo, a friend needs to register you. Before you can upload a photo, a friend needs to register you.
</p> </p>
<button <button
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" :class="registrationButtonClasses"
@click="handleQRCodeClick" @click="handleQRCodeClick"
> >
Share Your Info Share Your Info
@ -262,10 +205,20 @@ import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper"; import VuePictureCropper, { cropper } from "vue-picture-cropper";
import { Capacitor } from "@capacitor/core"; import { Capacitor } from "@capacitor/core";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app"; import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import {
NOTIFY_IMAGE_DIALOG_SETTINGS_ERROR,
NOTIFY_IMAGE_DIALOG_RETRIEVAL_ERROR,
NOTIFY_IMAGE_DIALOG_CAPTURE_ERROR,
NOTIFY_IMAGE_DIALOG_NO_IMAGE_ERROR,
createImageDialogCameraErrorMessage,
createImageDialogUploadErrorMessage,
IMAGE_DIALOG_TIMEOUT_LONG,
IMAGE_DIALOG_TIMEOUT_MODAL,
} from "../constants/notifications";
import { accessToken } from "../libs/crypto"; import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory"; import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import * as databaseUtil from "../db/databaseUtil"; import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { Prop } from "vue-facing-decorator"; import { Prop } from "vue-facing-decorator";
import { Router } from "vue-router"; import { Router } from "vue-router";
@ -273,6 +226,7 @@ const inputImageFileNameRef = ref<Blob>();
@Component({ @Component({
components: { VuePictureCropper }, components: { VuePictureCropper },
mixins: [PlatformServiceMixin],
}) })
export default class ImageMethodDialog extends Vue { export default class ImageMethodDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
@ -317,7 +271,7 @@ export default class ImageMethodDialog extends Vue {
/** Current camera facing mode */ /** Current camera facing mode */
private currentFacingMode: "environment" | "user" = "environment"; private currentFacingMode: "environment" | "user" = "environment";
private platformService = PlatformServiceFactory.getInstance(); platformService = PlatformServiceFactory.getInstance();
URL = window.URL || window.webkitURL; URL = window.URL || window.webkitURL;
private platformCapabilities = this.platformService.getCapabilities(); private platformCapabilities = this.platformService.getCapabilities();
@ -350,13 +304,233 @@ export default class ImageMethodDialog extends Vue {
}) })
defaultCameraMode!: string; 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";
}
/**
* Computed property for section divider classes
* 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";
}
/**
* Computed property for section divider span classes
* Provides consistent styling for divider labels
*/
get sectionDividerSpanClasses(): string {
return "block w-fit mx-auto -mb-2.5 bg-white px-2";
}
/**
* Computed property for camera preview container classes
* Provides consistent styling for camera preview
*/
get cameraPreviewClasses(): string {
return "camera-preview relative flex bg-black overflow-hidden mb-4";
}
/**
* Computed property for diagnostics panel classes
* 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]";
}
/**
* Computed property for diagnostics toggle button classes
* 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";
}
/**
* Computed property for camera control button classes
* Provides consistent styling for camera control buttons
*/
get cameraControlButtonClasses(): string {
return "bg-white text-slate-800 p-3 rounded-full text-2xl leading-none";
}
/**
* Computed property for camera controls container classes
* Provides consistent styling for camera controls
*/
get cameraControlsClasses(): string {
return "absolute bottom-4 inset-x-0 flex items-center justify-center gap-4";
}
/**
* Computed property for file input classes
* 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";
}
/**
* Computed property for URL input classes
* Provides consistent styling for URL input field
*/
get urlInputClasses(): string {
return "block w-full rounded border border-slate-400 px-4 py-2";
}
/**
* Computed property for accept URL button classes
* 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";
}
/**
* Computed property for image container classes
* Provides consistent styling for image display
*/
get imageContainerClasses(): string {
return "mt-2 rounded max-h-[50vh] max-w-[90vw] object-contain";
}
/**
* Computed property for cropper container classes
* Provides consistent styling for image cropper
*/
get cropperClasses(): string {
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";
}
/** /**
* Lifecycle hook: Initializes component and retrieves user settings * Lifecycle hook: Initializes component and retrieves user settings
* @throws {Error} When settings retrieval fails * @throws {Error} When settings retrieval fails
*/ */
async mounted() { async mounted() {
try { try {
const settings = await databaseUtil.retrieveSettingsForActiveAccount(); const settings = await this.$accountSettings();
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
} catch (error: unknown) { } catch (error: unknown) {
logger.error("Error retrieving settings from database:", error); logger.error("Error retrieving settings from database:", error);
@ -368,9 +542,9 @@ export default class ImageMethodDialog extends Vue {
text: text:
error instanceof Error error instanceof Error
? error.message ? error.message
: "There was an error retrieving your settings.", : NOTIFY_IMAGE_DIALOG_SETTINGS_ERROR.message,
}, },
-1, IMAGE_DIALOG_TIMEOUT_MODAL,
); );
} }
} }
@ -435,9 +609,9 @@ export default class ImageMethodDialog extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "There was an error retrieving that image.", text: NOTIFY_IMAGE_DIALOG_RETRIEVAL_ERROR.message,
}, },
5000, IMAGE_DIALOG_TIMEOUT_LONG,
); );
} }
} else { } else {
@ -510,22 +684,9 @@ export default class ImageMethodDialog extends Vue {
} }
} catch (error) { } catch (error) {
logger.error("Error starting camera preview:", error); logger.error("Error starting camera preview:", error);
let errorMessage = const errorMessage = createImageDialogCameraErrorMessage(
error instanceof Error ? error.message : "Failed to access camera"; error instanceof Error ? error : new Error("Unknown camera error"),
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.cameraState = "error";
this.cameraStateMessage = errorMessage; this.cameraStateMessage = errorMessage;
this.error = errorMessage; this.error = errorMessage;
@ -537,7 +698,7 @@ export default class ImageMethodDialog extends Vue {
title: "Error", title: "Error",
text: errorMessage, text: errorMessage,
}, },
5000, IMAGE_DIALOG_TIMEOUT_LONG,
); );
} }
} }
@ -583,9 +744,9 @@ export default class ImageMethodDialog extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "Failed to capture photo. Please try again.", text: NOTIFY_IMAGE_DIALOG_CAPTURE_ERROR.message,
}, },
5000, IMAGE_DIALOG_TIMEOUT_LONG,
); );
} }
} }
@ -634,9 +795,9 @@ export default class ImageMethodDialog extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "There was an error finding the picture. Please try again.", text: NOTIFY_IMAGE_DIALOG_NO_IMAGE_ERROR.message,
}, },
5000, IMAGE_DIALOG_TIMEOUT_LONG,
); );
this.uploading = false; this.uploading = false;
this.close(); this.close();
@ -663,25 +824,7 @@ export default class ImageMethodDialog extends Vue {
this.close(); this.close();
this.imageCallback(response.data.url as string); this.imageCallback(response.data.url as string);
} catch (error: unknown) { } catch (error: unknown) {
let errorMessage = "There was an error saving the picture."; const errorMessage = createImageDialogUploadErrorMessage(error);
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( this.$notify(
{ {
@ -690,7 +833,7 @@ export default class ImageMethodDialog extends Vue {
title: "Error", title: "Error",
text: errorMessage, text: errorMessage,
}, },
5000, IMAGE_DIALOG_TIMEOUT_LONG,
); );
this.uploading = false; this.uploading = false;
this.blob = undefined; this.blob = undefined;

234
src/constants/notifications.ts

@ -1,3 +1,5 @@
import axios from "axios";
// Notification message constants for user-facing notifications // Notification message constants for user-facing notifications
// Add new notification messages here as needed // Add new notification messages here as needed
// //
@ -1382,3 +1384,235 @@ export const QR_TIMEOUT_SHORT = 1000; // Short operations like registration subm
export const QR_TIMEOUT_MEDIUM = 2000; // Medium operations like URL copy export const QR_TIMEOUT_MEDIUM = 2000; // Medium operations like URL copy
export const QR_TIMEOUT_STANDARD = 3000; // Standard success messages export const QR_TIMEOUT_STANDARD = 3000; // Standard success messages
export const QR_TIMEOUT_LONG = 5000; // Error messages and warnings export const QR_TIMEOUT_LONG = 5000; // Error messages and warnings
// NewEditProjectView.vue specific constants
// Used in: NewEditProjectView.vue (mounted method - account loading error)
export const NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR = {
title: "Account Loading Error",
message: "There was a problem loading your account info.",
};
// Used in: NewEditProjectView.vue (loadProject method - project retrieval error)
export const NOTIFY_PROJECT_RETRIEVAL_ERROR = {
title: "Project Retrieval Error",
message: "There was an error retrieving that project.",
};
// Used in: NewEditProjectView.vue (confirmDeleteImage method - image deletion confirmation)
export const NOTIFY_PROJECT_DELETE_IMAGE_CONFIRM = {
title: "Delete Image",
message: "Are you sure you want to delete the image?",
};
// Used in: NewEditProjectView.vue (deleteImage method - image deletion error)
export const NOTIFY_PROJECT_DELETE_IMAGE_ERROR = {
title: "Image Deletion Error",
message: "There was a problem deleting the image.",
};
// Used in: NewEditProjectView.vue (deleteImage method - image deletion general error)
export const NOTIFY_PROJECT_DELETE_IMAGE_GENERAL_ERROR = {
title: "Image Deletion Error",
message: "There was an error deleting the image.",
};
// Used in: NewEditProjectView.vue (validateLocation method - invalid location error)
export const NOTIFY_PROJECT_INVALID_LOCATION = {
title: "Invalid Location",
message: "The location was invalid so it was not set.",
};
// Used in: NewEditProjectView.vue (validateStartDate method - invalid start date error)
export const NOTIFY_PROJECT_INVALID_START_DATE = {
title: "Invalid Start Date",
message: "The start date was invalid so it was not set.",
};
// Used in: NewEditProjectView.vue (validateEndDate method - invalid end date error)
export const NOTIFY_PROJECT_INVALID_END_DATE = {
title: "Invalid End Date",
message: "The end date was invalid so it was not set.",
};
// Used in: NewEditProjectView.vue (saveProject method - project save success)
export const NOTIFY_PROJECT_SAVE_SUCCESS = {
title: "Success",
message: "The project was saved successfully.",
};
// Used in: NewEditProjectView.vue (saveProject method - partner location warning)
export const NOTIFY_PROJECT_PARTNER_LOCATION_WARNING = {
title: "Partner Location Warning",
message:
"A partner was selected but the location was not set, so it was not sent to any partner.",
};
// Used in: NewEditProjectView.vue (sendToNostrPartner method - partner send success)
export const NOTIFY_PROJECT_PARTNER_SEND_SUCCESS = {
title: "Partner Integration Success",
message: "The project info was sent to",
};
// Used in: NewEditProjectView.vue (sendToNostrPartner method - partner send error)
export const NOTIFY_PROJECT_PARTNER_SEND_ERROR = {
title: "Partner Integration Error",
message: "Failed sending to",
};
// Used in: NewEditProjectView.vue (sendToNostrPartner method - partner send general error)
export const NOTIFY_PROJECT_PARTNER_SEND_GENERAL_ERROR = {
title: "Partner Integration Error",
message: "There was an error sending to the partner service.",
};
// Used in: NewEditProjectView.vue (confirmEraseLatLong method - location deletion confirmation)
export const NOTIFY_PROJECT_DELETE_LOCATION_CONFIRM = {
title: "Delete Location",
message: "Are you sure you want to delete the location?",
};
// Used in: NewEditProjectView.vue (showNostrPartnerInfo method - partner info)
export const NOTIFY_PROJECT_NOSTR_PARTNER_INFO = {
title: "Partner Integration Info",
message:
"This will share your project information with external partner services using Nostr protocol.",
};
// Helper function for dynamic partner send success messages
// Used in: NewEditProjectView.vue (sendToNostrPartner method - dynamic success message)
export function createProjectPartnerSendSuccessMessage(
serviceName: string,
): string {
return `${NOTIFY_PROJECT_PARTNER_SEND_SUCCESS.message} ${serviceName}.`;
}
// Helper function for dynamic partner send error messages
// Used in: NewEditProjectView.vue (sendToNostrPartner method - dynamic error message)
export function createProjectPartnerSendErrorMessage(
serviceName: string,
errorData: string,
): string {
return `${NOTIFY_PROJECT_PARTNER_SEND_ERROR.message} ${serviceName}: ${errorData}`;
}
// NewEditProjectView.vue timeout constants
export const PROJECT_TIMEOUT_SHORT = 1000; // Short operations like confirmations
export const PROJECT_TIMEOUT_STANDARD = 3000; // Standard success messages
export const PROJECT_TIMEOUT_LONG = 5000; // Error messages and warnings
export const PROJECT_TIMEOUT_VERY_LONG = 7000; // Complex operations and partner errors
// ImageMethodDialog.vue specific constants
// Used in: ImageMethodDialog.vue (mounted method - settings retrieval error)
export const NOTIFY_IMAGE_DIALOG_SETTINGS_ERROR = {
title: "Error",
message: "There was an error retrieving your settings.",
};
// Used in: ImageMethodDialog.vue (acceptUrl method - image retrieval error)
export const NOTIFY_IMAGE_DIALOG_RETRIEVAL_ERROR = {
title: "Error",
message: "There was an error retrieving that image.",
};
// Used in: ImageMethodDialog.vue (startCameraPreview method - camera access error)
export const NOTIFY_IMAGE_DIALOG_CAMERA_ACCESS_ERROR = {
title: "Error",
message: "Failed to access camera",
};
// Used in: ImageMethodDialog.vue (startCameraPreview method - camera in use error)
export const NOTIFY_IMAGE_DIALOG_CAMERA_IN_USE = {
title: "Camera in Use",
message:
"Camera is in use by another application. Please close any other apps or browser tabs using the camera and try again.",
};
// Used in: ImageMethodDialog.vue (startCameraPreview method - camera permission denied)
export const NOTIFY_IMAGE_DIALOG_CAMERA_PERMISSION_DENIED = {
title: "Camera Access Denied",
message:
"Camera access was denied. Please allow camera access in your browser settings.",
};
// Used in: ImageMethodDialog.vue (capturePhoto method - photo capture error)
export const NOTIFY_IMAGE_DIALOG_CAPTURE_ERROR = {
title: "Error",
message: "Failed to capture photo. Please try again.",
};
// Used in: ImageMethodDialog.vue (uploadImage method - no image found error)
export const NOTIFY_IMAGE_DIALOG_NO_IMAGE_ERROR = {
title: "Error",
message: "There was an error finding the picture. Please try again.",
};
// Used in: ImageMethodDialog.vue (uploadImage method - general upload error)
export const NOTIFY_IMAGE_DIALOG_UPLOAD_ERROR = {
title: "Error",
message: "There was an error saving the picture.",
};
// Used in: ImageMethodDialog.vue (uploadImage method - authentication error)
export const NOTIFY_IMAGE_DIALOG_AUTH_ERROR = {
title: "Authentication Error",
message: "Authentication failed. Please try logging in again.",
};
// Used in: ImageMethodDialog.vue (uploadImage method - file too large error)
export const NOTIFY_IMAGE_DIALOG_FILE_TOO_LARGE = {
title: "File Too Large",
message: "Image file is too large. Please try a smaller image.",
};
// Used in: ImageMethodDialog.vue (uploadImage method - unsupported format error)
export const NOTIFY_IMAGE_DIALOG_UNSUPPORTED_FORMAT = {
title: "Unsupported Format",
message: "Unsupported image format. Please try a different image.",
};
// Used in: ImageMethodDialog.vue (uploadImage method - server error)
export const NOTIFY_IMAGE_DIALOG_SERVER_ERROR = {
title: "Server Error",
message: "Server error. Please try again later.",
};
// Helper function for dynamic camera error messages
// Used in: ImageMethodDialog.vue (startCameraPreview method - dynamic camera error message)
export function createImageDialogCameraErrorMessage(error: Error): string {
if (error.name === "NotReadableError" || error.name === "TrackStartError") {
return NOTIFY_IMAGE_DIALOG_CAMERA_IN_USE.message;
} else if (
error.name === "NotAllowedError" ||
error.name === "PermissionDeniedError"
) {
return NOTIFY_IMAGE_DIALOG_CAMERA_PERMISSION_DENIED.message;
}
return error.message || NOTIFY_IMAGE_DIALOG_CAMERA_ACCESS_ERROR.message;
}
// Helper function for dynamic upload error messages
// Used in: ImageMethodDialog.vue (uploadImage method - dynamic upload error message)
export function createImageDialogUploadErrorMessage(error: any): string {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const data = error.response?.data;
if (status === 401) {
return NOTIFY_IMAGE_DIALOG_AUTH_ERROR.message;
} else if (status === 413) {
return NOTIFY_IMAGE_DIALOG_FILE_TOO_LARGE.message;
} else if (status === 415) {
return NOTIFY_IMAGE_DIALOG_UNSUPPORTED_FORMAT.message;
} else if (status && status >= 500) {
return NOTIFY_IMAGE_DIALOG_SERVER_ERROR.message;
} else if (data?.message) {
return data.message;
}
}
return NOTIFY_IMAGE_DIALOG_UPLOAD_ERROR.message;
}
// ImageMethodDialog.vue timeout constants
export const IMAGE_DIALOG_TIMEOUT_STANDARD = 3000; // Standard error messages
export const IMAGE_DIALOG_TIMEOUT_LONG = 5000; // Camera and upload errors
export const IMAGE_DIALOG_TIMEOUT_MODAL = -1; // Modal confirmations (no auto-dismiss)

82
src/views/NewEditProjectView.vue

@ -235,7 +235,25 @@ import {
NotificationIface, NotificationIface,
} from "../constants/app"; } from "../constants/app";
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "../utils/notify"; import { createNotifyHelpers } from "../utils/notify";
import {
NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR,
NOTIFY_PROJECT_RETRIEVAL_ERROR,
NOTIFY_PROJECT_DELETE_IMAGE_CONFIRM,
NOTIFY_PROJECT_DELETE_IMAGE_ERROR,
NOTIFY_PROJECT_DELETE_IMAGE_GENERAL_ERROR,
NOTIFY_PROJECT_INVALID_LOCATION,
NOTIFY_PROJECT_INVALID_START_DATE,
NOTIFY_PROJECT_INVALID_END_DATE,
NOTIFY_PROJECT_SAVE_SUCCESS,
NOTIFY_PROJECT_PARTNER_LOCATION_WARNING,
NOTIFY_PROJECT_PARTNER_SEND_GENERAL_ERROR,
NOTIFY_PROJECT_DELETE_LOCATION_CONFIRM,
NOTIFY_PROJECT_NOSTR_PARTNER_INFO,
createProjectPartnerSendSuccessMessage,
createProjectPartnerSendErrorMessage,
PROJECT_TIMEOUT_VERY_LONG,
} from "../constants/notifications";
import { PlanActionClaim } from "../interfaces/claims"; import { PlanActionClaim } from "../interfaces/claims";
import { import {
createEndorserJwtVcFromClaim, createEndorserJwtVcFromClaim,
@ -310,7 +328,7 @@ export default class NewEditProjectView extends Vue {
$router!: Router; $router!: Router;
// Notification helpers // Notification helpers
private notifyHelpers = createNotifyHelpers(this.$notify); private notify!: ReturnType<typeof createNotifyHelpers>;
/** /**
* Display error notification to user * Display error notification to user
@ -318,7 +336,7 @@ export default class NewEditProjectView extends Vue {
* @param message - Error message to display * @param message - Error message to display
*/ */
errNote(message: string) { errNote(message: string) {
this.notifyHelpers.error(message); this.notify.error(message);
} }
// Component state properties // Component state properties
@ -358,6 +376,9 @@ export default class NewEditProjectView extends Vue {
* Handles account validation and project loading with comprehensive error handling * Handles account validation and project loading with comprehensive error handling
*/ */
async mounted() { async mounted() {
// Initialize notification helpers
this.notify = createNotifyHelpers(this.$notify);
this.numAccounts = await retrieveAccountCount(); this.numAccounts = await retrieveAccountCount();
const settings = await this.$accountSettings(); const settings = await this.$accountSettings();
@ -369,9 +390,7 @@ export default class NewEditProjectView extends Vue {
if (this.projectId) { if (this.projectId) {
if (this.numAccounts === 0) { if (this.numAccounts === 0) {
this.notifyHelpers.error( this.notify.error(NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR.message);
"There was a problem loading your account info.",
);
} else { } else {
this.loadProject(this.activeDid); this.loadProject(this.activeDid);
} }
@ -422,7 +441,7 @@ export default class NewEditProjectView extends Vue {
} }
} catch (error) { } catch (error) {
logger.error("Got error retrieving that project", error); logger.error("Got error retrieving that project", error);
this.notifyHelpers.error("There was an error retrieving that project."); this.notify.error(NOTIFY_PROJECT_RETRIEVAL_ERROR.message);
} }
} }
@ -441,8 +460,8 @@ export default class NewEditProjectView extends Vue {
* Shows confirmation dialog before proceeding with image deletion * Shows confirmation dialog before proceeding with image deletion
*/ */
confirmDeleteImage() { confirmDeleteImage() {
this.notifyHelpers.confirm( this.notify.confirm(
"Are you sure you want to delete the image?", NOTIFY_PROJECT_DELETE_IMAGE_CONFIRM.message,
async () => { async () => {
await this.deleteImage(); await this.deleteImage();
}, },
@ -480,7 +499,7 @@ export default class NewEditProjectView extends Vue {
// (either they'll simply continue or they're canceling and going back) // (either they'll simply continue or they're canceling and going back)
} else { } else {
logger.error("Problem deleting image:", response); logger.error("Problem deleting image:", response);
this.notifyHelpers.error("There was a problem deleting the image."); this.notify.error(NOTIFY_PROJECT_DELETE_IMAGE_ERROR.message);
return; return;
} }
@ -495,7 +514,7 @@ export default class NewEditProjectView extends Vue {
// it already doesn't exist so we won't say anything to the user // it already doesn't exist so we won't say anything to the user
} else { } else {
this.notifyHelpers.error("There was an error deleting the image."); this.notify.error(NOTIFY_PROJECT_DELETE_IMAGE_GENERAL_ERROR.message);
} }
} }
} }
@ -525,7 +544,7 @@ export default class NewEditProjectView extends Vue {
} }
if (this.includeLocation) { if (this.includeLocation) {
if (!this.latitude || !this.longitude) { if (!this.latitude || !this.longitude) {
this.notifyHelpers.error("The location was invalid so it was not set."); this.notify.error(NOTIFY_PROJECT_INVALID_LOCATION.message);
delete vcClaim.location; delete vcClaim.location;
} else { } else {
vcClaim.location = { vcClaim.location = {
@ -548,9 +567,7 @@ export default class NewEditProjectView extends Vue {
} catch { } catch {
// it's not a valid date so erase it and tell the user // it's not a valid date so erase it and tell the user
delete vcClaim.startTime; delete vcClaim.startTime;
this.notifyHelpers.error( this.notify.error(NOTIFY_PROJECT_INVALID_START_DATE.message);
"The start date was invalid so it was not set.",
);
} }
} else { } else {
delete vcClaim.startTime; delete vcClaim.startTime;
@ -564,7 +581,7 @@ export default class NewEditProjectView extends Vue {
} catch { } catch {
// it's not a valid date so erase it and tell the user // it's not a valid date so erase it and tell the user
delete vcClaim.endTime; delete vcClaim.endTime;
this.notifyHelpers.error("The end date was invalid so it was not set."); this.notify.error(NOTIFY_PROJECT_INVALID_END_DATE.message);
} }
} else { } else {
delete vcClaim.endTime; delete vcClaim.endTime;
@ -580,7 +597,7 @@ export default class NewEditProjectView extends Vue {
try { try {
const resp = await this.axios.post(url, payload, { headers }); const resp = await this.axios.post(url, payload, { headers });
if (resp.data?.success?.handleId) { if (resp.data?.success?.handleId) {
this.notifyHelpers.success("The project was saved successfully."); this.notify.success(NOTIFY_PROJECT_SAVE_SUCCESS.message);
this.errorMessage = ""; this.errorMessage = "";
@ -614,9 +631,7 @@ export default class NewEditProjectView extends Vue {
); );
} }
} else { } else {
this.notifyHelpers.error( this.notify.error(NOTIFY_PROJECT_PARTNER_LOCATION_WARNING.message);
"A partner was selected but the location was not set, so it was not sent to any partner.",
);
} }
} }
@ -654,7 +669,7 @@ export default class NewEditProjectView extends Vue {
} }
} }
if (userMessage) { if (userMessage) {
this.notifyHelpers.error(userMessage); this.notify.error(userMessage);
} }
// Now set that error for the user to see. // Now set that error for the user to see.
this.errorMessage = userMessage; this.errorMessage = userMessage;
@ -756,23 +771,26 @@ export default class NewEditProjectView extends Vue {
{ headers }, { headers },
); );
if (linkResp.status === 201) { if (linkResp.status === 201) {
this.notifyHelpers.success( this.notify.success(
`The project info was sent to ${serviceName}.`, createProjectPartnerSendSuccessMessage(serviceName),
); );
} else { } else {
// axios never gets here because it throws an error, but just in case // axios never gets here because it throws an error, but just in case
this.notifyHelpers.error( this.notify.error(
`Failed sending to ${serviceName}: ${JSON.stringify(linkResp.data)}`, createProjectPartnerSendErrorMessage(
serviceName,
JSON.stringify(linkResp.data),
),
); );
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (error: any) {
logger.error(`Error sending to ${serviceName}`, error); logger.error(`Error sending to ${serviceName}`, error);
let errorMessage = `There was an error sending to ${serviceName}.`; let errorMessage = NOTIFY_PROJECT_PARTNER_SEND_GENERAL_ERROR.message;
if (error.response?.data?.error?.message) { if (error.response?.data?.error?.message) {
errorMessage = error.response.data.error.message; errorMessage = error.response.data.error.message;
} }
this.notifyHelpers.error(errorMessage, TIMEOUTS.VERY_LONG); this.notify.error(errorMessage, PROJECT_TIMEOUT_VERY_LONG);
} }
} }
@ -796,8 +814,8 @@ export default class NewEditProjectView extends Vue {
* Shows confirmation dialog before clearing location data * Shows confirmation dialog before clearing location data
*/ */
confirmEraseLatLong() { confirmEraseLatLong() {
this.notifyHelpers.confirm( this.notify.confirm(
"Are you sure you don't want to mark a location? This will erase the current location.", NOTIFY_PROJECT_DELETE_LOCATION_CONFIRM.message,
async () => { async () => {
this.eraseLatLong(); this.eraseLatLong();
}, },
@ -827,9 +845,9 @@ export default class NewEditProjectView extends Vue {
* Displays privacy information about partner service integration * Displays privacy information about partner service integration
*/ */
public showNostrPartnerInfo() { public showNostrPartnerInfo() {
this.notifyHelpers.info( this.notify.info(
"This will submit this project to a partner on the nostr network. It will contain your public key data which may allow correlation, so don't allow this if you're not comfortable with that.", NOTIFY_PROJECT_NOSTR_PARTNER_INFO.message,
TIMEOUTS.VERY_LONG, PROJECT_TIMEOUT_VERY_LONG,
); );
} }

Loading…
Cancel
Save