From a5fb6c482564eb02f6cdb37eab7da78e1ddfb102 Mon Sep 17 00:00:00 2001 From: "Muhammad A. Ghorbani" Date: Sun, 13 Sep 2026 13:21:47 +0330 Subject: [PATCH] feat: add QuestionPhoto component for image uploads and previews --- src/components/Componentes/question-photo.tsx | 281 +++++------------- 1 file changed, 76 insertions(+), 205 deletions(-) diff --git a/src/components/Componentes/question-photo.tsx b/src/components/Componentes/question-photo.tsx index b8f1198..636c27e 100644 --- a/src/components/Componentes/question-photo.tsx +++ b/src/components/Componentes/question-photo.tsx @@ -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(null); - const activeBlobUrlRef = useRef(null); - const { dictionary: t } = useI18n(); const [localPreviewUrl, setLocalPreviewUrl] = useState(null); const [isUploading, setIsUploading] = useState(false); const [toastMessage, setToastMessage] = useState(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": { + setIsUploading(false); + isInitiatorRef.current = false; const file = event.data?.files?.[0]; - const rawUrl = - file?.url || + const remoteUrl = 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); + 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) - .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); - } 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; + 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", }); - 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; - } + return uploadTmpMediaMutation.mutateAsync(f); + }) + .then((res) => { + if (res?.path) { + setAnswerValue(question, res.path); + setLocalPreviewUrl(res.path); + pendingDeferredRef.current?.resolve(res.path); + } else { + pendingDeferredRef.current?.resolve(); + } + pendingDeferredRef.current = null; + }) + .catch((err) => { + pendingDeferredRef.current?.reject(err); + pendingDeferredRef.current = null; + }); + registerPendingUpload?.(question.id, uploadPromise); } 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)} /> )} - @@ -463,3 +333,4 @@ export function QuestionPhoto({ } export default QuestionPhoto; +