@package-uploader/ui 1.0.14 → 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.
@@ -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;
@@ -26,15 +27,27 @@ export default function UploadModal({
26
27
  const [files, setFiles] = useState<FileItem[]>([]);
27
28
  const [uploading, setUploading] = useState(false);
28
29
 
30
+ // Course structure — on-demand, collapsible
31
+ const [courseStructure, setCourseStructure] = useState<CourseStructure | null>(null);
32
+ const [classMappings, setClassMappings] = useState<CourseClassMappings>({});
33
+ const [parsingStructure, setParsingStructure] = useState(false);
34
+ const [structurePanelOpen, setStructurePanelOpen] = useState(false);
35
+ const [parseFailed, setParseFailed] = useState(false);
36
+
29
37
  // LMS Thin Pack options
30
- const [createThinPack, setCreateThinPack] = useState(true); // Default ON
38
+ const [createThinPack, setCreateThinPack] = useState(true);
31
39
  const [thinPackName, setThinPackName] = useState('');
32
40
 
33
41
  // Shared Link options
34
- const [createSharedLink, setCreateSharedLink] = useState(false); // Default OFF
42
+ const [createSharedLink, setCreateSharedLink] = useState(false);
35
43
  const [sharedLinkName, setSharedLinkName] = useState('');
36
44
 
37
- // Generate slug from name: replace spaces with underscores
45
+ // Skin option
46
+ const [skinName, setSkinName] = useState('');
47
+
48
+ // Wrap & Download mode
49
+ const [wrapOnly, setWrapOnly] = useState(false);
50
+
38
51
  function generateSlug(name: string): string {
39
52
  return name.trim().replace(/\s+/g, '_');
40
53
  }
@@ -45,10 +58,54 @@ export default function UploadModal({
45
58
  status: 'pending',
46
59
  }));
47
60
  setFiles((prev) => [...prev, ...fileItems]);
61
+
62
+ // Reset structure state when files change
63
+ setCourseStructure(null);
64
+ setClassMappings({});
65
+ setStructurePanelOpen(false);
66
+ setParseFailed(false);
48
67
  }
49
68
 
50
69
  function handleRemoveFile(index: number) {
51
- setFiles((prev) => prev.filter((_, i) => i !== index));
70
+ setFiles((prev) => {
71
+ const next = prev.filter((_, i) => i !== index);
72
+ if (next.length !== 1) {
73
+ setCourseStructure(null);
74
+ setClassMappings({});
75
+ setStructurePanelOpen(false);
76
+ setParseFailed(false);
77
+ }
78
+ return next;
79
+ });
80
+ }
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);
52
109
  }
53
110
 
54
111
  function formatSize(bytes: number): string {
@@ -63,65 +120,91 @@ export default function UploadModal({
63
120
 
64
121
  setUploading(true);
65
122
 
123
+ const hasClassAssignments =
124
+ classMappings.course ||
125
+ Object.keys(classMappings.lessons || {}).length > 0 ||
126
+ Object.keys(classMappings.blocks || {}).length > 0;
127
+
66
128
  for (let i = 0; i < files.length; i++) {
67
129
  if (files[i].status !== 'pending') continue;
68
130
 
69
- // Mark as uploading
70
131
  setFiles((prev) =>
71
132
  prev.map((f, idx) => (idx === i ? { ...f, status: 'uploading' } : f))
72
133
  );
73
134
 
74
135
  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
- }
136
+ if (wrapOnly) {
137
+ const wrapOptions: { skin?: string; classMappings?: CourseClassMappings } = {};
138
+ if (skinName.trim()) wrapOptions.skin = skinName.trim();
139
+ if (hasClassAssignments) wrapOptions.classMappings = classMappings;
93
140
 
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
- }
141
+ await api.wrapAndDownload(
142
+ files[i].file,
143
+ Object.keys(wrapOptions).length > 0 ? wrapOptions : undefined
144
+ );
108
145
 
109
146
  setFiles((prev) =>
110
147
  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
148
+ idx === i ? { ...f, status: 'success' } : f
119
149
  )
120
150
  );
121
-
122
- onUploadComplete(result.documentId);
123
151
  } else {
124
- throw new Error(result.errors?.join(', ') || 'Upload failed');
152
+ const uploadOptions: { title?: string; skin?: string; classMappings?: CourseClassMappings } = {};
153
+ if (skinName.trim()) uploadOptions.skin = skinName.trim();
154
+ if (hasClassAssignments) uploadOptions.classMappings = classMappings;
155
+
156
+ const result = await api.uploadCourse(
157
+ files[i].file,
158
+ folderId,
159
+ Object.keys(uploadOptions).length > 0 ? uploadOptions : undefined
160
+ );
161
+
162
+ if (result.success && result.documentId) {
163
+ if (createThinPack) {
164
+ const name = thinPackName.trim() || `${result.documentName} - LMS Thin Pack`;
165
+ try {
166
+ await api.createSharedLink(result.documentId, {
167
+ name,
168
+ token: generateSlug(name),
169
+ isPublic: true,
170
+ isForThinPackage: true,
171
+ });
172
+ } catch (err) {
173
+ console.error('Failed to create thin pack:', err);
174
+ }
175
+ }
176
+
177
+ if (createSharedLink) {
178
+ const name = sharedLinkName.trim() || `${result.documentName} - Private`;
179
+ try {
180
+ await api.createSharedLink(result.documentId, {
181
+ name,
182
+ token: generateSlug(name),
183
+ isPublic: false,
184
+ isForThinPackage: false,
185
+ });
186
+ } catch (err) {
187
+ console.error('Failed to create shared link:', err);
188
+ }
189
+ }
190
+
191
+ setFiles((prev) =>
192
+ prev.map((f, idx) =>
193
+ idx === i
194
+ ? {
195
+ ...f,
196
+ status: 'success',
197
+ documentId: result.documentId ?? undefined,
198
+ documentName: result.documentName,
199
+ }
200
+ : f
201
+ )
202
+ );
203
+
204
+ onUploadComplete(result.documentId);
205
+ } else {
206
+ throw new Error(result.errors?.join(', ') || 'Upload failed');
207
+ }
125
208
  }
126
209
  } catch (err) {
127
210
  setFiles((prev) =>
@@ -130,7 +213,7 @@ export default function UploadModal({
130
213
  ? {
131
214
  ...f,
132
215
  status: 'error',
133
- error: err instanceof Error ? err.message : 'Upload failed',
216
+ error: err instanceof Error ? err.message : wrapOnly ? 'Wrap failed' : 'Upload failed',
134
217
  }
135
218
  : f
136
219
  )
@@ -143,8 +226,8 @@ export default function UploadModal({
143
226
 
144
227
  const pendingCount = files.filter((f) => f.status === 'pending').length;
145
228
  const hasFiles = files.length > 0;
229
+ const isSingleFile = files.length === 1;
146
230
 
147
- // Preview slugs
148
231
  const thinPackSlugPreview = thinPackName.trim()
149
232
  ? generateSlug(thinPackName)
150
233
  : '(will use document name)';
@@ -152,11 +235,28 @@ export default function UploadModal({
152
235
  ? generateSlug(sharedLinkName)
153
236
  : '(will use document name)';
154
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
+
155
253
  return (
156
254
  <div className="modal-overlay" onClick={onClose}>
157
255
  <div className="modal upload-modal" onClick={(e) => e.stopPropagation()}>
158
256
  <div className="modal-header">
159
- <h3 className="modal-title">Upload to: {folderName}</h3>
257
+ <h3 className="modal-title">
258
+ {wrapOnly ? 'Wrap & Download' : `Upload to: ${folderName}`}
259
+ </h3>
160
260
  <button className="modal-close" onClick={onClose}>
161
261
  x
162
262
  </button>
@@ -178,24 +278,21 @@ export default function UploadModal({
178
278
  </div>
179
279
  <div className="upload-file-status">
180
280
  {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
- </>
281
+ <button
282
+ className="btn btn-sm btn-secondary"
283
+ onClick={() => handleRemoveFile(index)}
284
+ >
285
+ Remove
286
+ </button>
190
287
  )}
191
288
  {item.status === 'uploading' && (
192
289
  <>
193
290
  <div className="spinner" />
194
- <span className="status-uploading">Uploading...</span>
291
+ <span className="status-uploading">{wrapOnly ? 'Wrapping...' : 'Uploading...'}</span>
195
292
  </>
196
293
  )}
197
294
  {item.status === 'success' && (
198
- <span className="status-success">Uploaded</span>
295
+ <span className="status-success">{wrapOnly ? 'Downloaded' : 'Uploaded'}</span>
199
296
  )}
200
297
  {item.status === 'error' && (
201
298
  <span className="status-error" title={item.error}>
@@ -208,60 +305,146 @@ export default function UploadModal({
208
305
  </div>
209
306
  )}
210
307
 
211
- {/* Link Options */}
212
- <div className="upload-link-options">
213
- <h4>After upload, create:</h4>
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>
214
327
 
215
- {/* LMS Thin Pack */}
328
+ {structurePanelOpen && courseStructure && (
329
+ <div className="structure-panel">
330
+ <CourseStructureStep
331
+ structure={courseStructure}
332
+ classMappings={classMappings}
333
+ onChange={setClassMappings}
334
+ />
335
+ </div>
336
+ )}
337
+ </div>
338
+ )}
339
+
340
+ {/* Options */}
341
+ <div className="upload-link-options">
342
+ {/* Wrap & Download toggle */}
216
343
  <div className="upload-link-option">
217
344
  <label className="upload-link-toggle">
218
345
  <input
219
346
  type="checkbox"
220
- checked={createThinPack}
221
- onChange={(e) => setCreateThinPack(e.target.checked)}
347
+ checked={wrapOnly}
348
+ onChange={(e) => setWrapOnly(e.target.checked)}
222
349
  disabled={uploading}
223
350
  />
224
- <span>LMS Thin Pack (public)</span>
351
+ <span>Wrap & Download only (skip CDS upload)</span>
225
352
  </label>
226
- {createThinPack && (
227
- <div className="upload-link-name-input">
228
- <input
229
- type="text"
230
- placeholder="Link name (uses document name if empty)"
231
- value={thinPackName}
232
- onChange={(e) => setThinPackName(e.target.value)}
233
- disabled={uploading}
234
- />
235
- <span className="slug-preview">Slug: {thinPackSlugPreview}</span>
236
- </div>
353
+ {wrapOnly && (
354
+ <span className="slug-preview">Wraps with PA-Patcher and downloads the ZIP directly</span>
237
355
  )}
238
356
  </div>
239
357
 
240
- {/* Shared Link */}
241
- <div className="upload-link-option">
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">
375
+ <input
376
+ type="text"
377
+ placeholder="Link name (uses document name if empty)"
378
+ value={thinPackName}
379
+ onChange={(e) => setThinPackName(e.target.value)}
380
+ disabled={uploading}
381
+ />
382
+ <span className="slug-preview">Slug: {thinPackSlugPreview}</span>
383
+ </div>
384
+ )}
385
+ </div>
386
+
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">
399
+ <input
400
+ type="text"
401
+ placeholder="Link name (uses document name if empty)"
402
+ value={sharedLinkName}
403
+ onChange={(e) => setSharedLinkName(e.target.value)}
404
+ disabled={uploading}
405
+ />
406
+ <span className="slug-preview">Slug: {sharedLinkSlugPreview}</span>
407
+ </div>
408
+ )}
409
+ </div>
410
+ </>
411
+ )}
412
+
413
+ {/* Skin */}
414
+ <div className="upload-link-option" style={{ marginTop: '12px' }}>
242
415
  <label className="upload-link-toggle">
416
+ <span>Skin (optional)</span>
417
+ </label>
418
+ <div className="upload-link-name-input">
243
419
  <input
244
- type="checkbox"
245
- checked={createSharedLink}
246
- onChange={(e) => setCreateSharedLink(e.target.checked)}
420
+ type="text"
421
+ placeholder="e.g. marketing"
422
+ value={skinName}
423
+ onChange={(e) => setSkinName(e.target.value)}
247
424
  disabled={uploading}
248
425
  />
249
- <span>Shared Link (private/SSO)</span>
250
- </label>
251
- {createSharedLink && (
252
- <div className="upload-link-name-input">
253
- <input
254
- type="text"
255
- placeholder="Link name (uses document name if empty)"
256
- value={sharedLinkName}
257
- onChange={(e) => setSharedLinkName(e.target.value)}
258
- disabled={uploading}
259
- />
260
- <span className="slug-preview">Slug: {sharedLinkSlugPreview}</span>
261
- </div>
262
- )}
426
+ <span className="slug-preview">CSS class for custom skin styling</span>
427
+ </div>
263
428
  </div>
264
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(', ')}
446
+ </div>
447
+ )}
265
448
  </div>
266
449
 
267
450
  <div className="modal-footer">
@@ -276,8 +459,10 @@ export default function UploadModal({
276
459
  {uploading ? (
277
460
  <>
278
461
  <div className="spinner" />
279
- Uploading...
462
+ {wrapOnly ? 'Wrapping...' : 'Uploading...'}
280
463
  </>
464
+ ) : wrapOnly ? (
465
+ `Wrap & Download ${pendingCount} file${pendingCount !== 1 ? 's' : ''}`
281
466
  ) : (
282
467
  `Upload ${pendingCount} file${pendingCount !== 1 ? 's' : ''}`
283
468
  )}