|
|
|
@ -4,7 +4,7 @@ import Image from "next/image"; |
|
|
|
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react"; |
|
|
|
import type { QuestionField } from "@/lib/schema-adapter"; |
|
|
|
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; |
|
|
|
import { resolveMediaUrl } from "@/lib/http"; |
|
|
|
import { getApiRequestUrl } from "@/lib/http"; |
|
|
|
import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; |
|
|
|
import { useI18n } from "@/translations/provider"; |
|
|
|
import ErrorToast from "./error-toast"; |
|
|
|
@ -17,35 +17,16 @@ type QuestionPhotoProps = { |
|
|
|
disabled?: boolean; |
|
|
|
}; |
|
|
|
|
|
|
|
function base64ToFile(dataUrl: string, filename = "photo.jpg"): File { |
|
|
|
const parts = dataUrl.split(","); |
|
|
|
const mimeMatch = parts[0]?.match(/:(.*?);/); |
|
|
|
const mime = mimeMatch ? mimeMatch[1] : "image/jpeg"; |
|
|
|
const rawBase64 = parts[1] || parts[0]; |
|
|
|
const cleanBase64 = rawBase64.replace(/\s+/g, ""); |
|
|
|
const binaryString = atob(cleanBase64); |
|
|
|
const len = binaryString.length; |
|
|
|
const bytes = new Uint8Array(len); |
|
|
|
for (let i = 0; i < len; i++) { |
|
|
|
bytes[i] = binaryString.charCodeAt(i); |
|
|
|
} |
|
|
|
return new File([bytes], filename, { type: mime }); |
|
|
|
} |
|
|
|
|
|
|
|
export function QuestionPhoto({ |
|
|
|
question, |
|
|
|
description, |
|
|
|
disabled, |
|
|
|
}: QuestionPhotoProps) { |
|
|
|
const inputId = useId(); |
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null); |
|
|
|
const activeBlobUrlRef = useRef<string | null>(null); |
|
|
|
|
|
|
|
const { dictionary: t } = useI18n(); |
|
|
|
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null); |
|
|
|
const [isUploading, setIsUploading] = useState(false); |
|
|
|
const [toastMessage, setToastMessage] = useState<string | null>(null); |
|
|
|
const [hasImageError, setHasImageError] = useState(false); |
|
|
|
|
|
|
|
const acceptedFiles = |
|
|
|
question.extras?.options && question.extras.options.length > 0 |
|
|
|
@ -62,63 +43,23 @@ export function QuestionPhoto({ |
|
|
|
reject: (err?: any) => void; |
|
|
|
} | null>(null); |
|
|
|
|
|
|
|
// Revoke blob URL on unmount
|
|
|
|
useEffect(() => { |
|
|
|
return () => { |
|
|
|
if (activeBlobUrlRef.current) { |
|
|
|
URL.revokeObjectURL(activeBlobUrlRef.current); |
|
|
|
activeBlobUrlRef.current = null; |
|
|
|
} |
|
|
|
}; |
|
|
|
}, []); |
|
|
|
|
|
|
|
const handleUploadFailure = useCallback( |
|
|
|
(errorMessage?: string) => { |
|
|
|
setIsUploading(false); |
|
|
|
isInitiatorRef.current = false; |
|
|
|
if (activeBlobUrlRef.current) { |
|
|
|
URL.revokeObjectURL(activeBlobUrlRef.current); |
|
|
|
activeBlobUrlRef.current = null; |
|
|
|
} |
|
|
|
setLocalPreviewUrl(null); |
|
|
|
if (fileInputRef.current) { |
|
|
|
fileInputRef.current.value = ""; |
|
|
|
} |
|
|
|
setToastMessage( |
|
|
|
errorMessage ?? |
|
|
|
t["Photo upload failed. Please try again."] ?? |
|
|
|
"Photo upload failed. Please try again.", |
|
|
|
); |
|
|
|
}, |
|
|
|
[t], |
|
|
|
); |
|
|
|
|
|
|
|
const handleImageError = useCallback(() => { |
|
|
|
console.error("Photo preview failed to load"); |
|
|
|
if (activeBlobUrlRef.current) { |
|
|
|
URL.revokeObjectURL(activeBlobUrlRef.current); |
|
|
|
activeBlobUrlRef.current = null; |
|
|
|
} |
|
|
|
}, []); |
|
|
|
|
|
|
|
const uploadTmpMediaMutation = useUploadTmpMediaMutation({ |
|
|
|
onSuccess: (response) => { |
|
|
|
if (response?.path) { |
|
|
|
const resolved = resolveMediaUrl(response.path) ?? response.path; |
|
|
|
setAnswerValue(question, resolved); |
|
|
|
setHasImageError(false); |
|
|
|
if (!activeBlobUrlRef.current) { |
|
|
|
setLocalPreviewUrl(resolved); |
|
|
|
} |
|
|
|
} else { |
|
|
|
handleUploadFailure(); |
|
|
|
if (response.path) { |
|
|
|
setAnswerValue(question, response.path); |
|
|
|
setLocalPreviewUrl(response.path); |
|
|
|
} |
|
|
|
setIsUploading(false); |
|
|
|
isInitiatorRef.current = false; |
|
|
|
}, |
|
|
|
onError: (error) => { |
|
|
|
console.error("Photo upload error:", error); |
|
|
|
handleUploadFailure(); |
|
|
|
setIsUploading(false); |
|
|
|
isInitiatorRef.current = false; |
|
|
|
setToastMessage( |
|
|
|
t["Photo upload failed. Please try again."] ?? |
|
|
|
"Photo upload failed. Please try again.", |
|
|
|
); |
|
|
|
}, |
|
|
|
}); |
|
|
|
|
|
|
|
@ -128,111 +69,72 @@ export function QuestionPhoto({ |
|
|
|
useEffect(() => { |
|
|
|
if (!isInFlutterWebView()) return; |
|
|
|
|
|
|
|
const unsubscribe = window.addFlutterResponseListener?.((event) => { |
|
|
|
const unsubscribe = window.addFlutterResponseListener?.((event: any) => { |
|
|
|
if (event.action !== "upload_file") return; |
|
|
|
if (event.requestId && String(event.requestId) !== String(question.id)) return; |
|
|
|
if (!event.requestId && !isInitiatorRef.current) return; |
|
|
|
|
|
|
|
switch (event.status) { |
|
|
|
case "picking": |
|
|
|
break; |
|
|
|
case "picked": |
|
|
|
case "progress": |
|
|
|
setIsUploading(true); |
|
|
|
setHasImageError(false); |
|
|
|
setToastMessage(null); |
|
|
|
if (event.data?.files?.[0]?.base64) { |
|
|
|
setLocalPreviewUrl(event.data.files[0].base64); |
|
|
|
if (event.data?.base64) { |
|
|
|
const b64 = event.data.base64.startsWith("data:") |
|
|
|
? event.data.base64 |
|
|
|
: `data:image/jpeg;base64,${event.data.base64}`; |
|
|
|
setLocalPreviewUrl(b64); |
|
|
|
} |
|
|
|
break; |
|
|
|
case "completed": { |
|
|
|
const file = event.data?.files?.[0]; |
|
|
|
const rawUrl = |
|
|
|
file?.url || |
|
|
|
file?.path || |
|
|
|
(file?.data?.url as string | undefined) || |
|
|
|
(file?.data?.path as string | undefined); |
|
|
|
|
|
|
|
if (rawUrl) { |
|
|
|
const resolved = resolveMediaUrl(rawUrl) ?? rawUrl; |
|
|
|
setIsUploading(false); |
|
|
|
isInitiatorRef.current = false; |
|
|
|
setAnswerValue(question, resolved); |
|
|
|
setLocalPreviewUrl(resolved); |
|
|
|
setHasImageError(false); |
|
|
|
pendingDeferredRef.current?.resolve(resolved); |
|
|
|
const file = event.data?.files?.[0]; |
|
|
|
const remoteUrl = |
|
|
|
file?.path || |
|
|
|
file?.url || |
|
|
|
(file?.data?.path as string | undefined) || |
|
|
|
(file?.data?.url as string | undefined); |
|
|
|
if (remoteUrl) { |
|
|
|
setAnswerValue(question, remoteUrl); |
|
|
|
setLocalPreviewUrl(remoteUrl); |
|
|
|
pendingDeferredRef.current?.resolve(remoteUrl); |
|
|
|
pendingDeferredRef.current = null; |
|
|
|
} else if (file?.base64) { |
|
|
|
setIsUploading(true); |
|
|
|
setHasImageError(false); |
|
|
|
setToastMessage(null); |
|
|
|
try { |
|
|
|
const f = base64ToFile(file.base64, file.name ?? "photo.jpg"); |
|
|
|
if (activeBlobUrlRef.current) { |
|
|
|
URL.revokeObjectURL(activeBlobUrlRef.current); |
|
|
|
} |
|
|
|
const objectUrl = URL.createObjectURL(f); |
|
|
|
activeBlobUrlRef.current = objectUrl; |
|
|
|
setLocalPreviewUrl(objectUrl); |
|
|
|
|
|
|
|
const uploadPromise = uploadTmpMediaMutation |
|
|
|
.mutateAsync(f) |
|
|
|
const b64 = file.base64; |
|
|
|
setLocalPreviewUrl(b64); |
|
|
|
const uploadPromise = fetch(b64) |
|
|
|
.then((res) => res.blob()) |
|
|
|
.then((blob) => { |
|
|
|
const f = new File([blob], file.name ?? "photo.jpg", { |
|
|
|
type: blob.type || "image/jpeg", |
|
|
|
}); |
|
|
|
return uploadTmpMediaMutation.mutateAsync(f); |
|
|
|
}) |
|
|
|
.then((res) => { |
|
|
|
if (res?.path) { |
|
|
|
const resolved = resolveMediaUrl(res.path) ?? res.path; |
|
|
|
setAnswerValue(question, resolved); |
|
|
|
setHasImageError(false); |
|
|
|
if (!activeBlobUrlRef.current) { |
|
|
|
setLocalPreviewUrl(resolved); |
|
|
|
} |
|
|
|
pendingDeferredRef.current?.resolve(resolved); |
|
|
|
setAnswerValue(question, res.path); |
|
|
|
setLocalPreviewUrl(res.path); |
|
|
|
pendingDeferredRef.current?.resolve(res.path); |
|
|
|
} else { |
|
|
|
handleUploadFailure(); |
|
|
|
pendingDeferredRef.current?.resolve(); |
|
|
|
} |
|
|
|
setIsUploading(false); |
|
|
|
isInitiatorRef.current = false; |
|
|
|
pendingDeferredRef.current = null; |
|
|
|
}) |
|
|
|
.catch((err) => { |
|
|
|
console.error("Flutter photo upload to server error:", err); |
|
|
|
const reason = |
|
|
|
err?.response?.data?.reason || |
|
|
|
err?.response?.data?.detail || |
|
|
|
err?.message; |
|
|
|
handleUploadFailure(reason); |
|
|
|
pendingDeferredRef.current?.reject(err); |
|
|
|
pendingDeferredRef.current = null; |
|
|
|
}); |
|
|
|
registerPendingUpload?.(question.id, uploadPromise); |
|
|
|
} catch (convErr: any) { |
|
|
|
console.error("Failed to decode base64 photo:", convErr); |
|
|
|
handleUploadFailure(convErr?.message); |
|
|
|
pendingDeferredRef.current?.reject(convErr); |
|
|
|
pendingDeferredRef.current = null; |
|
|
|
} |
|
|
|
} else { |
|
|
|
setIsUploading(false); |
|
|
|
isInitiatorRef.current = false; |
|
|
|
pendingDeferredRef.current?.resolve(); |
|
|
|
pendingDeferredRef.current = null; |
|
|
|
} |
|
|
|
break; |
|
|
|
} |
|
|
|
case "cancelled": |
|
|
|
setIsUploading(false); |
|
|
|
isInitiatorRef.current = false; |
|
|
|
if (!storedValue) { |
|
|
|
setLocalPreviewUrl(null); |
|
|
|
} |
|
|
|
pendingDeferredRef.current?.reject(new Error("Upload cancelled")); |
|
|
|
pendingDeferredRef.current = null; |
|
|
|
break; |
|
|
|
case "failed": |
|
|
|
setIsUploading(false); |
|
|
|
isInitiatorRef.current = false; |
|
|
|
handleUploadFailure(event.message || event.data?.message); |
|
|
|
pendingDeferredRef.current?.reject(new Error("Upload failed")); |
|
|
|
pendingDeferredRef.current?.reject(new Error("Upload failed or cancelled")); |
|
|
|
pendingDeferredRef.current = null; |
|
|
|
break; |
|
|
|
} |
|
|
|
@ -241,14 +143,7 @@ export function QuestionPhoto({ |
|
|
|
return () => { |
|
|
|
unsubscribe?.(); |
|
|
|
}; |
|
|
|
}, [ |
|
|
|
question, |
|
|
|
setAnswerValue, |
|
|
|
registerPendingUpload, |
|
|
|
uploadTmpMediaMutation, |
|
|
|
handleUploadFailure, |
|
|
|
storedValue, |
|
|
|
]); |
|
|
|
}, [question, setAnswerValue, registerPendingUpload, uploadTmpMediaMutation]); |
|
|
|
|
|
|
|
const handleFlutterPick = useCallback(() => { |
|
|
|
const extensions = (question.extras?.options ?? []).map((o) => |
|
|
|
@ -257,8 +152,6 @@ export function QuestionPhoto({ |
|
|
|
|
|
|
|
isInitiatorRef.current = true; |
|
|
|
setIsUploading(true); |
|
|
|
setHasImageError(false); |
|
|
|
setToastMessage(null); |
|
|
|
|
|
|
|
let resolveFn!: (val?: any) => void; |
|
|
|
let rejectFn!: (err?: any) => void; |
|
|
|
@ -278,22 +171,17 @@ export function QuestionPhoto({ |
|
|
|
question.ui_config?.source || |
|
|
|
"gallery"; |
|
|
|
|
|
|
|
const uploadUrl = `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`; |
|
|
|
|
|
|
|
uploadFile({ |
|
|
|
requestId: String(question.id), |
|
|
|
mediaType: "image", |
|
|
|
source: pickerSource as any, |
|
|
|
picker_type: pickerType as any, |
|
|
|
returnAs: "upload", |
|
|
|
uploadUrl, |
|
|
|
uploadUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`, |
|
|
|
fieldName: "file", |
|
|
|
allowedExtensions: |
|
|
|
extensions.length > 0 ? extensions : ["jpg", "jpeg", "png", "webp"], |
|
|
|
maxBytes: 10_485_760, // 10 MB
|
|
|
|
maxWidth: 1024, |
|
|
|
maxHeight: 1024, |
|
|
|
imageQuality: 85, |
|
|
|
title: |
|
|
|
typeof question.title === "string" ? question.title : "Upload Photo", |
|
|
|
}); |
|
|
|
@ -303,28 +191,8 @@ export function QuestionPhoto({ |
|
|
|
const file = files?.[0]; |
|
|
|
if (!file) return; |
|
|
|
|
|
|
|
// Validate size (10 MB limit)
|
|
|
|
if (file.size > 10_485_760) { |
|
|
|
if (fileInputRef.current) { |
|
|
|
fileInputRef.current.value = ""; |
|
|
|
} |
|
|
|
setToastMessage( |
|
|
|
t["File size exceeds the 10 MB limit."] ?? |
|
|
|
"File size exceeds the 10 MB limit.", |
|
|
|
); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
// Reset previous errors
|
|
|
|
setHasImageError(false); |
|
|
|
setToastMessage(null); |
|
|
|
|
|
|
|
// Create synchronous object URL for instant UI preview
|
|
|
|
if (activeBlobUrlRef.current) { |
|
|
|
URL.revokeObjectURL(activeBlobUrlRef.current); |
|
|
|
} |
|
|
|
// Create synchronous object URL for instant UI preview only
|
|
|
|
const objectUrl = URL.createObjectURL(file); |
|
|
|
activeBlobUrlRef.current = objectUrl; |
|
|
|
setLocalPreviewUrl(objectUrl); |
|
|
|
|
|
|
|
// Trigger background upload and register promise with central storage
|
|
|
|
@ -333,20 +201,18 @@ export function QuestionPhoto({ |
|
|
|
.mutateAsync(file) |
|
|
|
.then((res) => { |
|
|
|
if (res?.path) { |
|
|
|
const resolved = resolveMediaUrl(res.path) ?? res.path; |
|
|
|
setAnswerValue(question, resolved); |
|
|
|
setHasImageError(false); |
|
|
|
if (!activeBlobUrlRef.current) { |
|
|
|
setLocalPreviewUrl(resolved); |
|
|
|
} |
|
|
|
} else { |
|
|
|
handleUploadFailure(); |
|
|
|
setAnswerValue(question, res.path); |
|
|
|
setLocalPreviewUrl(res.path); |
|
|
|
} |
|
|
|
setIsUploading(false); |
|
|
|
}) |
|
|
|
.catch((err) => { |
|
|
|
console.error("Photo upload error:", err); |
|
|
|
handleUploadFailure(); |
|
|
|
setIsUploading(false); |
|
|
|
setToastMessage( |
|
|
|
t["Photo upload failed. Please try again."] ?? |
|
|
|
"Photo upload failed. Please try again.", |
|
|
|
); |
|
|
|
}); |
|
|
|
|
|
|
|
registerPendingUpload?.(question.id, uploadPromise); |
|
|
|
@ -360,18 +226,26 @@ export function QuestionPhoto({ |
|
|
|
}; |
|
|
|
|
|
|
|
// Determine photo URL to display
|
|
|
|
let candidateUrl: string | null = localPreviewUrl; |
|
|
|
let displayUrl: string | null = localPreviewUrl; |
|
|
|
|
|
|
|
if ( |
|
|
|
!candidateUrl && |
|
|
|
!hasImageError && |
|
|
|
!displayUrl && |
|
|
|
typeof storedValue === "string" && |
|
|
|
storedValue.trim().length > 0 |
|
|
|
) { |
|
|
|
candidateUrl = storedValue.trim(); |
|
|
|
const val = storedValue.trim(); |
|
|
|
if ( |
|
|
|
val.startsWith("http://") || |
|
|
|
val.startsWith("https://") || |
|
|
|
val.startsWith("blob:") || |
|
|
|
val.startsWith("data:") |
|
|
|
) { |
|
|
|
displayUrl = val; |
|
|
|
} else { |
|
|
|
displayUrl = getApiRequestUrl(val); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
const displayUrl = resolveMediaUrl(candidateUrl); |
|
|
|
const hasAnswer = Boolean(displayUrl); |
|
|
|
|
|
|
|
return ( |
|
|
|
@ -388,14 +262,12 @@ export function QuestionPhoto({ |
|
|
|
onClose={() => setToastMessage(null)} |
|
|
|
/> |
|
|
|
)} |
|
|
|
|
|
|
|
<QuestionTitle |
|
|
|
question={question} |
|
|
|
className="justify-center text-center" |
|
|
|
/> |
|
|
|
|
|
|
|
<input |
|
|
|
ref={fileInputRef} |
|
|
|
id={inputId} |
|
|
|
type="file" |
|
|
|
accept={acceptedFiles} |
|
|
|
@ -404,7 +276,7 @@ export function QuestionPhoto({ |
|
|
|
className="sr-only" |
|
|
|
/> |
|
|
|
<label |
|
|
|
htmlFor={isInFlutterWebView() ? undefined : inputId} |
|
|
|
htmlFor={inputId} |
|
|
|
onClick={handleLabelClick} |
|
|
|
className="flex w-full cursor-pointer flex-col items-center" |
|
|
|
> |
|
|
|
@ -414,7 +286,6 @@ export function QuestionPhoto({ |
|
|
|
<img |
|
|
|
src={displayUrl} |
|
|
|
alt="" |
|
|
|
onError={handleImageError} |
|
|
|
className="h-[86px] w-[86px] rounded-full object-cover border border-[#D7DBE2]" |
|
|
|
/> |
|
|
|
) : ( |
|
|
|
@ -455,7 +326,6 @@ export function QuestionPhoto({ |
|
|
|
</div> |
|
|
|
)} |
|
|
|
</div> |
|
|
|
|
|
|
|
</span> |
|
|
|
</label> |
|
|
|
</div> |
|
|
|
@ -463,3 +333,4 @@ export function QuestionPhoto({ |
|
|
|
} |
|
|
|
|
|
|
|
export default QuestionPhoto; |
|
|
|
|