@@ -57,28 +57,31 @@ interface ImportCharacterModalProps {
57
57
export default function ImportCharacterModal ( { isOpen, onClose, onImport } : ImportCharacterModalProps ) {
58
58
const { t, fontClass, serifFontClass } = useLanguage ( ) ;
59
59
const [ isDragging , setIsDragging ] = useState ( false ) ;
60
- const [ selectedFile , setSelectedFile ] = useState < File | null > ( null ) ;
60
+ const [ selectedFiles , setSelectedFiles ] = useState < File [ ] > ( [ ] ) ;
61
61
const [ isUploading , setIsUploading ] = useState ( false ) ;
62
62
const [ error , setError ] = useState ( "" ) ;
63
63
const fileInputRef = useRef < HTMLInputElement > ( null ) ;
64
64
65
- // ErrorToast state
66
- const [ errorToast , setErrorToast ] = useState ( {
65
+ // Toast state
66
+ const [ toast , setToast ] = useState ( {
67
67
isVisible : false ,
68
68
message : "" ,
69
+ type : "error" as "success" | "error" | "warning" ,
69
70
} ) ;
70
71
71
- const showErrorToast = ( message : string ) => {
72
- setErrorToast ( {
72
+ const showToast = ( message : string , type : "success" | "error" | "warning" = "error" ) => {
73
+ setToast ( {
73
74
isVisible : true ,
74
75
message,
76
+ type,
75
77
} ) ;
76
78
} ;
77
79
78
- const hideErrorToast = ( ) => {
79
- setErrorToast ( {
80
+ const hideToast = ( ) => {
81
+ setToast ( {
80
82
isVisible : false ,
81
83
message : "" ,
84
+ type : "error" ,
82
85
} ) ;
83
86
} ;
84
87
@@ -97,67 +100,120 @@ export default function ImportCharacterModal({ isOpen, onClose, onImport }: Impo
97
100
setIsDragging ( false ) ;
98
101
99
102
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 ) ;
103
108
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
+ }
104
115
} else {
105
116
const errorMessage = t ( "importCharacterModal.pngOnly" ) ;
106
117
setError ( errorMessage ) ;
107
- showErrorToast ( errorMessage ) ;
118
+ showToast ( errorMessage , "error" ) ;
108
119
}
109
120
}
110
121
} ;
111
122
112
123
const handleFileSelect = ( e : React . ChangeEvent < HTMLInputElement > ) => {
113
124
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 ) ;
117
130
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
+ }
118
137
} else {
119
138
const errorMessage = t ( "importCharacterModal.pngOnly" ) ;
120
139
setError ( errorMessage ) ;
121
- showErrorToast ( errorMessage ) ;
140
+ showToast ( errorMessage , "error" ) ;
122
141
}
123
142
}
124
143
} ;
125
144
126
145
const handleUpload = async ( ) => {
127
- if ( ! selectedFile ) {
146
+ if ( selectedFiles . length === 0 ) {
128
147
const errorMessage = t ( "importCharacterModal.noFileSelected" ) ;
129
148
setError ( errorMessage ) ;
130
- showErrorToast ( errorMessage ) ;
149
+ showToast ( errorMessage , "error" ) ;
131
150
return ;
132
151
}
133
152
134
153
setIsUploading ( true ) ;
135
154
setError ( "" ) ;
136
155
137
156
try {
138
- const formData = new FormData ( ) ;
139
- formData . append ( "file" , selectedFile ) ;
157
+ let successCount = 0 ;
158
+ let failCount = 0 ;
159
+ const errors : string [ ] = [ ] ;
140
160
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
+ }
145
178
}
146
179
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
+ }
149
205
} catch ( err ) {
150
- console . error ( "Error uploading character :" , err ) ;
206
+ console . error ( "Error uploading characters :" , err ) ;
151
207
const errorMessage = typeof err === "string" ? err : t ( "importCharacterModal.uploadFailed" ) ;
152
208
setError ( errorMessage ) ;
153
- showErrorToast ( errorMessage ) ;
209
+ showToast ( errorMessage , "error" ) ;
154
210
} finally {
155
211
setIsUploading ( false ) ;
156
212
}
157
213
} ;
158
214
159
215
const resetForm = ( ) => {
160
- setSelectedFile ( null ) ;
216
+ setSelectedFiles ( [ ] ) ;
161
217
setError ( "" ) ;
162
218
if ( fileInputRef . current ) {
163
219
fileInputRef . current . value = "" ;
@@ -209,23 +265,44 @@ export default function ImportCharacterModal({ isOpen, onClose, onImport }: Impo
209
265
ref = { fileInputRef }
210
266
className = "hidden"
211
267
accept = "image/png"
268
+ multiple
212
269
onChange = { handleFileSelect }
213
270
/>
214
271
215
272
< 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" >
217
274
< 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" />
218
275
</ svg >
219
276
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
+ ) }
224
300
</ div >
225
301
) : (
226
302
< div className = { `text-[#a18d6f] ${ fontClass } ` } >
227
303
< p > { t ( "importCharacterModal.dragOrClick" ) } </ p >
228
304
< p className = "text-xs mt-1" > { t ( "importCharacterModal.pngFormat" ) } </ p >
305
+ < p className = "text-xs mt-1 text-[#8a7c6a]" > Multiple files supported</ p >
229
306
</ div >
230
307
) }
231
308
</ div >
@@ -247,25 +324,33 @@ export default function ImportCharacterModal({ isOpen, onClose, onImport }: Impo
247
324
248
325
< button
249
326
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" : "" } ` }
251
329
>
252
330
{ isUploading ? (
253
331
< div className = "flex items-center" >
254
332
< 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
+ }
256
337
</ div >
257
- ) : t ( "importCharacterModal.import" ) }
338
+ ) : (
339
+ selectedFiles . length > 1
340
+ ? `${ t ( "importCharacterModal.import" ) } (${ selectedFiles . length } )`
341
+ : t ( "importCharacterModal.import" )
342
+ ) }
258
343
</ button >
259
344
</ div >
260
345
</ div >
261
346
</ motion . div >
262
347
</ div >
263
348
) }
264
349
< 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 }
269
354
/>
270
355
</ AnimatePresence >
271
356
) ;
0 commit comments