@package-uploader/ui 1.0.14 → 1.1.0

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.
@@ -1,6 +1,7 @@
1
1
  import { useState } from 'react';
2
- import { api } from '../api/client';
2
+ import { api, type CourseClassMappings } from '../api/client';
3
3
  import DropZone from './DropZone';
4
+ import CourseStructureStep, { type CourseStructure } from './CourseStructureStep';
4
5
 
5
6
  interface FileItem {
6
7
  file: File;
@@ -10,6 +11,8 @@ interface FileItem {
10
11
  error?: string;
11
12
  }
12
13
 
14
+ type Step = 'drop' | 'structure' | 'options';
15
+
13
16
  interface UploadModalProps {
14
17
  folderId: number;
15
18
  folderName: string;
@@ -25,6 +28,12 @@ export default function UploadModal({
25
28
  }: UploadModalProps) {
26
29
  const [files, setFiles] = useState<FileItem[]>([]);
27
30
  const [uploading, setUploading] = useState(false);
31
+ const [step, setStep] = useState<Step>('drop');
32
+
33
+ // Course structure (parsed from Rise ZIP)
34
+ const [courseStructure, setCourseStructure] = useState<CourseStructure | null>(null);
35
+ const [classMappings, setClassMappings] = useState<CourseClassMappings>({});
36
+ const [parsingStructure, setParsingStructure] = useState(false);
28
37
 
29
38
  // LMS Thin Pack options
30
39
  const [createThinPack, setCreateThinPack] = useState(true); // Default ON
@@ -34,21 +43,52 @@ export default function UploadModal({
34
43
  const [createSharedLink, setCreateSharedLink] = useState(false); // Default OFF
35
44
  const [sharedLinkName, setSharedLinkName] = useState('');
36
45
 
46
+ // Skin option (optional)
47
+ const [skinName, setSkinName] = useState('');
48
+
49
+ // Wrap & Download mode (skip CDS upload)
50
+ const [wrapOnly, setWrapOnly] = useState(false);
51
+
37
52
  // Generate slug from name: replace spaces with underscores
38
53
  function generateSlug(name: string): string {
39
54
  return name.trim().replace(/\s+/g, '_');
40
55
  }
41
56
 
42
- function handleFilesAccepted(newFiles: File[]) {
57
+ async function handleFilesAccepted(newFiles: File[]) {
43
58
  const fileItems: FileItem[] = newFiles.map((file) => ({
44
59
  file,
45
60
  status: 'pending',
46
61
  }));
47
62
  setFiles((prev) => [...prev, ...fileItems]);
63
+
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
+ }
48
80
  }
49
81
 
50
82
  function handleRemoveFile(index: number) {
51
- setFiles((prev) => prev.filter((_, i) => i !== index));
83
+ setFiles((prev) => {
84
+ const next = prev.filter((_, i) => i !== index);
85
+ // Reset structure if we removed the only file or now have multiple
86
+ if (next.length !== 1) {
87
+ setCourseStructure(null);
88
+ setClassMappings({});
89
+ }
90
+ return next;
91
+ });
52
92
  }
53
93
 
54
94
  function formatSize(bytes: number): string {
@@ -57,12 +97,26 @@ export default function UploadModal({
57
97
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
58
98
  }
59
99
 
100
+ function handleNextFromDrop() {
101
+ if (courseStructure && files.length === 1) {
102
+ setStep('structure');
103
+ } else {
104
+ setStep('options');
105
+ }
106
+ }
107
+
60
108
  async function handleUpload() {
61
109
  const pendingFiles = files.filter((f) => f.status === 'pending');
62
110
  if (pendingFiles.length === 0) return;
63
111
 
64
112
  setUploading(true);
65
113
 
114
+ // Build class mappings option (only if any assignments exist)
115
+ const hasClassAssignments =
116
+ classMappings.course ||
117
+ Object.keys(classMappings.lessons || {}).length > 0 ||
118
+ Object.keys(classMappings.blocks || {}).length > 0;
119
+
66
120
  for (let i = 0; i < files.length; i++) {
67
121
  if (files[i].status !== 'pending') continue;
68
122
 
@@ -72,56 +126,82 @@ export default function UploadModal({
72
126
  );
73
127
 
74
128
  try {
75
- // 1. Upload file
76
- const result = await api.uploadCourse(files[i].file, folderId);
77
-
78
- if (result.success && result.documentId) {
79
- // 2. Create LMS Thin Pack if enabled
80
- if (createThinPack) {
81
- const name = thinPackName.trim() || `${result.documentName} - LMS Thin Pack`;
82
- try {
83
- await api.createSharedLink(result.documentId, {
84
- name,
85
- token: generateSlug(name),
86
- isPublic: true,
87
- isForThinPackage: true,
88
- });
89
- } catch (err) {
90
- console.error('Failed to create thin pack:', err);
91
- }
92
- }
129
+ if (wrapOnly) {
130
+ // Wrap & Download mode: wrap with PA-Patcher and download, no CDS upload
131
+ const wrapOptions: { skin?: string; classMappings?: CourseClassMappings } = {};
132
+ if (skinName.trim()) wrapOptions.skin = skinName.trim();
133
+ if (hasClassAssignments) wrapOptions.classMappings = classMappings;
93
134
 
94
- // 3. Create Shared Link if enabled
95
- if (createSharedLink) {
96
- const name = sharedLinkName.trim() || `${result.documentName} - Private`;
97
- try {
98
- await api.createSharedLink(result.documentId, {
99
- name,
100
- token: generateSlug(name),
101
- isPublic: false,
102
- isForThinPackage: false,
103
- });
104
- } catch (err) {
105
- console.error('Failed to create shared link:', err);
106
- }
107
- }
135
+ await api.wrapAndDownload(
136
+ files[i].file,
137
+ Object.keys(wrapOptions).length > 0 ? wrapOptions : undefined
138
+ );
108
139
 
109
140
  setFiles((prev) =>
110
141
  prev.map((f, idx) =>
111
- idx === i
112
- ? {
113
- ...f,
114
- status: 'success',
115
- documentId: result.documentId ?? undefined,
116
- documentName: result.documentName,
117
- }
118
- : f
142
+ idx === i ? { ...f, status: 'success' } : f
119
143
  )
120
144
  );
121
-
122
- onUploadComplete(result.documentId);
123
145
  } else {
124
- throw new Error(result.errors?.join(', ') || 'Upload failed');
146
+ // Normal upload mode
147
+ const uploadOptions: { title?: string; skin?: string; classMappings?: CourseClassMappings } = {};
148
+ if (skinName.trim()) uploadOptions.skin = skinName.trim();
149
+ if (hasClassAssignments) uploadOptions.classMappings = classMappings;
150
+
151
+ const result = await api.uploadCourse(
152
+ files[i].file,
153
+ folderId,
154
+ Object.keys(uploadOptions).length > 0 ? uploadOptions : undefined
155
+ );
156
+
157
+ if (result.success && result.documentId) {
158
+ // Create LMS Thin Pack if enabled
159
+ if (createThinPack) {
160
+ const name = thinPackName.trim() || `${result.documentName} - LMS Thin Pack`;
161
+ try {
162
+ await api.createSharedLink(result.documentId, {
163
+ name,
164
+ token: generateSlug(name),
165
+ isPublic: true,
166
+ isForThinPackage: true,
167
+ });
168
+ } catch (err) {
169
+ console.error('Failed to create thin pack:', err);
170
+ }
171
+ }
172
+
173
+ // Create Shared Link if enabled
174
+ if (createSharedLink) {
175
+ const name = sharedLinkName.trim() || `${result.documentName} - Private`;
176
+ try {
177
+ await api.createSharedLink(result.documentId, {
178
+ name,
179
+ token: generateSlug(name),
180
+ isPublic: false,
181
+ isForThinPackage: false,
182
+ });
183
+ } catch (err) {
184
+ console.error('Failed to create shared link:', err);
185
+ }
186
+ }
187
+
188
+ setFiles((prev) =>
189
+ prev.map((f, idx) =>
190
+ idx === i
191
+ ? {
192
+ ...f,
193
+ status: 'success',
194
+ documentId: result.documentId ?? undefined,
195
+ documentName: result.documentName,
196
+ }
197
+ : f
198
+ )
199
+ );
200
+
201
+ onUploadComplete(result.documentId);
202
+ } else {
203
+ throw new Error(result.errors?.join(', ') || 'Upload failed');
204
+ }
125
205
  }
126
206
  } catch (err) {
127
207
  setFiles((prev) =>
@@ -130,7 +210,7 @@ export default function UploadModal({
130
210
  ? {
131
211
  ...f,
132
212
  status: 'error',
133
- error: err instanceof Error ? err.message : 'Upload failed',
213
+ error: err instanceof Error ? err.message : wrapOnly ? 'Wrap failed' : 'Upload failed',
134
214
  }
135
215
  : f
136
216
  )
@@ -156,132 +236,275 @@ export default function UploadModal({
156
236
  <div className="modal-overlay" onClick={onClose}>
157
237
  <div className="modal upload-modal" onClick={(e) => e.stopPropagation()}>
158
238
  <div className="modal-header">
159
- <h3 className="modal-title">Upload to: {folderName}</h3>
239
+ <h3 className="modal-title">
240
+ {wrapOnly ? 'Wrap & Download' : `Upload to: ${folderName}`}
241
+ {step === 'structure' && ' — Customize'}
242
+ {step === 'options' && ' — Options'}
243
+ </h3>
160
244
  <button className="modal-close" onClick={onClose}>
161
245
  x
162
246
  </button>
163
247
  </div>
164
248
 
165
249
  <div className="upload-modal-content">
166
- {/* Dropzone */}
167
- <DropZone onFilesAccepted={handleFilesAccepted} disabled={uploading} />
168
-
169
- {/* File List */}
170
- {hasFiles && (
171
- <div className="upload-file-list">
172
- <h4>Files ({files.length})</h4>
173
- {files.map((item, index) => (
174
- <div key={index} className="upload-file-item">
175
- <div className="upload-file-info">
176
- <span className="upload-file-name">{item.file.name}</span>
177
- <span className="upload-file-size">{formatSize(item.file.size)}</span>
178
- </div>
179
- <div className="upload-file-status">
180
- {item.status === 'pending' && (
181
- <>
182
- <span className="status-pending">Ready</span>
183
- <button
184
- className="btn btn-sm btn-secondary"
185
- onClick={() => handleRemoveFile(index)}
186
- >
187
- Remove
188
- </button>
189
- </>
190
- )}
191
- {item.status === 'uploading' && (
192
- <>
193
- <div className="spinner" />
194
- <span className="status-uploading">Uploading...</span>
195
- </>
196
- )}
197
- {item.status === 'success' && (
198
- <span className="status-success">Uploaded</span>
199
- )}
200
- {item.status === 'error' && (
201
- <span className="status-error" title={item.error}>
202
- Failed
203
- </span>
204
- )}
205
- </div>
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
+ ))}
206
291
  </div>
207
- ))}
208
- </div>
292
+ )}
293
+
294
+ {/* Parsing indicator */}
295
+ {parsingStructure && (
296
+ <div className="structure-parsing-indicator">
297
+ <div className="spinner" />
298
+ <span>Analyzing course structure...</span>
299
+ </div>
300
+ )}
301
+
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
+ )}
310
+
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.
315
+ </div>
316
+ )}
317
+ </>
209
318
  )}
210
319
 
211
- {/* Link Options */}
212
- <div className="upload-link-options">
213
- <h4>After upload, create:</h4>
214
-
215
- {/* LMS Thin Pack */}
216
- <div className="upload-link-option">
217
- <label className="upload-link-toggle">
218
- <input
219
- type="checkbox"
220
- checked={createThinPack}
221
- onChange={(e) => setCreateThinPack(e.target.checked)}
222
- disabled={uploading}
223
- />
224
- <span>LMS Thin Pack (public)</span>
225
- </label>
226
- {createThinPack && (
227
- <div className="upload-link-name-input">
320
+ {/* Step 2: Course Structure */}
321
+ {step === 'structure' && courseStructure && (
322
+ <CourseStructureStep
323
+ structure={courseStructure}
324
+ classMappings={classMappings}
325
+ onChange={setClassMappings}
326
+ />
327
+ )}
328
+
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">
228
335
  <input
229
- type="text"
230
- placeholder="Link name (uses document name if empty)"
231
- value={thinPackName}
232
- onChange={(e) => setThinPackName(e.target.value)}
336
+ type="checkbox"
337
+ checked={wrapOnly}
338
+ onChange={(e) => setWrapOnly(e.target.checked)}
233
339
  disabled={uploading}
234
340
  />
235
- <span className="slug-preview">Slug: {thinPackSlugPreview}</span>
236
- </div>
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>
352
+
353
+ {/* LMS Thin Pack */}
354
+ <div className="upload-link-option">
355
+ <label className="upload-link-toggle">
356
+ <input
357
+ type="checkbox"
358
+ checked={createThinPack}
359
+ onChange={(e) => setCreateThinPack(e.target.checked)}
360
+ disabled={uploading}
361
+ />
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>
377
+
378
+ {/* Shared Link */}
379
+ <div className="upload-link-option">
380
+ <label className="upload-link-toggle">
381
+ <input
382
+ type="checkbox"
383
+ checked={createSharedLink}
384
+ onChange={(e) => setCreateSharedLink(e.target.checked)}
385
+ disabled={uploading}
386
+ />
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
+ </>
237
403
  )}
238
- </div>
239
404
 
240
- {/* Shared Link */}
241
- <div className="upload-link-option">
242
- <label className="upload-link-toggle">
243
- <input
244
- type="checkbox"
245
- checked={createSharedLink}
246
- onChange={(e) => setCreateSharedLink(e.target.checked)}
247
- disabled={uploading}
248
- />
249
- <span>Shared Link (private/SSO)</span>
250
- </label>
251
- {createSharedLink && (
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>
252
410
  <div className="upload-link-name-input">
253
411
  <input
254
412
  type="text"
255
- placeholder="Link name (uses document name if empty)"
256
- value={sharedLinkName}
257
- onChange={(e) => setSharedLinkName(e.target.value)}
413
+ placeholder="e.g. marketing"
414
+ value={skinName}
415
+ onChange={(e) => setSkinName(e.target.value)}
258
416
  disabled={uploading}
259
417
  />
260
- <span className="slug-preview">Slug: {sharedLinkSlugPreview}</span>
418
+ <span className="slug-preview">CSS class for custom skin styling</span>
419
+ </div>
420
+ </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(', ')}
261
439
  </div>
262
440
  )}
263
441
  </div>
264
- </div>
442
+ )}
265
443
  </div>
266
444
 
267
445
  <div className="modal-footer">
268
- <button className="btn btn-secondary" onClick={onClose} disabled={uploading}>
269
- Cancel
270
- </button>
271
- <button
272
- className="btn btn-primary"
273
- onClick={handleUpload}
274
- disabled={uploading || pendingCount === 0}
275
- >
276
- {uploading ? (
277
- <>
278
- <div className="spinner" />
279
- Uploading...
280
- </>
281
- ) : (
282
- `Upload ${pendingCount} file${pendingCount !== 1 ? 's' : ''}`
283
- )}
284
- </button>
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
+ )}
285
508
  </div>
286
509
  </div>
287
510
  </div>