You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
324 lines
10 KiB
324 lines
10 KiB
"use client";
|
|
|
|
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 { getApiRequestUrl } from "@/lib/http";
|
|
import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions";
|
|
import { useQuestionAnswers } from "./question-answer-storage";
|
|
import QuestionTitle from "./question-title";
|
|
import { LoadingSkeleton } from "./loading-skeleton";
|
|
|
|
type QuestionPhotoProps = {
|
|
question: QuestionField;
|
|
description?: ReactNode;
|
|
disabled?: boolean;
|
|
};
|
|
|
|
export function QuestionPhoto({
|
|
question,
|
|
description,
|
|
disabled,
|
|
}: QuestionPhotoProps) {
|
|
const inputId = useId();
|
|
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
|
|
const acceptedFiles =
|
|
question.extras?.options && question.extras.options.length > 0
|
|
? question.extras.options.join(",")
|
|
: "image/*";
|
|
|
|
const descriptionContent = description ?? question.description;
|
|
const { getAnswerValue, setAnswerValue, registerPendingUpload } =
|
|
useQuestionAnswers();
|
|
const storedValue = getAnswerValue(question);
|
|
|
|
const isInitiatorRef = useRef(false);
|
|
const pendingDeferredRef = useRef<{
|
|
resolve: (val?: any) => void;
|
|
reject: (err?: any) => void;
|
|
} | null>(null);
|
|
|
|
const uploadTmpMediaMutation = useUploadTmpMediaMutation({
|
|
onSuccess: (response) => {
|
|
if (response.path) {
|
|
setAnswerValue(question, response.path);
|
|
setLocalPreviewUrl(response.path);
|
|
}
|
|
setIsUploading(false);
|
|
},
|
|
onError: (error) => {
|
|
console.error("Photo upload error:", error);
|
|
setIsUploading(false);
|
|
},
|
|
});
|
|
|
|
const isPending = uploadTmpMediaMutation.isPending || isUploading;
|
|
|
|
// Listen for upload_file responses from Flutter WebView if active
|
|
useEffect(() => {
|
|
if (!isInFlutterWebView()) return;
|
|
|
|
const unsubscribe = window.addFlutterResponseListener?.((event) => {
|
|
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);
|
|
if (event.data?.files?.[0]?.base64) {
|
|
const b64 = event.data.files[0].base64;
|
|
setLocalPreviewUrl(b64);
|
|
}
|
|
break;
|
|
case "completed": {
|
|
setIsUploading(false);
|
|
isInitiatorRef.current = false;
|
|
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) {
|
|
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) {
|
|
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 {
|
|
pendingDeferredRef.current?.resolve();
|
|
pendingDeferredRef.current = null;
|
|
}
|
|
break;
|
|
}
|
|
case "cancelled":
|
|
case "failed":
|
|
setIsUploading(false);
|
|
isInitiatorRef.current = false;
|
|
pendingDeferredRef.current?.reject(new Error("Upload failed or cancelled"));
|
|
pendingDeferredRef.current = null;
|
|
break;
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
unsubscribe?.();
|
|
};
|
|
}, [question, setAnswerValue, registerPendingUpload, uploadTmpMediaMutation]);
|
|
|
|
const handleFlutterPick = useCallback(() => {
|
|
const extensions = (question.extras?.options ?? []).map((o) =>
|
|
o.replace(/^\./, "").toLowerCase(),
|
|
);
|
|
|
|
isInitiatorRef.current = true;
|
|
setIsUploading(true);
|
|
|
|
let resolveFn!: (val?: any) => void;
|
|
let rejectFn!: (err?: any) => void;
|
|
const uploadPromise = new Promise((resolve, reject) => {
|
|
resolveFn = resolve;
|
|
rejectFn = reject;
|
|
});
|
|
pendingDeferredRef.current = { resolve: resolveFn, reject: rejectFn };
|
|
registerPendingUpload?.(question.id, uploadPromise);
|
|
|
|
const pickerSource =
|
|
question.ui_config?.source ||
|
|
question.ui_config?.picker_type ||
|
|
"gallery";
|
|
const pickerType =
|
|
question.ui_config?.picker_type ||
|
|
question.ui_config?.source ||
|
|
"gallery";
|
|
|
|
uploadFile({
|
|
requestId: String(question.id),
|
|
mediaType: "image",
|
|
source: pickerSource as any,
|
|
picker_type: pickerType as any,
|
|
returnAs: "upload",
|
|
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
|
|
title:
|
|
typeof question.title === "string" ? question.title : "Upload Photo",
|
|
});
|
|
}, [question, registerPendingUpload]);
|
|
|
|
const handleBrowserFileChange = (files: FileList | null) => {
|
|
const file = files?.[0];
|
|
if (!file) return;
|
|
|
|
// Create synchronous object URL for instant UI preview only
|
|
const objectUrl = URL.createObjectURL(file);
|
|
setLocalPreviewUrl(objectUrl);
|
|
|
|
// Trigger background upload and register promise with central storage
|
|
setIsUploading(true);
|
|
const uploadPromise = uploadTmpMediaMutation
|
|
.mutateAsync(file)
|
|
.then((res) => {
|
|
if (res?.path) {
|
|
setAnswerValue(question, res.path);
|
|
setLocalPreviewUrl(res.path);
|
|
}
|
|
setIsUploading(false);
|
|
})
|
|
.catch((err) => {
|
|
console.error("Photo upload error:", err);
|
|
setIsUploading(false);
|
|
});
|
|
|
|
registerPendingUpload?.(question.id, uploadPromise);
|
|
};
|
|
|
|
const handleLabelClick = (e: React.MouseEvent) => {
|
|
if (isInFlutterWebView()) {
|
|
e.preventDefault();
|
|
handleFlutterPick();
|
|
}
|
|
};
|
|
|
|
// Determine photo URL to display
|
|
let displayUrl: string | null = localPreviewUrl;
|
|
|
|
if (
|
|
!displayUrl &&
|
|
typeof storedValue === "string" &&
|
|
storedValue.trim().length > 0
|
|
) {
|
|
const val = storedValue.trim();
|
|
if (
|
|
val.startsWith("http://") ||
|
|
val.startsWith("https://") ||
|
|
val.startsWith("blob:") ||
|
|
val.startsWith("data:")
|
|
) {
|
|
displayUrl = val;
|
|
} else {
|
|
displayUrl = getApiRequestUrl(val);
|
|
}
|
|
}
|
|
|
|
const hasAnswer = Boolean(displayUrl);
|
|
|
|
return (
|
|
<div
|
|
data-question-answered={hasAnswer ? "true" : "false"}
|
|
className={[
|
|
"flex w-full flex-col items-center text-center transition-opacity duration-200 gap-4",
|
|
disabled ? "pointer-events-none opacity-30" : "",
|
|
].join(" ")}
|
|
>
|
|
<QuestionTitle
|
|
question={{ ...question, description: "" }}
|
|
className="justify-center text-center"
|
|
/>
|
|
|
|
<input
|
|
id={inputId}
|
|
type="file"
|
|
accept={acceptedFiles}
|
|
onChange={(event) => handleBrowserFileChange(event.target.files)}
|
|
disabled={disabled}
|
|
className="sr-only"
|
|
/>
|
|
<label
|
|
htmlFor={inputId}
|
|
onClick={handleLabelClick}
|
|
className="flex w-full cursor-pointer flex-col items-center"
|
|
>
|
|
<span className="flex w-full flex-col items-center">
|
|
<div className="relative flex h-[92px] w-[86px] items-center justify-center">
|
|
{displayUrl ? (
|
|
<img
|
|
src={displayUrl}
|
|
alt={typeof question.title === "string" ? question.title : ""}
|
|
className="h-[86px] w-[86px] rounded-full object-cover border border-[#D7DBE2]"
|
|
/>
|
|
) : (
|
|
<Image
|
|
src="/assets/images/Frame 2095586679.svg"
|
|
alt=""
|
|
aria-hidden="true"
|
|
width={86}
|
|
height={92}
|
|
className="h-[92px] w-[86px]"
|
|
/>
|
|
)}
|
|
|
|
{/* Red camera badge when image is uploaded */}
|
|
{displayUrl && (
|
|
<span className="absolute bottom-[2px] right-0 flex h-7 w-7 items-center justify-center rounded-full bg-[#F0445B]">
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className="text-white"
|
|
>
|
|
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
|
|
<circle cx="12" cy="13" r="3" />
|
|
</svg>
|
|
</span>
|
|
)}
|
|
|
|
{/* Loading spinner during upload matching Flutter ProfileAvatarWidget */}
|
|
{isPending && (
|
|
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 backdrop-blur-[1px] z-10 transition-all duration-200">
|
|
<div className="h-7 w-7 animate-spin rounded-full border-[2.5px] border-white border-t-transparent shadow-sm" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{descriptionContent ? (
|
|
<span className="mt-4 block max-w-[350px] group-10 leading-[1.35] font-semibold text-[#D44747]">
|
|
{descriptionContent}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default QuestionPhoto;
|