Browse Source

feat: add QuestionPhoto component for image uploads and previews

master
parent
commit
a5fb6c4825
  1. 281
      src/components/Componentes/question-photo.tsx

281
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 ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; 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 { isInFlutterWebView, uploadFile } from "@/lib/webview-actions";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import ErrorToast from "./error-toast"; import ErrorToast from "./error-toast";
@ -17,35 +17,16 @@ type QuestionPhotoProps = {
disabled?: boolean; 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({ export function QuestionPhoto({
question, question,
description, description,
disabled, disabled,
}: QuestionPhotoProps) { }: QuestionPhotoProps) {
const inputId = useId(); const inputId = useId();
const fileInputRef = useRef<HTMLInputElement>(null);
const activeBlobUrlRef = useRef<string | null>(null);
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null); const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [toastMessage, setToastMessage] = useState<string | null>(null); const [toastMessage, setToastMessage] = useState<string | null>(null);
const [hasImageError, setHasImageError] = useState(false);
const acceptedFiles = const acceptedFiles =
question.extras?.options && question.extras.options.length > 0 question.extras?.options && question.extras.options.length > 0
@ -62,63 +43,23 @@ export function QuestionPhoto({
reject: (err?: any) => void; reject: (err?: any) => void;
} | null>(null); } | 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({ const uploadTmpMediaMutation = useUploadTmpMediaMutation({
onSuccess: (response) => { 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); setIsUploading(false);
isInitiatorRef.current = false; isInitiatorRef.current = false;
}, },
onError: (error) => { onError: (error) => {
console.error("Photo upload error:", 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(() => { useEffect(() => {
if (!isInFlutterWebView()) return; if (!isInFlutterWebView()) return;
const unsubscribe = window.addFlutterResponseListener?.((event) => {
const unsubscribe = window.addFlutterResponseListener?.((event: any) => {
if (event.action !== "upload_file") return; if (event.action !== "upload_file") return;
if (event.requestId && String(event.requestId) !== String(question.id)) return; if (event.requestId && String(event.requestId) !== String(question.id)) return;
if (!event.requestId && !isInitiatorRef.current) return; if (!event.requestId && !isInitiatorRef.current) return;
switch (event.status) { switch (event.status) {
case "picking":
break;
case "picked":
case "progress": case "progress":
setIsUploading(true); 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; break;
case "completed": { case "completed": {
setIsUploading(false);
isInitiatorRef.current = false;
const file = event.data?.files?.[0]; const file = event.data?.files?.[0];
const rawUrl =
file?.url ||
const remoteUrl =
file?.path || 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; pendingDeferredRef.current = null;
} else if (file?.base64) { } 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 { } else {
setIsUploading(false);
isInitiatorRef.current = false;
pendingDeferredRef.current?.resolve(); pendingDeferredRef.current?.resolve();
pendingDeferredRef.current = null; pendingDeferredRef.current = null;
} }
break; break;
} }
case "cancelled": case "cancelled":
setIsUploading(false);
isInitiatorRef.current = false;
if (!storedValue) {
setLocalPreviewUrl(null);
}
pendingDeferredRef.current?.reject(new Error("Upload cancelled"));
pendingDeferredRef.current = null;
break;
case "failed": case "failed":
setIsUploading(false); setIsUploading(false);
isInitiatorRef.current = 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; pendingDeferredRef.current = null;
break; break;
} }
@ -241,14 +143,7 @@ export function QuestionPhoto({
return () => { return () => {
unsubscribe?.(); unsubscribe?.();
}; };
}, [
question,
setAnswerValue,
registerPendingUpload,
uploadTmpMediaMutation,
handleUploadFailure,
storedValue,
]);
}, [question, setAnswerValue, registerPendingUpload, uploadTmpMediaMutation]);
const handleFlutterPick = useCallback(() => { const handleFlutterPick = useCallback(() => {
const extensions = (question.extras?.options ?? []).map((o) => const extensions = (question.extras?.options ?? []).map((o) =>
@ -257,8 +152,6 @@ export function QuestionPhoto({
isInitiatorRef.current = true; isInitiatorRef.current = true;
setIsUploading(true); setIsUploading(true);
setHasImageError(false);
setToastMessage(null);
let resolveFn!: (val?: any) => void; let resolveFn!: (val?: any) => void;
let rejectFn!: (err?: any) => void; let rejectFn!: (err?: any) => void;
@ -278,22 +171,17 @@ export function QuestionPhoto({
question.ui_config?.source || question.ui_config?.source ||
"gallery"; "gallery";
const uploadUrl = `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`;
uploadFile({ uploadFile({
requestId: String(question.id), requestId: String(question.id),
mediaType: "image", mediaType: "image",
source: pickerSource as any, source: pickerSource as any,
picker_type: pickerType as any, picker_type: pickerType as any,
returnAs: "upload", returnAs: "upload",
uploadUrl,
uploadUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`,
fieldName: "file", fieldName: "file",
allowedExtensions: allowedExtensions:
extensions.length > 0 ? extensions : ["jpg", "jpeg", "png", "webp"], extensions.length > 0 ? extensions : ["jpg", "jpeg", "png", "webp"],
maxBytes: 10_485_760, // 10 MB maxBytes: 10_485_760, // 10 MB
maxWidth: 1024,
maxHeight: 1024,
imageQuality: 85,
title: title:
typeof question.title === "string" ? question.title : "Upload Photo", typeof question.title === "string" ? question.title : "Upload Photo",
}); });
@ -303,28 +191,8 @@ export function QuestionPhoto({
const file = files?.[0]; const file = files?.[0];
if (!file) return; 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); const objectUrl = URL.createObjectURL(file);
activeBlobUrlRef.current = objectUrl;
setLocalPreviewUrl(objectUrl); setLocalPreviewUrl(objectUrl);
// Trigger background upload and register promise with central storage // Trigger background upload and register promise with central storage
@ -333,20 +201,18 @@ export function QuestionPhoto({
.mutateAsync(file) .mutateAsync(file)
.then((res) => { .then((res) => {
if (res?.path) { 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); setIsUploading(false);
}) })
.catch((err) => { .catch((err) => {
console.error("Photo upload error:", 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); registerPendingUpload?.(question.id, uploadPromise);
@ -360,18 +226,26 @@ export function QuestionPhoto({
}; };
// Determine photo URL to display // Determine photo URL to display
let candidateUrl: string | null = localPreviewUrl;
let displayUrl: string | null = localPreviewUrl;
if ( if (
!candidateUrl &&
!hasImageError &&
!displayUrl &&
typeof storedValue === "string" && typeof storedValue === "string" &&
storedValue.trim().length > 0 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); const hasAnswer = Boolean(displayUrl);
return ( return (
@ -388,14 +262,12 @@ export function QuestionPhoto({
onClose={() => setToastMessage(null)} onClose={() => setToastMessage(null)}
/> />
)} )}
<QuestionTitle <QuestionTitle
question={question} question={question}
className="justify-center text-center" className="justify-center text-center"
/> />
<input <input
ref={fileInputRef}
id={inputId} id={inputId}
type="file" type="file"
accept={acceptedFiles} accept={acceptedFiles}
@ -404,7 +276,7 @@ export function QuestionPhoto({
className="sr-only" className="sr-only"
/> />
<label <label
htmlFor={isInFlutterWebView() ? undefined : inputId}
htmlFor={inputId}
onClick={handleLabelClick} onClick={handleLabelClick}
className="flex w-full cursor-pointer flex-col items-center" className="flex w-full cursor-pointer flex-col items-center"
> >
@ -414,7 +286,6 @@ export function QuestionPhoto({
<img <img
src={displayUrl} src={displayUrl}
alt="" alt=""
onError={handleImageError}
className="h-[86px] w-[86px] rounded-full object-cover border border-[#D7DBE2]" className="h-[86px] w-[86px] rounded-full object-cover border border-[#D7DBE2]"
/> />
) : ( ) : (
@ -455,7 +326,6 @@ export function QuestionPhoto({
</div> </div>
)} )}
</div> </div>
</span> </span>
</label> </label>
</div> </div>
@ -463,3 +333,4 @@ export function QuestionPhoto({
} }
export default QuestionPhoto; export default QuestionPhoto;
Loading…
Cancel
Save