@package-uploader/ui 1.1.2 → 1.1.4

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,4 +1,4 @@
1
- import { useState } from 'react';
1
+ import { useState, useRef, useEffect, useCallback } from 'react';
2
2
  import { api, type CourseClassMappings } from '../api/client';
3
3
  import DropZone from './DropZone';
4
4
  import CourseStructureStep, { type CourseStructure } from './CourseStructureStep';
@@ -31,9 +31,17 @@ export default function UploadModal({
31
31
  const [courseStructure, setCourseStructure] = useState<CourseStructure | null>(null);
32
32
  const [classMappings, setClassMappings] = useState<CourseClassMappings>({});
33
33
  const [parsingStructure, setParsingStructure] = useState(false);
34
- const [structurePanelOpen, setStructurePanelOpen] = useState(false);
35
34
  const [parseFailed, setParseFailed] = useState(false);
36
35
 
36
+ // Preview state
37
+ const [previewToken, setPreviewToken] = useState<string | null>(null);
38
+ const [previewUrl, setPreviewUrl] = useState<string | null>(null);
39
+ const [previewLoading, setPreviewLoading] = useState(false);
40
+ const iframeRef = useRef<HTMLIFrameElement>(null);
41
+
42
+ // Options panel (collapsed by default in preview mode)
43
+ const [optionsOpen, setOptionsOpen] = useState(false);
44
+
37
45
  // LMS Thin Pack options
38
46
  const [createThinPack, setCreateThinPack] = useState(true);
39
47
  const [thinPackName, setThinPackName] = useState('');
@@ -48,6 +56,35 @@ export default function UploadModal({
48
56
  // Wrap & Download mode
49
57
  const [wrapOnly, setWrapOnly] = useState(false);
50
58
 
59
+ const isPreviewMode = !!previewToken && !!previewUrl;
60
+
61
+ // Sync classMappings to iframe via postMessage
62
+ useEffect(() => {
63
+ if (!isPreviewMode || !iframeRef.current?.contentWindow) return;
64
+ iframeRef.current.contentWindow.postMessage(
65
+ { type: 'pa-class-mappings', classMappings },
66
+ '*'
67
+ );
68
+ }, [classMappings, isPreviewMode]);
69
+
70
+ // Navigate iframe to a lesson when user clicks lesson in tree
71
+ const handleNavigateLesson = useCallback((lessonId: string) => {
72
+ if (!iframeRef.current?.contentWindow) return;
73
+ iframeRef.current.contentWindow.postMessage(
74
+ { type: 'pa-navigate', lessonId },
75
+ '*'
76
+ );
77
+ }, []);
78
+
79
+ // Cleanup preview on unmount
80
+ useEffect(() => {
81
+ return () => {
82
+ if (previewToken) {
83
+ api.stopPreview(previewToken).catch(() => {});
84
+ }
85
+ };
86
+ }, [previewToken]);
87
+
51
88
  function generateSlug(name: string): string {
52
89
  return name.trim().replace(/\s+/g, '_');
53
90
  }
@@ -59,11 +96,15 @@ export default function UploadModal({
59
96
  }));
60
97
  setFiles((prev) => [...prev, ...fileItems]);
61
98
 
62
- // Reset structure state when files change
99
+ // Reset structure + preview state when files change
63
100
  setCourseStructure(null);
64
101
  setClassMappings({});
65
- setStructurePanelOpen(false);
66
102
  setParseFailed(false);
103
+ if (previewToken) {
104
+ api.stopPreview(previewToken).catch(() => {});
105
+ setPreviewToken(null);
106
+ setPreviewUrl(null);
107
+ }
67
108
  }
68
109
 
69
110
  function handleRemoveFile(index: number) {
@@ -72,17 +113,25 @@ export default function UploadModal({
72
113
  if (next.length !== 1) {
73
114
  setCourseStructure(null);
74
115
  setClassMappings({});
75
- setStructurePanelOpen(false);
76
116
  setParseFailed(false);
117
+ if (previewToken) {
118
+ api.stopPreview(previewToken).catch(() => {});
119
+ setPreviewToken(null);
120
+ setPreviewUrl(null);
121
+ }
77
122
  }
78
123
  return next;
79
124
  });
80
125
  }
81
126
 
82
127
  async function handleCustomizeClick() {
83
- // Already parsedjust toggle
84
- if (courseStructure) {
85
- setStructurePanelOpen((prev) => !prev);
128
+ // Already in preview mode toggle back to normal
129
+ if (isPreviewMode) {
130
+ if (previewToken) {
131
+ api.stopPreview(previewToken).catch(() => {});
132
+ }
133
+ setPreviewToken(null);
134
+ setPreviewUrl(null);
86
135
  return;
87
136
  }
88
137
 
@@ -93,19 +142,53 @@ export default function UploadModal({
93
142
  if (files.length !== 1) return;
94
143
 
95
144
  setParsingStructure(true);
145
+ setPreviewLoading(true);
146
+
96
147
  try {
97
- const structure = await api.parseCourseStructure(files[0].file);
148
+ // Parse structure and start preview in parallel
149
+ const [structure, preview] = await Promise.all([
150
+ courseStructure
151
+ ? Promise.resolve(courseStructure)
152
+ : api.parseCourseStructure(files[0].file),
153
+ api.startPreview(files[0].file),
154
+ ]);
155
+
98
156
  if (structure) {
99
157
  setCourseStructure(structure);
100
- setStructurePanelOpen(true);
101
158
  setParseFailed(false);
159
+ setPreviewToken(preview.token);
160
+ setPreviewUrl(preview.url);
102
161
  } else {
103
162
  setParseFailed(true);
163
+ // Cleanup the preview if structure parse failed
164
+ api.stopPreview(preview.token).catch(() => {});
104
165
  }
105
166
  } catch {
106
167
  setParseFailed(true);
107
168
  }
169
+
108
170
  setParsingStructure(false);
171
+ setPreviewLoading(false);
172
+ }
173
+
174
+ function handleClose() {
175
+ if (previewToken) {
176
+ api.stopPreview(previewToken).catch(() => {});
177
+ }
178
+ onClose();
179
+ }
180
+
181
+ // When iframe loads, send current classMappings
182
+ function handleIframeLoad() {
183
+ // Small delay to let bridge script initialize
184
+ setTimeout(() => {
185
+ if (iframeRef.current?.contentWindow) {
186
+ iframeRef.current.contentWindow.postMessage(
187
+ { type: 'pa-class-mappings', classMappings },
188
+ '*'
189
+ );
190
+ }
191
+ }, 500);
109
192
  }
110
193
 
111
194
  function formatSize(bytes: number): string {
@@ -249,18 +332,169 @@ export default function UploadModal({
249
332
  ? 'Analyzing...'
250
333
  : parseFailed
251
334
  ? 'Not a Rise course'
252
- : courseStructure
253
- ? `Customize Classes (${courseStructure.lessons.length} lessons)`
335
+ : isPreviewMode
336
+ ? 'Close Preview'
254
337
  : 'Customize Classes';
255
338
 
339
+ // Build the preview URL — needs the API base path prepended
340
+ const fullPreviewUrl = previewUrl
341
+ ? (() => {
342
+ // Detect API base same way client.ts does
343
+ const scripts = document.querySelectorAll('script[src*="assets/index"]');
344
+ if (scripts.length > 0) {
345
+ const src = (scripts[0] as HTMLScriptElement).src;
346
+ const url = new URL(src);
347
+ const assetsIndex = url.pathname.indexOf('/assets/');
348
+ if (assetsIndex > 0) {
349
+ return url.pathname.substring(0, assetsIndex) + '/' + previewUrl.replace(/^\//, '');
350
+ }
351
+ }
352
+ return previewUrl;
353
+ })()
354
+ : null;
355
+
356
+ // --- Preview mode: full-screen split layout ---
357
+ if (isPreviewMode && courseStructure) {
358
+ return (
359
+ <div className="modal-overlay" onClick={handleClose}>
360
+ <div className="modal upload-modal preview-mode" onClick={(e) => e.stopPropagation()}>
361
+ <div className="modal-header">
362
+ <h3 className="modal-title">
363
+ Customize Classes — {courseStructure.courseTitle}
364
+ </h3>
365
+ {assignmentCount > 0 && (
366
+ <span className="course-structure-badge">
367
+ {assignmentCount} class{assignmentCount !== 1 ? 'es' : ''} assigned
368
+ </span>
369
+ )}
370
+ <button className="modal-close" onClick={handleClose}>
371
+ x
372
+ </button>
373
+ </div>
374
+
375
+ <div className="preview-split">
376
+ {/* Left: Course structure controls */}
377
+ <div className="preview-split-left">
378
+ <CourseStructureStep
379
+ structure={courseStructure}
380
+ classMappings={classMappings}
381
+ onChange={setClassMappings}
382
+ onNavigateLesson={handleNavigateLesson}
383
+ />
384
+ </div>
385
+
386
+ {/* Right: Live SCORM preview */}
387
+ <div className="preview-split-right">
388
+ {previewLoading ? (
389
+ <div className="preview-loading">
390
+ <div className="spinner" />
391
+ <span>Loading preview...</span>
392
+ </div>
393
+ ) : (
394
+ <iframe
395
+ ref={iframeRef}
396
+ src={fullPreviewUrl || ''}
397
+ className="preview-iframe"
398
+ title="Course Preview"
399
+ onLoad={handleIframeLoad}
400
+ sandbox="allow-scripts allow-same-origin allow-popups"
401
+ />
402
+ )}
403
+ </div>
404
+ </div>
405
+
406
+ <div className="modal-footer preview-footer">
407
+ <button className="btn btn-secondary" onClick={handleClose} disabled={uploading}>
408
+ Cancel
409
+ </button>
410
+
411
+ <button
412
+ className={`btn btn-sm btn-secondary preview-options-toggle${optionsOpen ? ' open' : ''}`}
413
+ onClick={() => setOptionsOpen((prev) => !prev)}
414
+ >
415
+ Options {optionsOpen ? '\u25B2' : '\u25BC'}
416
+ </button>
417
+
418
+ {optionsOpen && (
419
+ <div className="preview-options-panel">
420
+ <label className="upload-link-toggle">
421
+ <input
422
+ type="checkbox"
423
+ checked={wrapOnly}
424
+ onChange={(e) => setWrapOnly(e.target.checked)}
425
+ disabled={uploading}
426
+ />
427
+ <span>Wrap & Download only</span>
428
+ </label>
429
+
430
+ {!wrapOnly && (
431
+ <>
432
+ <label className="upload-link-toggle">
433
+ <input
434
+ type="checkbox"
435
+ checked={createThinPack}
436
+ onChange={(e) => setCreateThinPack(e.target.checked)}
437
+ disabled={uploading}
438
+ />
439
+ <span>LMS Thin Pack</span>
440
+ </label>
441
+ <label className="upload-link-toggle">
442
+ <input
443
+ type="checkbox"
444
+ checked={createSharedLink}
445
+ onChange={(e) => setCreateSharedLink(e.target.checked)}
446
+ disabled={uploading}
447
+ />
448
+ <span>Shared Link</span>
449
+ </label>
450
+ </>
451
+ )}
452
+
453
+ <div className="preview-option-skin">
454
+ <span className="preview-option-label">Skin:</span>
455
+ <input
456
+ type="text"
457
+ placeholder="e.g. marketing"
458
+ value={skinName}
459
+ onChange={(e) => setSkinName(e.target.value)}
460
+ disabled={uploading}
461
+ className="preview-option-input"
462
+ />
463
+ </div>
464
+ </div>
465
+ )}
466
+
467
+ <button
468
+ className="btn btn-primary"
469
+ onClick={handleUpload}
470
+ disabled={uploading || pendingCount === 0}
471
+ >
472
+ {uploading ? (
473
+ <>
474
+ <div className="spinner" />
475
+ {wrapOnly ? 'Wrapping...' : 'Uploading...'}
476
+ </>
477
+ ) : wrapOnly ? (
478
+ 'Wrap & Download'
479
+ ) : (
480
+ 'Upload'
481
+ )}
482
+ </button>
483
+ </div>
484
+ </div>
485
+ </div>
486
+ );
487
+ }
488
+
489
+ // --- Normal mode: standard upload modal ---
256
490
  return (
257
- <div className="modal-overlay" onClick={onClose}>
491
+ <div className="modal-overlay" onClick={handleClose}>
258
492
  <div className="modal upload-modal" onClick={(e) => e.stopPropagation()}>
259
493
  <div className="modal-header">
260
494
  <h3 className="modal-title">
261
495
  {wrapOnly ? 'Wrap & Download' : `Upload to: ${folderName}`}
262
496
  </h3>
263
- <button className="modal-close" onClick={onClose}>
497
+ <button className="modal-close" onClick={handleClose}>
264
498
  x
265
499
  </button>
266
500
  </div>
@@ -308,35 +542,20 @@ export default function UploadModal({
308
542
  </div>
309
543
  )}
310
544
 
311
- {/* Customize Classes on-demand toggle */}
545
+ {/* Customize Classes button */}
312
546
  {isSingleFile && !uploading && (
313
547
  <div className="structure-section">
314
548
  <button
315
- className={`structure-toggle-btn${structurePanelOpen ? ' open' : ''}${parseFailed ? ' disabled' : ''}`}
549
+ className={`structure-toggle-btn${parseFailed ? ' disabled' : ''}`}
316
550
  onClick={handleCustomizeClick}
317
551
  disabled={parsingStructure || parseFailed}
318
552
  >
319
553
  {parsingStructure && <div className="spinner" />}
320
554
  <span>{customizeLabel}</span>
321
- {!parsingStructure && !parseFailed && (
322
- <span className="structure-toggle-arrow">
323
- {structurePanelOpen ? '\u25B2' : '\u25BC'}
324
- </span>
325
- )}
326
- {assignmentCount > 0 && !structurePanelOpen && (
555
+ {assignmentCount > 0 && (
327
556
  <span className="course-structure-badge">{assignmentCount} assigned</span>
328
557
  )}
329
558
  </button>
330
-
331
- {structurePanelOpen && courseStructure && (
332
- <div className="structure-panel">
333
- <CourseStructureStep
334
- structure={courseStructure}
335
- classMappings={classMappings}
336
- onChange={setClassMappings}
337
- />
338
- </div>
339
- )}
340
559
  </div>
341
560
  )}
342
561
 
@@ -432,7 +651,7 @@ export default function UploadModal({
432
651
  </div>
433
652
 
434
653
  {/* Class assignments summary */}
435
- {assignmentCount > 0 && !structurePanelOpen && (
654
+ {assignmentCount > 0 && (
436
655
  <div className="structure-summary-badge">
437
656
  Class assignments:{' '}
438
657
  {[
@@ -454,7 +673,7 @@ export default function UploadModal({
454
673
  </div>
455
674
 
456
675
  <div className="modal-footer">
457
- <button className="btn btn-secondary" onClick={onClose} disabled={uploading}>
676
+ <button className="btn btn-secondary" onClick={handleClose} disabled={uploading}>
458
677
  Cancel
459
678
  </button>
460
679
  <button