@package-uploader/ui 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,8 +11,6 @@ interface FileItem {
11
11
  error?: string;
12
12
  }
13
13
 
14
- type Step = 'drop' | 'structure' | 'options';
15
-
16
14
  interface UploadModalProps {
17
15
  folderId: number;
18
16
  folderName: string;
@@ -28,90 +26,100 @@ export default function UploadModal({
28
26
  }: UploadModalProps) {
29
27
  const [files, setFiles] = useState<FileItem[]>([]);
30
28
  const [uploading, setUploading] = useState(false);
31
- const [step, setStep] = useState<Step>('drop');
32
29
 
33
- // Course structure (parsed from Rise ZIP)
30
+ // Course structure on-demand, collapsible
34
31
  const [courseStructure, setCourseStructure] = useState<CourseStructure | null>(null);
35
32
  const [classMappings, setClassMappings] = useState<CourseClassMappings>({});
36
33
  const [parsingStructure, setParsingStructure] = useState(false);
34
+ const [structurePanelOpen, setStructurePanelOpen] = useState(false);
35
+ const [parseFailed, setParseFailed] = useState(false);
37
36
 
38
37
  // LMS Thin Pack options
39
- const [createThinPack, setCreateThinPack] = useState(true); // Default ON
38
+ const [createThinPack, setCreateThinPack] = useState(true);
40
39
  const [thinPackName, setThinPackName] = useState('');
41
40
 
42
41
  // Shared Link options
43
- const [createSharedLink, setCreateSharedLink] = useState(false); // Default OFF
42
+ const [createSharedLink, setCreateSharedLink] = useState(false);
44
43
  const [sharedLinkName, setSharedLinkName] = useState('');
45
44
 
46
- // Skin option (optional)
45
+ // Skin option
47
46
  const [skinName, setSkinName] = useState('');
48
47
 
49
- // Wrap & Download mode (skip CDS upload)
48
+ // Wrap & Download mode
50
49
  const [wrapOnly, setWrapOnly] = useState(false);
51
50
 
52
- // Generate slug from name: replace spaces with underscores
53
51
  function generateSlug(name: string): string {
54
52
  return name.trim().replace(/\s+/g, '_');
55
53
  }
56
54
 
57
- async function handleFilesAccepted(newFiles: File[]) {
55
+ function handleFilesAccepted(newFiles: File[]) {
58
56
  const fileItems: FileItem[] = newFiles.map((file) => ({
59
57
  file,
60
58
  status: 'pending',
61
59
  }));
62
60
  setFiles((prev) => [...prev, ...fileItems]);
63
61
 
64
- // Auto-parse structure for single file uploads
65
- const allFiles = [...files, ...fileItems];
66
- if (allFiles.length === 1 || (files.length === 0 && newFiles.length === 1)) {
67
- const targetFile = newFiles.length === 1 ? newFiles[0] : allFiles[0].file;
68
- setParsingStructure(true);
69
- try {
70
- const structure = await api.parseCourseStructure(targetFile);
71
- setCourseStructure(structure);
72
- } catch {
73
- setCourseStructure(null);
74
- }
75
- setParsingStructure(false);
76
- } else {
77
- // Multi-file: no structure parsing
78
- setCourseStructure(null);
79
- }
62
+ // Reset structure state when files change
63
+ setCourseStructure(null);
64
+ setClassMappings({});
65
+ setStructurePanelOpen(false);
66
+ setParseFailed(false);
80
67
  }
81
68
 
82
69
  function handleRemoveFile(index: number) {
83
70
  setFiles((prev) => {
84
71
  const next = prev.filter((_, i) => i !== index);
85
- // Reset structure if we removed the only file or now have multiple
86
72
  if (next.length !== 1) {
87
73
  setCourseStructure(null);
88
74
  setClassMappings({});
75
+ setStructurePanelOpen(false);
76
+ setParseFailed(false);
89
77
  }
90
78
  return next;
91
79
  });
92
80
  }
93
81
 
82
+ async function handleCustomizeClick() {
83
+ // Already parsed — just toggle
84
+ if (courseStructure) {
85
+ setStructurePanelOpen((prev) => !prev);
86
+ return;
87
+ }
88
+
89
+ // Already failed — don't retry
90
+ if (parseFailed) return;
91
+
92
+ // Parse on first click
93
+ if (files.length !== 1) return;
94
+
95
+ setParsingStructure(true);
96
+ try {
97
+ const structure = await api.parseCourseStructure(files[0].file);
98
+ if (structure) {
99
+ setCourseStructure(structure);
100
+ setStructurePanelOpen(true);
101
+ setParseFailed(false);
102
+ } else {
103
+ setParseFailed(true);
104
+ }
105
+ } catch {
106
+ setParseFailed(true);
107
+ }
108
+ setParsingStructure(false);
109
+ }
110
+
94
111
  function formatSize(bytes: number): string {
95
112
  if (bytes < 1024) return `${bytes} B`;
96
113
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
97
114
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
98
115
  }
99
116
 
100
- function handleNextFromDrop() {
101
- if (courseStructure && files.length === 1) {
102
- setStep('structure');
103
- } else {
104
- setStep('options');
105
- }
106
- }
107
-
108
117
  async function handleUpload() {
109
118
  const pendingFiles = files.filter((f) => f.status === 'pending');
110
119
  if (pendingFiles.length === 0) return;
111
120
 
112
121
  setUploading(true);
113
122
 
114
- // Build class mappings option (only if any assignments exist)
115
123
  const hasClassAssignments =
116
124
  classMappings.course ||
117
125
  Object.keys(classMappings.lessons || {}).length > 0 ||
@@ -120,14 +128,12 @@ export default function UploadModal({
120
128
  for (let i = 0; i < files.length; i++) {
121
129
  if (files[i].status !== 'pending') continue;
122
130
 
123
- // Mark as uploading
124
131
  setFiles((prev) =>
125
132
  prev.map((f, idx) => (idx === i ? { ...f, status: 'uploading' } : f))
126
133
  );
127
134
 
128
135
  try {
129
136
  if (wrapOnly) {
130
- // Wrap & Download mode: wrap with PA-Patcher and download, no CDS upload
131
137
  const wrapOptions: { skin?: string; classMappings?: CourseClassMappings } = {};
132
138
  if (skinName.trim()) wrapOptions.skin = skinName.trim();
133
139
  if (hasClassAssignments) wrapOptions.classMappings = classMappings;
@@ -143,7 +149,6 @@ export default function UploadModal({
143
149
  )
144
150
  );
145
151
  } else {
146
- // Normal upload mode
147
152
  const uploadOptions: { title?: string; skin?: string; classMappings?: CourseClassMappings } = {};
148
153
  if (skinName.trim()) uploadOptions.skin = skinName.trim();
149
154
  if (hasClassAssignments) uploadOptions.classMappings = classMappings;
@@ -155,7 +160,6 @@ export default function UploadModal({
155
160
  );
156
161
 
157
162
  if (result.success && result.documentId) {
158
- // Create LMS Thin Pack if enabled
159
163
  if (createThinPack) {
160
164
  const name = thinPackName.trim() || `${result.documentName} - LMS Thin Pack`;
161
165
  try {
@@ -170,7 +174,6 @@ export default function UploadModal({
170
174
  }
171
175
  }
172
176
 
173
- // Create Shared Link if enabled
174
177
  if (createSharedLink) {
175
178
  const name = sharedLinkName.trim() || `${result.documentName} - Private`;
176
179
  try {
@@ -223,8 +226,8 @@ export default function UploadModal({
223
226
 
224
227
  const pendingCount = files.filter((f) => f.status === 'pending').length;
225
228
  const hasFiles = files.length > 0;
229
+ const isSingleFile = files.length === 1;
226
230
 
227
- // Preview slugs
228
231
  const thinPackSlugPreview = thinPackName.trim()
229
232
  ? generateSlug(thinPackName)
230
233
  : '(will use document name)';
@@ -232,14 +235,27 @@ export default function UploadModal({
232
235
  ? generateSlug(sharedLinkName)
233
236
  : '(will use document name)';
234
237
 
238
+ // Class assignment count for summary
239
+ const assignmentCount =
240
+ (classMappings.course ? 1 : 0) +
241
+ Object.keys(classMappings.lessons || {}).length +
242
+ Object.keys(classMappings.blocks || {}).length;
243
+
244
+ // Customize button label
245
+ const customizeLabel = parsingStructure
246
+ ? 'Analyzing...'
247
+ : parseFailed
248
+ ? 'Not a Rise course'
249
+ : courseStructure
250
+ ? `Customize Classes (${courseStructure.lessons.length} lessons)`
251
+ : 'Customize Classes';
252
+
235
253
  return (
236
254
  <div className="modal-overlay" onClick={onClose}>
237
255
  <div className="modal upload-modal" onClick={(e) => e.stopPropagation()}>
238
256
  <div className="modal-header">
239
257
  <h3 className="modal-title">
240
258
  {wrapOnly ? 'Wrap & Download' : `Upload to: ${folderName}`}
241
- {step === 'structure' && ' — Customize'}
242
- {step === 'options' && ' — Options'}
243
259
  </h3>
244
260
  <button className="modal-close" onClick={onClose}>
245
261
  x
@@ -247,264 +263,210 @@ export default function UploadModal({
247
263
  </div>
248
264
 
249
265
  <div className="upload-modal-content">
250
- {/* Step 1: Drop Zone */}
251
- {step === 'drop' && (
252
- <>
253
- <DropZone onFilesAccepted={handleFilesAccepted} disabled={uploading} />
254
-
255
- {/* File List */}
256
- {hasFiles && (
257
- <div className="upload-file-list">
258
- <h4>Files ({files.length})</h4>
259
- {files.map((item, index) => (
260
- <div key={index} className="upload-file-item">
261
- <div className="upload-file-info">
262
- <span className="upload-file-name">{item.file.name}</span>
263
- <span className="upload-file-size">{formatSize(item.file.size)}</span>
264
- </div>
265
- <div className="upload-file-status">
266
- {item.status === 'pending' && (
267
- <button
268
- className="btn btn-sm btn-secondary"
269
- onClick={() => handleRemoveFile(index)}
270
- >
271
- Remove
272
- </button>
273
- )}
274
- {item.status === 'uploading' && (
275
- <>
276
- <div className="spinner" />
277
- <span className="status-uploading">{wrapOnly ? 'Wrapping...' : 'Uploading...'}</span>
278
- </>
279
- )}
280
- {item.status === 'success' && (
281
- <span className="status-success">{wrapOnly ? 'Downloaded' : 'Uploaded'}</span>
282
- )}
283
- {item.status === 'error' && (
284
- <span className="status-error" title={item.error}>
285
- Failed
286
- </span>
287
- )}
288
- </div>
289
- </div>
290
- ))}
291
- </div>
292
- )}
293
-
294
- {/* Parsing indicator */}
295
- {parsingStructure && (
296
- <div className="structure-parsing-indicator">
297
- <div className="spinner" />
298
- <span>Analyzing course structure...</span>
266
+ {/* Dropzone */}
267
+ <DropZone onFilesAccepted={handleFilesAccepted} disabled={uploading} />
268
+
269
+ {/* File List */}
270
+ {hasFiles && (
271
+ <div className="upload-file-list">
272
+ <h4>Files ({files.length})</h4>
273
+ {files.map((item, index) => (
274
+ <div key={index} className="upload-file-item">
275
+ <div className="upload-file-info">
276
+ <span className="upload-file-name">{item.file.name}</span>
277
+ <span className="upload-file-size">{formatSize(item.file.size)}</span>
278
+ </div>
279
+ <div className="upload-file-status">
280
+ {item.status === 'pending' && (
281
+ <button
282
+ className="btn btn-sm btn-secondary"
283
+ onClick={() => handleRemoveFile(index)}
284
+ >
285
+ Remove
286
+ </button>
287
+ )}
288
+ {item.status === 'uploading' && (
289
+ <>
290
+ <div className="spinner" />
291
+ <span className="status-uploading">{wrapOnly ? 'Wrapping...' : 'Uploading...'}</span>
292
+ </>
293
+ )}
294
+ {item.status === 'success' && (
295
+ <span className="status-success">{wrapOnly ? 'Downloaded' : 'Uploaded'}</span>
296
+ )}
297
+ {item.status === 'error' && (
298
+ <span className="status-error" title={item.error}>
299
+ Failed
300
+ </span>
301
+ )}
302
+ </div>
299
303
  </div>
300
- )}
304
+ ))}
305
+ </div>
306
+ )}
301
307
 
302
- {/* Structure detected badge */}
303
- {courseStructure && !parsingStructure && (
304
- <div className="structure-detected-badge">
305
- Rise course detected — {courseStructure.lessons.length} lessons,{' '}
306
- {courseStructure.lessons.reduce((sum, l) => sum + l.blocks.length, 0)} blocks.
307
- You can customize classes in the next step.
308
- </div>
309
- )}
308
+ {/* Customize Classes on-demand toggle */}
309
+ {isSingleFile && !uploading && (
310
+ <div className="structure-section">
311
+ <button
312
+ className={`structure-toggle-btn${structurePanelOpen ? ' open' : ''}${parseFailed ? ' disabled' : ''}`}
313
+ onClick={handleCustomizeClick}
314
+ disabled={parsingStructure || parseFailed}
315
+ >
316
+ {parsingStructure && <div className="spinner" />}
317
+ <span>{customizeLabel}</span>
318
+ {!parsingStructure && !parseFailed && (
319
+ <span className="structure-toggle-arrow">
320
+ {structurePanelOpen ? '\u25B2' : '\u25BC'}
321
+ </span>
322
+ )}
323
+ {assignmentCount > 0 && !structurePanelOpen && (
324
+ <span className="course-structure-badge">{assignmentCount} assigned</span>
325
+ )}
326
+ </button>
310
327
 
311
- {/* Multi-file note */}
312
- {files.length > 1 && (
313
- <div className="structure-multi-note">
314
- Course structure customization is available for single-file uploads only.
328
+ {structurePanelOpen && courseStructure && (
329
+ <div className="structure-panel">
330
+ <CourseStructureStep
331
+ structure={courseStructure}
332
+ classMappings={classMappings}
333
+ onChange={setClassMappings}
334
+ />
315
335
  </div>
316
336
  )}
317
- </>
318
- )}
319
-
320
- {/* Step 2: Course Structure */}
321
- {step === 'structure' && courseStructure && (
322
- <CourseStructureStep
323
- structure={courseStructure}
324
- classMappings={classMappings}
325
- onChange={setClassMappings}
326
- />
337
+ </div>
327
338
  )}
328
339
 
329
- {/* Step 3: Options */}
330
- {step === 'options' && (
331
- <div className="upload-link-options">
332
- {/* Wrap & Download toggle */}
333
- <div className="upload-link-option">
334
- <label className="upload-link-toggle">
335
- <input
336
- type="checkbox"
337
- checked={wrapOnly}
338
- onChange={(e) => setWrapOnly(e.target.checked)}
339
- disabled={uploading}
340
- />
341
- <span>Wrap & Download only (skip CDS upload)</span>
342
- </label>
343
- {wrapOnly && (
344
- <span className="slug-preview">Wraps with PA-Patcher and downloads the ZIP directly</span>
345
- )}
346
- </div>
347
-
348
- {/* CDS upload options (hidden in wrap-only mode) */}
349
- {!wrapOnly && (
350
- <>
351
- <h4>After upload, create:</h4>
340
+ {/* Options */}
341
+ <div className="upload-link-options">
342
+ {/* Wrap & Download toggle */}
343
+ <div className="upload-link-option">
344
+ <label className="upload-link-toggle">
345
+ <input
346
+ type="checkbox"
347
+ checked={wrapOnly}
348
+ onChange={(e) => setWrapOnly(e.target.checked)}
349
+ disabled={uploading}
350
+ />
351
+ <span>Wrap & Download only (skip CDS upload)</span>
352
+ </label>
353
+ {wrapOnly && (
354
+ <span className="slug-preview">Wraps with PA-Patcher and downloads the ZIP directly</span>
355
+ )}
356
+ </div>
352
357
 
353
- {/* LMS Thin Pack */}
354
- <div className="upload-link-option">
355
- <label className="upload-link-toggle">
358
+ {/* CDS upload options */}
359
+ {!wrapOnly && (
360
+ <>
361
+ <h4>After upload, create:</h4>
362
+
363
+ <div className="upload-link-option">
364
+ <label className="upload-link-toggle">
365
+ <input
366
+ type="checkbox"
367
+ checked={createThinPack}
368
+ onChange={(e) => setCreateThinPack(e.target.checked)}
369
+ disabled={uploading}
370
+ />
371
+ <span>LMS Thin Pack (public)</span>
372
+ </label>
373
+ {createThinPack && (
374
+ <div className="upload-link-name-input">
356
375
  <input
357
- type="checkbox"
358
- checked={createThinPack}
359
- onChange={(e) => setCreateThinPack(e.target.checked)}
376
+ type="text"
377
+ placeholder="Link name (uses document name if empty)"
378
+ value={thinPackName}
379
+ onChange={(e) => setThinPackName(e.target.value)}
360
380
  disabled={uploading}
361
381
  />
362
- <span>LMS Thin Pack (public)</span>
363
- </label>
364
- {createThinPack && (
365
- <div className="upload-link-name-input">
366
- <input
367
- type="text"
368
- placeholder="Link name (uses document name if empty)"
369
- value={thinPackName}
370
- onChange={(e) => setThinPackName(e.target.value)}
371
- disabled={uploading}
372
- />
373
- <span className="slug-preview">Slug: {thinPackSlugPreview}</span>
374
- </div>
375
- )}
376
- </div>
382
+ <span className="slug-preview">Slug: {thinPackSlugPreview}</span>
383
+ </div>
384
+ )}
385
+ </div>
377
386
 
378
- {/* Shared Link */}
379
- <div className="upload-link-option">
380
- <label className="upload-link-toggle">
387
+ <div className="upload-link-option">
388
+ <label className="upload-link-toggle">
389
+ <input
390
+ type="checkbox"
391
+ checked={createSharedLink}
392
+ onChange={(e) => setCreateSharedLink(e.target.checked)}
393
+ disabled={uploading}
394
+ />
395
+ <span>Shared Link (private/SSO)</span>
396
+ </label>
397
+ {createSharedLink && (
398
+ <div className="upload-link-name-input">
381
399
  <input
382
- type="checkbox"
383
- checked={createSharedLink}
384
- onChange={(e) => setCreateSharedLink(e.target.checked)}
400
+ type="text"
401
+ placeholder="Link name (uses document name if empty)"
402
+ value={sharedLinkName}
403
+ onChange={(e) => setSharedLinkName(e.target.value)}
385
404
  disabled={uploading}
386
405
  />
387
- <span>Shared Link (private/SSO)</span>
388
- </label>
389
- {createSharedLink && (
390
- <div className="upload-link-name-input">
391
- <input
392
- type="text"
393
- placeholder="Link name (uses document name if empty)"
394
- value={sharedLinkName}
395
- onChange={(e) => setSharedLinkName(e.target.value)}
396
- disabled={uploading}
397
- />
398
- <span className="slug-preview">Slug: {sharedLinkSlugPreview}</span>
399
- </div>
400
- )}
401
- </div>
402
- </>
403
- )}
404
-
405
- {/* Skin Option */}
406
- <div className="upload-link-option" style={{ marginTop: '12px' }}>
407
- <label className="upload-link-toggle">
408
- <span>Skin (optional)</span>
409
- </label>
410
- <div className="upload-link-name-input">
411
- <input
412
- type="text"
413
- placeholder="e.g. marketing"
414
- value={skinName}
415
- onChange={(e) => setSkinName(e.target.value)}
416
- disabled={uploading}
417
- />
418
- <span className="slug-preview">CSS class for custom skin styling</span>
406
+ <span className="slug-preview">Slug: {sharedLinkSlugPreview}</span>
407
+ </div>
408
+ )}
419
409
  </div>
410
+ </>
411
+ )}
412
+
413
+ {/* Skin */}
414
+ <div className="upload-link-option" style={{ marginTop: '12px' }}>
415
+ <label className="upload-link-toggle">
416
+ <span>Skin (optional)</span>
417
+ </label>
418
+ <div className="upload-link-name-input">
419
+ <input
420
+ type="text"
421
+ placeholder="e.g. marketing"
422
+ value={skinName}
423
+ onChange={(e) => setSkinName(e.target.value)}
424
+ disabled={uploading}
425
+ />
426
+ <span className="slug-preview">CSS class for custom skin styling</span>
420
427
  </div>
421
-
422
- {/* Class mappings summary (if any assigned) */}
423
- {(classMappings.course ||
424
- Object.keys(classMappings.lessons || {}).length > 0 ||
425
- Object.keys(classMappings.blocks || {}).length > 0) && (
426
- <div className="structure-summary-badge">
427
- Class assignments:{' '}
428
- {[
429
- classMappings.course ? '1 course' : '',
430
- Object.keys(classMappings.lessons || {}).length > 0
431
- ? `${Object.keys(classMappings.lessons || {}).length} lesson(s)`
432
- : '',
433
- Object.keys(classMappings.blocks || {}).length > 0
434
- ? `${Object.keys(classMappings.blocks || {}).length} block(s)`
435
- : '',
436
- ]
437
- .filter(Boolean)
438
- .join(', ')}
439
- </div>
440
- )}
428
+ </div>
429
+ </div>
430
+
431
+ {/* Class assignments summary */}
432
+ {assignmentCount > 0 && !structurePanelOpen && (
433
+ <div className="structure-summary-badge">
434
+ Class assignments:{' '}
435
+ {[
436
+ classMappings.course ? '1 course' : '',
437
+ Object.keys(classMappings.lessons || {}).length > 0
438
+ ? `${Object.keys(classMappings.lessons || {}).length} lesson(s)`
439
+ : '',
440
+ Object.keys(classMappings.blocks || {}).length > 0
441
+ ? `${Object.keys(classMappings.blocks || {}).length} block(s)`
442
+ : '',
443
+ ]
444
+ .filter(Boolean)
445
+ .join(', ')}
441
446
  </div>
442
447
  )}
443
448
  </div>
444
449
 
445
450
  <div className="modal-footer">
446
- {step === 'drop' && (
447
- <>
448
- <button className="btn btn-secondary" onClick={onClose} disabled={uploading}>
449
- Cancel
450
- </button>
451
- <button
452
- className="btn btn-primary"
453
- onClick={handleNextFromDrop}
454
- disabled={uploading || pendingCount === 0 || parsingStructure}
455
- >
456
- Next
457
- </button>
458
- </>
459
- )}
460
-
461
- {step === 'structure' && (
462
- <>
463
- <button className="btn btn-secondary" onClick={() => setStep('drop')}>
464
- Back
465
- </button>
466
- <button
467
- className="btn btn-secondary"
468
- onClick={() => {
469
- setClassMappings({});
470
- setStep('options');
471
- }}
472
- >
473
- Skip
474
- </button>
475
- <button className="btn btn-primary" onClick={() => setStep('options')}>
476
- Next
477
- </button>
478
- </>
479
- )}
480
-
481
- {step === 'options' && (
482
- <>
483
- <button
484
- className="btn btn-secondary"
485
- onClick={() => setStep(courseStructure && files.length === 1 ? 'structure' : 'drop')}
486
- disabled={uploading}
487
- >
488
- Back
489
- </button>
490
- <button
491
- className="btn btn-primary"
492
- onClick={handleUpload}
493
- disabled={uploading || pendingCount === 0}
494
- >
495
- {uploading ? (
496
- <>
497
- <div className="spinner" />
498
- {wrapOnly ? 'Wrapping...' : 'Uploading...'}
499
- </>
500
- ) : wrapOnly ? (
501
- `Wrap & Download ${pendingCount} file${pendingCount !== 1 ? 's' : ''}`
502
- ) : (
503
- `Upload ${pendingCount} file${pendingCount !== 1 ? 's' : ''}`
504
- )}
505
- </button>
506
- </>
507
- )}
451
+ <button className="btn btn-secondary" onClick={onClose} disabled={uploading}>
452
+ Cancel
453
+ </button>
454
+ <button
455
+ className="btn btn-primary"
456
+ onClick={handleUpload}
457
+ disabled={uploading || pendingCount === 0}
458
+ >
459
+ {uploading ? (
460
+ <>
461
+ <div className="spinner" />
462
+ {wrapOnly ? 'Wrapping...' : 'Uploading...'}
463
+ </>
464
+ ) : wrapOnly ? (
465
+ `Wrap & Download ${pendingCount} file${pendingCount !== 1 ? 's' : ''}`
466
+ ) : (
467
+ `Upload ${pendingCount} file${pendingCount !== 1 ? 's' : ''}`
468
+ )}
469
+ </button>
508
470
  </div>
509
471
  </div>
510
472
  </div>