@package-uploader/ui 1.1.0 → 1.1.2

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,106 +26,115 @@ 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 ||
118
- Object.keys(classMappings.blocks || {}).length > 0;
126
+ Object.keys(classMappings.blocks || {}).length > 0 ||
127
+ (classMappings.blockGroups && classMappings.blockGroups.length > 0);
119
128
 
120
129
  for (let i = 0; i < files.length; i++) {
121
130
  if (files[i].status !== 'pending') continue;
122
131
 
123
- // Mark as uploading
124
132
  setFiles((prev) =>
125
133
  prev.map((f, idx) => (idx === i ? { ...f, status: 'uploading' } : f))
126
134
  );
127
135
 
128
136
  try {
129
137
  if (wrapOnly) {
130
- // Wrap & Download mode: wrap with PA-Patcher and download, no CDS upload
131
138
  const wrapOptions: { skin?: string; classMappings?: CourseClassMappings } = {};
132
139
  if (skinName.trim()) wrapOptions.skin = skinName.trim();
133
140
  if (hasClassAssignments) wrapOptions.classMappings = classMappings;
@@ -143,7 +150,6 @@ export default function UploadModal({
143
150
  )
144
151
  );
145
152
  } else {
146
- // Normal upload mode
147
153
  const uploadOptions: { title?: string; skin?: string; classMappings?: CourseClassMappings } = {};
148
154
  if (skinName.trim()) uploadOptions.skin = skinName.trim();
149
155
  if (hasClassAssignments) uploadOptions.classMappings = classMappings;
@@ -155,7 +161,6 @@ export default function UploadModal({
155
161
  );
156
162
 
157
163
  if (result.success && result.documentId) {
158
- // Create LMS Thin Pack if enabled
159
164
  if (createThinPack) {
160
165
  const name = thinPackName.trim() || `${result.documentName} - LMS Thin Pack`;
161
166
  try {
@@ -170,7 +175,6 @@ export default function UploadModal({
170
175
  }
171
176
  }
172
177
 
173
- // Create Shared Link if enabled
174
178
  if (createSharedLink) {
175
179
  const name = sharedLinkName.trim() || `${result.documentName} - Private`;
176
180
  try {
@@ -223,8 +227,8 @@ export default function UploadModal({
223
227
 
224
228
  const pendingCount = files.filter((f) => f.status === 'pending').length;
225
229
  const hasFiles = files.length > 0;
230
+ const isSingleFile = files.length === 1;
226
231
 
227
- // Preview slugs
228
232
  const thinPackSlugPreview = thinPackName.trim()
229
233
  ? generateSlug(thinPackName)
230
234
  : '(will use document name)';
@@ -232,14 +236,29 @@ export default function UploadModal({
232
236
  ? generateSlug(sharedLinkName)
233
237
  : '(will use document name)';
234
238
 
239
+ // Class assignment count for summary
240
+ const groupCount = (classMappings.blockGroups || []).length;
241
+ const assignmentCount =
242
+ (classMappings.course ? 1 : 0) +
243
+ Object.keys(classMappings.lessons || {}).length +
244
+ Object.keys(classMappings.blocks || {}).length +
245
+ groupCount;
246
+
247
+ // Customize button label
248
+ const customizeLabel = parsingStructure
249
+ ? 'Analyzing...'
250
+ : parseFailed
251
+ ? 'Not a Rise course'
252
+ : courseStructure
253
+ ? `Customize Classes (${courseStructure.lessons.length} lessons)`
254
+ : 'Customize Classes';
255
+
235
256
  return (
236
257
  <div className="modal-overlay" onClick={onClose}>
237
258
  <div className="modal upload-modal" onClick={(e) => e.stopPropagation()}>
238
259
  <div className="modal-header">
239
260
  <h3 className="modal-title">
240
261
  {wrapOnly ? 'Wrap & Download' : `Upload to: ${folderName}`}
241
- {step === 'structure' && ' — Customize'}
242
- {step === 'options' && ' — Options'}
243
262
  </h3>
244
263
  <button className="modal-close" onClick={onClose}>
245
264
  x
@@ -247,264 +266,213 @@ export default function UploadModal({
247
266
  </div>
248
267
 
249
268
  <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>
269
+ {/* Dropzone */}
270
+ <DropZone onFilesAccepted={handleFilesAccepted} disabled={uploading} />
271
+
272
+ {/* File List */}
273
+ {hasFiles && (
274
+ <div className="upload-file-list">
275
+ <h4>Files ({files.length})</h4>
276
+ {files.map((item, index) => (
277
+ <div key={index} className="upload-file-item">
278
+ <div className="upload-file-info">
279
+ <span className="upload-file-name">{item.file.name}</span>
280
+ <span className="upload-file-size">{formatSize(item.file.size)}</span>
281
+ </div>
282
+ <div className="upload-file-status">
283
+ {item.status === 'pending' && (
284
+ <button
285
+ className="btn btn-sm btn-secondary"
286
+ onClick={() => handleRemoveFile(index)}
287
+ >
288
+ Remove
289
+ </button>
290
+ )}
291
+ {item.status === 'uploading' && (
292
+ <>
293
+ <div className="spinner" />
294
+ <span className="status-uploading">{wrapOnly ? 'Wrapping...' : 'Uploading...'}</span>
295
+ </>
296
+ )}
297
+ {item.status === 'success' && (
298
+ <span className="status-success">{wrapOnly ? 'Downloaded' : 'Uploaded'}</span>
299
+ )}
300
+ {item.status === 'error' && (
301
+ <span className="status-error" title={item.error}>
302
+ Failed
303
+ </span>
304
+ )}
305
+ </div>
299
306
  </div>
300
- )}
307
+ ))}
308
+ </div>
309
+ )}
301
310
 
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
- )}
311
+ {/* Customize Classes on-demand toggle */}
312
+ {isSingleFile && !uploading && (
313
+ <div className="structure-section">
314
+ <button
315
+ className={`structure-toggle-btn${structurePanelOpen ? ' open' : ''}${parseFailed ? ' disabled' : ''}`}
316
+ onClick={handleCustomizeClick}
317
+ disabled={parsingStructure || parseFailed}
318
+ >
319
+ {parsingStructure && <div className="spinner" />}
320
+ <span>{customizeLabel}</span>
321
+ {!parsingStructure && !parseFailed && (
322
+ <span className="structure-toggle-arrow">
323
+ {structurePanelOpen ? '\u25B2' : '\u25BC'}
324
+ </span>
325
+ )}
326
+ {assignmentCount > 0 && !structurePanelOpen && (
327
+ <span className="course-structure-badge">{assignmentCount} assigned</span>
328
+ )}
329
+ </button>
310
330
 
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.
331
+ {structurePanelOpen && courseStructure && (
332
+ <div className="structure-panel">
333
+ <CourseStructureStep
334
+ structure={courseStructure}
335
+ classMappings={classMappings}
336
+ onChange={setClassMappings}
337
+ />
315
338
  </div>
316
339
  )}
317
- </>
318
- )}
319
-
320
- {/* Step 2: Course Structure */}
321
- {step === 'structure' && courseStructure && (
322
- <CourseStructureStep
323
- structure={courseStructure}
324
- classMappings={classMappings}
325
- onChange={setClassMappings}
326
- />
340
+ </div>
327
341
  )}
328
342
 
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>
343
+ {/* Options */}
344
+ <div className="upload-link-options">
345
+ {/* Wrap & Download toggle */}
346
+ <div className="upload-link-option">
347
+ <label className="upload-link-toggle">
348
+ <input
349
+ type="checkbox"
350
+ checked={wrapOnly}
351
+ onChange={(e) => setWrapOnly(e.target.checked)}
352
+ disabled={uploading}
353
+ />
354
+ <span>Wrap & Download only (skip CDS upload)</span>
355
+ </label>
356
+ {wrapOnly && (
357
+ <span className="slug-preview">Wraps with PA-Patcher and downloads the ZIP directly</span>
358
+ )}
359
+ </div>
352
360
 
353
- {/* LMS Thin Pack */}
354
- <div className="upload-link-option">
355
- <label className="upload-link-toggle">
361
+ {/* CDS upload options */}
362
+ {!wrapOnly && (
363
+ <>
364
+ <h4>After upload, create:</h4>
365
+
366
+ <div className="upload-link-option">
367
+ <label className="upload-link-toggle">
368
+ <input
369
+ type="checkbox"
370
+ checked={createThinPack}
371
+ onChange={(e) => setCreateThinPack(e.target.checked)}
372
+ disabled={uploading}
373
+ />
374
+ <span>LMS Thin Pack (public)</span>
375
+ </label>
376
+ {createThinPack && (
377
+ <div className="upload-link-name-input">
356
378
  <input
357
- type="checkbox"
358
- checked={createThinPack}
359
- onChange={(e) => setCreateThinPack(e.target.checked)}
379
+ type="text"
380
+ placeholder="Link name (uses document name if empty)"
381
+ value={thinPackName}
382
+ onChange={(e) => setThinPackName(e.target.value)}
360
383
  disabled={uploading}
361
384
  />
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>
385
+ <span className="slug-preview">Slug: {thinPackSlugPreview}</span>
386
+ </div>
387
+ )}
388
+ </div>
377
389
 
378
- {/* Shared Link */}
379
- <div className="upload-link-option">
380
- <label className="upload-link-toggle">
390
+ <div className="upload-link-option">
391
+ <label className="upload-link-toggle">
392
+ <input
393
+ type="checkbox"
394
+ checked={createSharedLink}
395
+ onChange={(e) => setCreateSharedLink(e.target.checked)}
396
+ disabled={uploading}
397
+ />
398
+ <span>Shared Link (private/SSO)</span>
399
+ </label>
400
+ {createSharedLink && (
401
+ <div className="upload-link-name-input">
381
402
  <input
382
- type="checkbox"
383
- checked={createSharedLink}
384
- onChange={(e) => setCreateSharedLink(e.target.checked)}
403
+ type="text"
404
+ placeholder="Link name (uses document name if empty)"
405
+ value={sharedLinkName}
406
+ onChange={(e) => setSharedLinkName(e.target.value)}
385
407
  disabled={uploading}
386
408
  />
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>
409
+ <span className="slug-preview">Slug: {sharedLinkSlugPreview}</span>
410
+ </div>
411
+ )}
419
412
  </div>
413
+ </>
414
+ )}
415
+
416
+ {/* Skin */}
417
+ <div className="upload-link-option" style={{ marginTop: '12px' }}>
418
+ <label className="upload-link-toggle">
419
+ <span>Skin (optional)</span>
420
+ </label>
421
+ <div className="upload-link-name-input">
422
+ <input
423
+ type="text"
424
+ placeholder="e.g. marketing"
425
+ value={skinName}
426
+ onChange={(e) => setSkinName(e.target.value)}
427
+ disabled={uploading}
428
+ />
429
+ <span className="slug-preview">CSS class for custom skin styling</span>
420
430
  </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
- )}
431
+ </div>
432
+ </div>
433
+
434
+ {/* Class assignments summary */}
435
+ {assignmentCount > 0 && !structurePanelOpen && (
436
+ <div className="structure-summary-badge">
437
+ Class assignments:{' '}
438
+ {[
439
+ classMappings.course ? '1 course' : '',
440
+ Object.keys(classMappings.lessons || {}).length > 0
441
+ ? `${Object.keys(classMappings.lessons || {}).length} lesson(s)`
442
+ : '',
443
+ Object.keys(classMappings.blocks || {}).length > 0
444
+ ? `${Object.keys(classMappings.blocks || {}).length} block(s)`
445
+ : '',
446
+ groupCount > 0
447
+ ? `${groupCount} group(s)`
448
+ : '',
449
+ ]
450
+ .filter(Boolean)
451
+ .join(', ')}
441
452
  </div>
442
453
  )}
443
454
  </div>
444
455
 
445
456
  <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
- )}
457
+ <button className="btn btn-secondary" onClick={onClose} disabled={uploading}>
458
+ Cancel
459
+ </button>
460
+ <button
461
+ className="btn btn-primary"
462
+ onClick={handleUpload}
463
+ disabled={uploading || pendingCount === 0}
464
+ >
465
+ {uploading ? (
466
+ <>
467
+ <div className="spinner" />
468
+ {wrapOnly ? 'Wrapping...' : 'Uploading...'}
469
+ </>
470
+ ) : wrapOnly ? (
471
+ `Wrap & Download ${pendingCount} file${pendingCount !== 1 ? 's' : ''}`
472
+ ) : (
473
+ `Upload ${pendingCount} file${pendingCount !== 1 ? 's' : ''}`
474
+ )}
475
+ </button>
508
476
  </div>
509
477
  </div>
510
478
  </div>