Browse Source

Design: polished dialog UI

qrcode-reboot
Jose Olarte III 1 day ago
parent
commit
b74ec8ecbb
  1. 509
      src/components/ImageMethodDialog.vue

509
src/components/ImageMethodDialog.vue

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

Loading…
Cancel
Save