Browse Source

feat(question-file): add multi-document upload support with tmp-media and Flutter-style spinner while preserving card frame

master
mortezaei 2 days ago
parent
commit
dd8ea52037
  1. 500
      src/components/Componentes/question-file.tsx

500
src/components/Componentes/question-file.tsx

@ -3,7 +3,10 @@
import Image from "next/image";
import { useCallback, useEffect, useRef, useState } from "react";
import type { QuestionField } from "@/lib/schema-adapter";
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media";
import {
uploadTmpMedia,
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";
@ -56,6 +59,12 @@ function isImageFile(
return /\.(jpg|jpeg|png|webp|gif|svg|bmp|avif)$/i.test(nameToCheck);
}
export type UploadedDoc = {
url: string;
name: string;
previewUrl?: string;
};
export function QuestionFile({
question,
disabled,
@ -63,28 +72,60 @@ export function QuestionFile({
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const storedValue = getAnswerValue(question);
const initialFileName =
typeof storedValue === "string" && storedValue.trim().length > 0
? (storedValue.split("/").pop() ?? storedValue)
: null;
const initialFileUrl =
typeof storedValue === "string" && storedValue.trim().length > 0
? storedValue.startsWith("http") ||
storedValue.startsWith("blob:") ||
storedValue.startsWith("data:")
? storedValue
: getApiRequestUrl(storedValue)
: null;
const [selectedFileName, setSelectedFileName] = useState<string | null>(
initialFileName,
);
const [filePreviewUrl, setFilePreviewUrl] = useState<string | null>(
initialFileUrl,
const parseInitialDocs = useCallback((stored: unknown): UploadedDoc[] => {
if (!stored) return [];
if (Array.isArray(stored)) {
return stored
.filter((item): item is string | Record<string, unknown> => Boolean(item))
.map((item) => {
if (typeof item === "string") {
const resolvedUrl =
item.startsWith("http") ||
item.startsWith("blob:") ||
item.startsWith("data:")
? item
: getApiRequestUrl(item);
return {
url: item,
previewUrl: resolvedUrl,
name: item.split("/").pop() || "document",
};
}
const url = (item.url || item.path || "") as string;
const name = (item.name || url.split("/").pop() || "document") as string;
const resolvedUrl =
url.startsWith("http") ||
url.startsWith("blob:") ||
url.startsWith("data:")
? url
: getApiRequestUrl(url);
return { url, name, previewUrl: resolvedUrl };
});
}
if (typeof stored === "string" && stored.trim().length > 0) {
const resolvedUrl =
stored.startsWith("http") ||
stored.startsWith("blob:") ||
stored.startsWith("data:")
? stored
: getApiRequestUrl(stored);
return [
{
url: stored,
name: stored.split("/").pop() || "document",
previewUrl: resolvedUrl,
},
];
}
return [];
}, []);
const [uploadedDocs, setUploadedDocs] = useState<UploadedDoc[]>(() =>
parseInitialDocs(storedValue),
);
const [isUploading, setIsUploading] = useState(false);
const isInitiatorRef = useRef(false);
const [isFlutterPicking, setIsFlutterPicking] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const acceptedFiles = (question.extras?.options ?? [])
.map((option) => option.replace(/^\./, ""))
@ -93,17 +134,32 @@ export function QuestionFile({
const uploadTmpMediaMutation = useUploadTmpMediaMutation({
onSuccess: (response) => {
if (response.path) {
setAnswerValue(question, response.path);
setSelectedFileName(response.name ?? response.path.split("/").pop() ?? "uploaded");
setFilePreviewUrl(response.path);
const resolved = response.path.startsWith("http")
? response.path
: getApiRequestUrl(response.path);
const newDoc: UploadedDoc = {
url: response.path,
name: response.name ?? response.path.split("/").pop() ?? "uploaded",
previewUrl: resolved,
};
setUploadedDocs((prev) => {
const nextDocs = [...prev, newDoc];
setAnswerValue(
question,
nextDocs.length === 1 ? nextDocs[0].url : nextDocs.map((d) => d.url),
);
return nextDocs;
});
}
setIsUploading(false);
},
onError: (error) => {
console.error("File upload error:", error);
setIsUploading(false);
},
});
const isPending = uploadTmpMediaMutation.isPending || isFlutterPicking;
const isPending = uploadTmpMediaMutation.isPending || isUploading;
// Listen for upload_file responses from Flutter
useEffect(() => {
@ -116,52 +172,66 @@ export function QuestionFile({
switch (event.status) {
case "picking":
setIsFlutterPicking(true);
break;
case "picked":
if (event.data?.files?.[0]) {
const fileName = event.data.files[0].name ?? null;
setSelectedFileName(fileName);
}
break;
case "progress":
setIsUploading(true);
break;
case "completed": {
setIsFlutterPicking(false);
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);
setSelectedFileName(file?.name ?? remoteUrl.split("/").pop() ?? "uploaded");
setFilePreviewUrl(remoteUrl);
} else if (file?.base64) {
const b64 = file.base64;
setSelectedFileName(file.name ?? "upload");
setFilePreviewUrl(b64);
fetch(b64)
.then((res) => res.blob())
.then((blob) => {
const f = new File([blob], file.name ?? "upload", {
type: blob.type,
});
uploadTmpMediaMutation.mutate(f);
const incomingFiles = event.data?.files || [];
const newDocs: UploadedDoc[] = [];
for (const f of incomingFiles) {
const remoteUrl =
f?.path ||
f?.url ||
(f?.data?.path as string | undefined) ||
(f?.data?.url as string | undefined);
if (remoteUrl) {
const resolved =
remoteUrl.startsWith("http") ||
remoteUrl.startsWith("blob:") ||
remoteUrl.startsWith("data:")
? remoteUrl
: getApiRequestUrl(remoteUrl);
newDocs.push({
url: remoteUrl,
name: f?.name || remoteUrl.split("/").pop() || "document",
previewUrl: resolved,
});
} else if (f?.base64) {
const b64 = f.base64;
fetch(b64)
.then((res) => res.blob())
.then((blob) => {
const fileObj = new File([blob], f.name ?? "document", {
type: blob.type,
});
uploadTmpMediaMutation.mutate(fileObj);
});
}
}
if (newDocs.length > 0) {
setUploadedDocs((prev) => {
const nextDocs = [...prev, ...newDocs];
setAnswerValue(
question,
nextDocs.length === 1
? nextDocs[0].url
: nextDocs.map((d) => d.url),
);
return nextDocs;
});
}
break;
}
case "cancelled":
setIsFlutterPicking(false);
isInitiatorRef.current = false;
break;
case "failed":
setIsFlutterPicking(false);
setIsUploading(false);
isInitiatorRef.current = false;
console.error("upload_file failed:", event.message);
break;
}
});
@ -179,11 +249,11 @@ export function QuestionFile({
const mediaType = resolveMediaType(extensions);
isInitiatorRef.current = true;
setIsFlutterPicking(true);
uploadFile({
requestId: String(question.id),
mediaType,
multiple: true,
source: "gallery",
returnAs: "upload",
uploadUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`,
@ -195,180 +265,236 @@ export function QuestionFile({
});
}, [question]);
/** Handle file pick via browser <input type="file"> (fallback). */
function handleBrowserFileChange(files: FileList | null) {
const file = files?.[0];
if (!file) {
setSelectedFileName(null);
setFilePreviewUrl(null);
setAnswerValue(question, null);
return;
/** Handle file pick via browser <input type="file" multiple> (fallback). */
async function handleBrowserFileChange(files: FileList | null) {
if (!files || files.length === 0) return;
setIsUploading(true);
const newDocs: UploadedDoc[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
try {
const res = await uploadTmpMedia(file);
if (res.path) {
const resolved = res.path.startsWith("http")
? res.path
: getApiRequestUrl(res.path);
newDocs.push({
url: res.path,
name: res.name || file.name,
previewUrl: resolved,
});
}
} catch (err) {
console.error("Failed to upload tmp file:", file.name, err);
}
}
setSelectedFileName(file.name);
setAnswerValue(question, file.name);
if (file.type.startsWith("image/")) {
const objectUrl = URL.createObjectURL(file);
setFilePreviewUrl(objectUrl);
} else {
setFilePreviewUrl(null);
setIsUploading(false);
if (newDocs.length > 0) {
setUploadedDocs((prev) => {
const nextDocs = [...prev, ...newDocs];
setAnswerValue(
question,
nextDocs.length === 1 ? nextDocs[0].url : nextDocs.map((d) => d.url),
);
return nextDocs;
});
}
uploadTmpMediaMutation.mutate(file);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
const handleRemoveFile = (e: React.MouseEvent) => {
const handleRemoveDoc = (indexToRemove: number, e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setSelectedFileName(null);
setFilePreviewUrl(null);
setAnswerValue(question, null);
setUploadedDocs((prev) => {
const nextDocs = prev.filter((_, idx) => idx !== indexToRemove);
if (nextDocs.length === 0) {
setAnswerValue(question, null);
} else if (nextDocs.length === 1) {
setAnswerValue(question, nextDocs[0].url);
} else {
setAnswerValue(question, nextDocs.map((d) => d.url));
}
return nextDocs;
});
};
const handleTriggerPick = (e?: React.MouseEvent) => {
if (e) {
e.stopPropagation();
}
if (isInFlutterWebView()) {
handleFlutterPick();
} else {
fileInputRef.current?.click();
}
};
const inWebView = isInFlutterWebView();
const isUploaded = Boolean(selectedFileName || storedValue);
const currentFileName =
selectedFileName ??
(typeof storedValue === "string" ? storedValue.split("/").pop() : null);
const currentFileUrl =
filePreviewUrl ??
(typeof storedValue === "string" && storedValue.trim().length > 0
? storedValue.startsWith("http") ||
storedValue.startsWith("blob:") ||
storedValue.startsWith("data:")
? storedValue
: getApiRequestUrl(storedValue)
: null);
const isImg = isImageFile(currentFileName, currentFileUrl);
const hasDocuments = uploadedDocs.length > 0;
return (
<div
data-question-answered={isUploaded ? "true" : "false"}
data-question-answered={hasDocuments ? "true" : "false"}
className={[
"flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
<span
className="relative flex aspect-[727/330] min-h-[156px] w-full cursor-pointer flex-col items-center justify-center rounded-[29px] border-2 border-dashed border-[#8D8D8D] bg-[#F7F7F7] text-center transition-colors duration-200 focus-within:outline-2 focus-within:outline-offset-4 focus-within:outline-[#6F6F6F] hover:border-[#777777] overflow-hidden p-4"
role={inWebView ? "button" : undefined}
tabIndex={inWebView ? 0 : undefined}
onClick={inWebView && !isUploaded ? handleFlutterPick : undefined}
onKeyDown={
inWebView && !isUploaded
? (e) => {
if (e.key === "Enter" || e.key === " ") handleFlutterPick();
}
: undefined
}
>
{/* Fallback: browser file input (hidden in WebView or when uploaded) */}
{!inWebView && !isUploaded && (
<input
type="file"
accept={acceptedFiles ? acceptedFiles : "*/*"}
disabled={disabled}
onChange={(event) => handleBrowserFileChange(event.target.files)}
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
/>
)}
{isUploaded ? (
/* ────── UPLOADED STATE ────── */
<div className="relative flex h-full w-full flex-col items-center justify-center">
{isImg && currentFileUrl ? (
/* Image Preview (Left design in screenshot) */
<img
src={currentFileUrl}
alt={currentFileName ?? "Uploaded image"}
className="max-h-[120px] max-w-[85%] rounded-[12px] object-contain shadow-xs"
/>
) : (
/* Document / PDF Preview (Right design in screenshot) */
<div className="flex flex-col items-center justify-center gap-2">
<div className="relative flex h-14 w-11 items-center justify-center rounded-[6px] border border-[#D1D5DB] bg-white shadow-xs">
<svg
width="28"
height="32"
viewBox="0 0 32 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{/* Hidden browser input for fallback */}
{!inWebView && (
<input
ref={fileInputRef}
type="file"
multiple
accept={acceptedFiles ? acceptedFiles : "*/*"}
disabled={disabled}
onChange={(event) => handleBrowserFileChange(event.target.files)}
className="sr-only"
/>
)}
<div
className="relative flex min-h-[160px] w-full flex-col items-center justify-center rounded-[29px] border-2 border-dashed border-[#8D8D8D] bg-[#F7F7F7] text-center transition-colors duration-200 focus-within:outline-2 focus-within:outline-offset-4 focus-within:outline-[#6F6F6F] hover:border-[#777777] overflow-hidden p-4"
>
{hasDocuments ? (
/* ────── UPLOADED MULTI-DOCUMENT STATE ────── */
<div className="relative flex w-full flex-col items-center gap-3">
<div className="flex w-full flex-col gap-2">
{uploadedDocs.map((doc, index) => {
const isImg = isImageFile(doc.name, doc.previewUrl ?? doc.url);
return (
<div
key={`${doc.url}-${index}`}
className="relative flex w-full items-center justify-between gap-3 rounded-[16px] border border-[#E5E7EB] bg-white p-2.5 shadow-xs transition-shadow hover:shadow-sm"
>
<path
d="M4 0C1.79086 0 0 1.79086 0 4V32C0 34.2091 1.79086 36 4 36H28C30.2091 36 32 34.2091 32 32V10L22 0H4Z"
fill="#E5E7EB"
/>
<path d="M22 0V10H32L22 0Z" fill="#9CA3AF" />
</svg>
<span className="absolute bottom-1 rounded-[3px] bg-[#F0445B] px-1 py-[1px] text-[8px] font-bold text-white leading-none">
PDF
</span>
</div>
<span className="max-w-[240px] truncate text-xs font-semibold text-[#111111]">
{currentFileName ?? "document"}
</span>
</div>
)}
{/* Trash Button in Bottom-Right */}
{/* Left: Thumbnail or PDF Icon */}
<div className="flex items-center gap-3 overflow-hidden">
{isImg && doc.previewUrl ? (
<img
src={doc.previewUrl}
alt={doc.name}
className="h-12 w-12 shrink-0 rounded-[10px] object-cover border border-[#E5E7EB]"
/>
) : (
<div className="relative flex h-12 w-10 shrink-0 items-center justify-center rounded-[6px] border border-[#D1D5DB] bg-[#F9FAFB] shadow-xs">
<svg
width="24"
height="28"
viewBox="0 0 32 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 0C1.79086 0 0 1.79086 0 4V32C0 34.2091 1.79086 36 4 36H28C30.2091 36 32 34.2091 32 32V10L22 0H4Z"
fill="#E5E7EB"
/>
<path d="M22 0V10H32L22 0Z" fill="#9CA3AF" />
</svg>
<span className="absolute bottom-1 rounded-[3px] bg-[#F0445B] px-1 py-[1px] text-[7px] font-bold text-white leading-none">
PDF
</span>
</div>
)}
{/* Middle: File Name */}
<span className="truncate text-start text-xs font-semibold text-[#1F2024] max-w-[200px]">
{doc.name}
</span>
</div>
{/* Right: Individual Trash Button */}
<button
type="button"
onClick={(e) => handleRemoveDoc(index, e)}
title="حذف مدرک"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#F3F4F6] text-[#4B5563] transition-colors hover:bg-red-50 hover:text-[#EF4444] active:scale-95 cursor-pointer"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
</button>
</div>
);
})}
</div>
{/* Add Another Document Button */}
<button
type="button"
onClick={handleRemoveFile}
title="Remove file"
className="absolute bottom-0 right-0 z-20 flex h-8 w-8 items-center justify-center rounded-full bg-[#EAEAEA] text-[#36363C] transition-colors hover:bg-[#DDD] active:scale-95 cursor-pointer shadow-xs"
onClick={(e) => handleTriggerPick(e)}
className="mt-1 flex items-center justify-center gap-1.5 rounded-full border border-[#D1D5DB] bg-white px-4 py-2 text-xs font-bold text-[#374151] shadow-xs transition-colors hover:bg-[#F9FAFB] hover:border-[#9CA3AF] active:scale-98 cursor-pointer"
>
<svg
width="16"
height="16"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<path d="M12 5v14M5 12h14" />
</svg>
<span>افزودن مدرک دیگر</span>
</button>
{isPending && (
<div className="absolute inset-0 z-30 flex items-center justify-center rounded-[29px] bg-black/40 backdrop-blur-[1px]">
<LoadingSkeleton className="h-full w-full rounded-[29px]" />
</div>
)}
</div>
) : /* ────── DEFAULT EMPTY STATE ────── */
isPending ? (
<LoadingSkeleton className="h-24 w-full rounded-[24px]" />
) : (
<>
/* ────── DEFAULT EMPTY STATE ────── */
<div
role="button"
tabIndex={0}
onClick={() => handleTriggerPick()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleTriggerPick();
}}
className="flex w-full cursor-pointer flex-col items-center justify-center py-4"
>
<Image
src="/assets/images/Image.svg"
alt="Upload"
width={24}
height={24}
width={28}
height={28}
/>
<span className="mt-3 block group-12 leading-none font-normal text-[#111111]">
{selectedFileName ?? "upload certificates"}
<span className="mt-3 block group-12 leading-none font-semibold text-[#111111]">
بارگذاری مدارک (کارت شناسایی، پاسپورت و...)
</span>
{uploadTmpMediaMutation.isError ? (
<span className="mt-2 block group-10 leading-none font-bold text-[#D44747]">
Upload failed. Please try again.
</span>
) : acceptedFiles ? (
{acceptedFiles ? (
<span className="mt-2 block group-10 leading-none font-bold text-[#8B8B8B]">
{acceptedFiles}
</span>
) : null}
</>
</div>
)}
{/* Smooth Circular Loading Spinner Overlay (Matching Flutter ProfileAvatar) */}
{isPending && (
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center rounded-[29px] bg-black/40 backdrop-blur-[1px] transition-all duration-200 gap-2">
<div className="h-8 w-8 animate-spin rounded-full border-[2.5px] border-white border-t-transparent shadow-sm" />
<span className="text-xs font-semibold text-white">در حال آپلود در تمپ...</span>
</div>
)}
</span>
</div>
</div>
);
}

Loading…
Cancel
Save