Skip to content

Commit e188db1

Browse files
authored
feat: enhance ImportCharacterModal to support multiple file uploads (#104)
- Updated the component to allow selection and upload of multiple PNG files. - Improved error handling and user feedback with toast notifications for upload results. - Adjusted UI to display selected files and their sizes, including warnings for non-PNG files. - Refactored state management for selected files and toast notifications.
1 parent 3d97fa3 commit e188db1

File tree

1 file changed

+125
-40
lines changed

1 file changed

+125
-40
lines changed

components/ImportCharacterModal.tsx

Lines changed: 125 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -57,28 +57,31 @@ interface ImportCharacterModalProps {
5757
export default function ImportCharacterModal({ isOpen, onClose, onImport }: ImportCharacterModalProps) {
5858
const { t, fontClass, serifFontClass } = useLanguage();
5959
const [isDragging, setIsDragging] = useState(false);
60-
const [selectedFile, setSelectedFile] = useState<File | null>(null);
60+
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
6161
const [isUploading, setIsUploading] = useState(false);
6262
const [error, setError] = useState("");
6363
const fileInputRef = useRef<HTMLInputElement>(null);
6464

65-
// ErrorToast state
66-
const [errorToast, setErrorToast] = useState({
65+
// Toast state
66+
const [toast, setToast] = useState({
6767
isVisible: false,
6868
message: "",
69+
type: "error" as "success" | "error" | "warning",
6970
});
7071

71-
const showErrorToast = (message: string) => {
72-
setErrorToast({
72+
const showToast = (message: string, type: "success" | "error" | "warning" = "error") => {
73+
setToast({
7374
isVisible: true,
7475
message,
76+
type,
7577
});
7678
};
7779

78-
const hideErrorToast = () => {
79-
setErrorToast({
80+
const hideToast = () => {
81+
setToast({
8082
isVisible: false,
8183
message: "",
84+
type: "error",
8285
});
8386
};
8487

@@ -97,67 +100,120 @@ export default function ImportCharacterModal({ isOpen, onClose, onImport }: Impo
97100
setIsDragging(false);
98101

99102
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
100-
const file = e.dataTransfer.files[0];
101-
if (file.type === "image/png") {
102-
setSelectedFile(file);
103+
const files = Array.from(e.dataTransfer.files);
104+
const pngFiles = files.filter(file => file.type === "image/png");
105+
106+
if (pngFiles.length > 0) {
107+
setSelectedFiles(pngFiles);
103108
setError("");
109+
110+
// Show warning if some files were not PNG
111+
if (pngFiles.length < files.length) {
112+
const warningMessage = t("importCharacterModal.someFilesSkipped");
113+
showToast(warningMessage, "warning");
114+
}
104115
} else {
105116
const errorMessage = t("importCharacterModal.pngOnly");
106117
setError(errorMessage);
107-
showErrorToast(errorMessage);
118+
showToast(errorMessage, "error");
108119
}
109120
}
110121
};
111122

112123
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
113124
if (e.target.files && e.target.files.length > 0) {
114-
const file = e.target.files[0];
115-
if (file.type === "image/png") {
116-
setSelectedFile(file);
125+
const files = Array.from(e.target.files);
126+
const pngFiles = files.filter(file => file.type === "image/png");
127+
128+
if (pngFiles.length > 0) {
129+
setSelectedFiles(pngFiles);
117130
setError("");
131+
132+
// Show warning if some files were not PNG
133+
if (pngFiles.length < files.length) {
134+
const warningMessage = t("importCharacterModal.someFilesSkipped");
135+
showToast(warningMessage, "warning");
136+
}
118137
} else {
119138
const errorMessage = t("importCharacterModal.pngOnly");
120139
setError(errorMessage);
121-
showErrorToast(errorMessage);
140+
showToast(errorMessage, "error");
122141
}
123142
}
124143
};
125144

126145
const handleUpload = async () => {
127-
if (!selectedFile) {
146+
if (selectedFiles.length === 0) {
128147
const errorMessage = t("importCharacterModal.noFileSelected");
129148
setError(errorMessage);
130-
showErrorToast(errorMessage);
149+
showToast(errorMessage, "error");
131150
return;
132151
}
133152

134153
setIsUploading(true);
135154
setError("");
136155

137156
try {
138-
const formData = new FormData();
139-
formData.append("file", selectedFile);
157+
let successCount = 0;
158+
let failCount = 0;
159+
const errors: string[] = [];
140160

141-
const response = await handleCharacterUpload(selectedFile);
142-
143-
if (!response.success) {
144-
throw new Error(t("importCharacterModal.uploadFailed"));
161+
// Upload files sequentially to avoid overwhelming the server
162+
for (let i = 0; i < selectedFiles.length; i++) {
163+
const file = selectedFiles[i];
164+
try {
165+
const response = await handleCharacterUpload(file);
166+
167+
if (response.success) {
168+
successCount++;
169+
} else {
170+
failCount++;
171+
errors.push(`${file.name}: ${t("importCharacterModal.uploadFailed")}`);
172+
}
173+
} catch (err) {
174+
failCount++;
175+
const errorMsg = typeof err === "string" ? err : t("importCharacterModal.uploadFailed");
176+
errors.push(`${file.name}: ${errorMsg}`);
177+
}
145178
}
146179

147-
onImport();
148-
onClose();
180+
// Show results
181+
if (successCount > 0 && failCount === 0) {
182+
showToast(
183+
selectedFiles.length === 1
184+
? t("importCharacterModal.uploadSuccess")
185+
: `${successCount} characters imported successfully`,
186+
"success",
187+
);
188+
onImport();
189+
onClose();
190+
} else if (successCount > 0 && failCount > 0) {
191+
showToast(
192+
`${successCount} characters imported, ${failCount} failed`,
193+
"warning",
194+
);
195+
if (errors.length > 0) {
196+
setError(errors.slice(0, 3).join("; ") + (errors.length > 3 ? "..." : ""));
197+
}
198+
onImport(); // Refresh the character list
199+
} else {
200+
// All failed
201+
const errorMessage = errors.length > 0 ? errors[0] : t("importCharacterModal.uploadFailed");
202+
setError(errorMessage);
203+
showToast(errorMessage, "error");
204+
}
149205
} catch (err) {
150-
console.error("Error uploading character:", err);
206+
console.error("Error uploading characters:", err);
151207
const errorMessage = typeof err === "string" ? err : t("importCharacterModal.uploadFailed");
152208
setError(errorMessage);
153-
showErrorToast(errorMessage);
209+
showToast(errorMessage, "error");
154210
} finally {
155211
setIsUploading(false);
156212
}
157213
};
158214

159215
const resetForm = () => {
160-
setSelectedFile(null);
216+
setSelectedFiles([]);
161217
setError("");
162218
if (fileInputRef.current) {
163219
fileInputRef.current.value = "";
@@ -209,23 +265,44 @@ export default function ImportCharacterModal({ isOpen, onClose, onImport }: Impo
209265
ref={fileInputRef}
210266
className="hidden"
211267
accept="image/png"
268+
multiple
212269
onChange={handleFileSelect}
213270
/>
214271

215272
<div className="flex flex-col items-center justify-center">
216-
<svg xmlns="http://www.w3.org/2000/svg" className={`w-12 h-12 mb-3 ${selectedFile ? "text-[#f9c86d]" : "text-[#a18d6f]"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
273+
<svg xmlns="http://www.w3.org/2000/svg" className={`w-12 h-12 mb-3 ${selectedFiles.length > 0 ? "text-[#f9c86d]" : "text-[#a18d6f]"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
217274
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
218275
</svg>
219276

220-
{selectedFile ? (
221-
<div className={`text-[#eae6db] ${fontClass}`}>
222-
<p className="font-medium">{selectedFile.name}</p>
223-
<p className="text-xs text-[#a18d6f] mt-1">{(selectedFile.size / 1024).toFixed(1)} KB</p>
277+
{selectedFiles.length > 0 ? (
278+
<div className={`text-[#eae6db] ${fontClass} max-w-full`}>
279+
{selectedFiles.length === 1 ? (
280+
<div>
281+
<p className="font-medium truncate">{selectedFiles[0].name}</p>
282+
<p className="text-xs text-[#a18d6f] mt-1">{(selectedFiles[0].size / 1024).toFixed(1)} KB</p>
283+
</div>
284+
) : (
285+
<div>
286+
<p className="font-medium">{selectedFiles.length} files selected</p>
287+
<p className="text-xs text-[#a18d6f] mt-1">
288+
Total: {(selectedFiles.reduce((sum, file) => sum + file.size, 0) / 1024).toFixed(1)} KB
289+
</p>
290+
<div className="mt-2 max-h-16 overflow-y-auto text-xs space-y-1">
291+
{selectedFiles.slice(0, 3).map((file, index) => (
292+
<p key={index} className="text-[#c0a480] truncate">{file.name}</p>
293+
))}
294+
{selectedFiles.length > 3 && (
295+
<p className="text-[#a18d6f]">... and {selectedFiles.length - 3} more</p>
296+
)}
297+
</div>
298+
</div>
299+
)}
224300
</div>
225301
) : (
226302
<div className={`text-[#a18d6f] ${fontClass}`}>
227303
<p>{t("importCharacterModal.dragOrClick")}</p>
228304
<p className="text-xs mt-1">{t("importCharacterModal.pngFormat")}</p>
305+
<p className="text-xs mt-1 text-[#8a7c6a]">Multiple files supported</p>
229306
</div>
230307
)}
231308
</div>
@@ -247,25 +324,33 @@ export default function ImportCharacterModal({ isOpen, onClose, onImport }: Impo
247324

248325
<button
249326
onClick={(e) => {trackButtonClick("ImportCharacterModal", "导入角色");handleUpload();}}
250-
className={`px-4 py-2 bg-[#252220] hover:bg-[#3a2a2a] border border-[#534741] rounded-md text-[#f9c86d] transition-colors ${fontClass} ${(!selectedFile || isUploading) ? "opacity-50 cursor-not-allowed" : ""}`}
327+
disabled={selectedFiles.length === 0 || isUploading}
328+
className={`px-4 py-2 bg-[#252220] hover:bg-[#3a2a2a] border border-[#534741] rounded-md text-[#f9c86d] transition-colors ${fontClass} ${(selectedFiles.length === 0 || isUploading) ? "opacity-50 cursor-not-allowed" : ""}`}
251329
>
252330
{isUploading ? (
253331
<div className="flex items-center">
254332
<div className="w-4 h-4 mr-2 rounded-full border-2 border-t-[#f9c86d] border-r-[#c0a480] border-b-[#a18d6f] border-l-transparent animate-spin"></div>
255-
{t("importCharacterModal.uploading")}
333+
{selectedFiles.length > 1
334+
? `${t("importCharacterModal.uploading")} (${selectedFiles.length} files)`
335+
: t("importCharacterModal.uploading")
336+
}
256337
</div>
257-
) : t("importCharacterModal.import")}
338+
) : (
339+
selectedFiles.length > 1
340+
? `${t("importCharacterModal.import")} (${selectedFiles.length})`
341+
: t("importCharacterModal.import")
342+
)}
258343
</button>
259344
</div>
260345
</div>
261346
</motion.div>
262347
</div>
263348
)}
264349
<Toast
265-
isVisible={errorToast.isVisible}
266-
message={errorToast.message}
267-
onClose={hideErrorToast}
268-
type="error"
350+
type={toast.type}
351+
isVisible={toast.isVisible}
352+
message={toast.message}
353+
onClose={hideToast}
269354
/>
270355
</AnimatePresence>
271356
);

0 commit comments

Comments
 (0)