@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.
package/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>Package Uploader</title>
8
- <script type="module" crossorigin src="/assets/index-Cur4iArP.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-8HHKr2PX.css">
8
+ <script type="module" crossorigin src="/assets/index-Ca5beg0c.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-C19M6liw.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@package-uploader/ui",
3
- "version": "1.0.14",
3
+ "version": "1.1.1",
4
4
  "description": "React UI for uploading and browsing courses on LMS platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/api/client.ts CHANGED
@@ -1,6 +1,33 @@
1
1
  // API client for communicating with the backend
2
- // Use VITE_API_URL env var for separate backend domain, or default to same-origin /api
3
- const API_BASE = import.meta.env.VITE_API_URL || '/api';
2
+ // Detects API URL at runtime based on where the app is mounted
3
+
4
+ function detectApiBase(): string {
5
+ // If explicitly set via env var at build time, use that
6
+ if (import.meta.env.VITE_API_URL) {
7
+ return import.meta.env.VITE_API_URL;
8
+ }
9
+
10
+ // Otherwise detect from the script location or current path
11
+ const scripts = document.querySelectorAll('script[src*="assets/index"]');
12
+ if (scripts.length > 0) {
13
+ const src = (scripts[0] as HTMLScriptElement).src;
14
+ const url = new URL(src);
15
+ const assetsIndex = url.pathname.indexOf('/assets/');
16
+ if (assetsIndex > 0) {
17
+ return url.pathname.substring(0, assetsIndex) + '/api';
18
+ }
19
+ }
20
+
21
+ // Fallback: detect from current path
22
+ const path = window.location.pathname;
23
+ if (path.endsWith('/')) {
24
+ return path.slice(0, -1) + '/api';
25
+ }
26
+ const lastSlash = path.lastIndexOf('/');
27
+ return (lastSlash > 0 ? path.substring(0, lastSlash) : '') + '/api';
28
+ }
29
+
30
+ const API_BASE = detectApiBase();
4
31
 
5
32
  export interface Folder {
6
33
  id: number;
@@ -93,6 +120,12 @@ export interface AppConfig {
93
120
  user: UserProfile | null;
94
121
  }
95
122
 
123
+ export interface CourseClassMappings {
124
+ course?: string;
125
+ lessons?: Record<string, string>;
126
+ blocks?: Record<string, string>;
127
+ }
128
+
96
129
  async function request<T>(
97
130
  method: string,
98
131
  path: string,
@@ -174,15 +207,72 @@ export const api = {
174
207
  getDocumentVersions: (documentId: number) =>
175
208
  request<DocumentVersion[]>('GET', `/documents/${documentId}/versions`),
176
209
 
210
+ // Parse course structure (for customization step)
211
+ parseCourseStructure: async (file: File) => {
212
+ const formData = new FormData();
213
+ formData.append('file', file);
214
+ try {
215
+ const result = await request<any>('POST', '/parse-structure', formData);
216
+ if (result.error || result.isRise === false) return null;
217
+ return result;
218
+ } catch {
219
+ return null;
220
+ }
221
+ },
222
+
177
223
  // Upload
178
- uploadCourse: (file: File, folderId: number, options?: { title?: string }) => {
224
+ uploadCourse: (file: File, folderId: number, options?: { title?: string; skin?: string; classMappings?: CourseClassMappings }) => {
179
225
  const formData = new FormData();
180
226
  formData.append('file', file);
181
227
  formData.append('folderId', String(folderId));
182
228
  if (options?.title) formData.append('title', options.title);
229
+ if (options?.skin) formData.append('skin', options.skin);
230
+ if (options?.classMappings) formData.append('classMappings', JSON.stringify(options.classMappings));
183
231
  return request<UploadResult>('POST', '/upload', formData);
184
232
  },
185
233
 
234
+ // Upload with progress tracking (uses XMLHttpRequest for onprogress events)
235
+ uploadCourseWithProgress: (
236
+ file: File,
237
+ folderId: number,
238
+ options: { title?: string } | undefined,
239
+ onProgress: (percent: number) => void
240
+ ): Promise<UploadResult> => {
241
+ return new Promise((resolve, reject) => {
242
+ const xhr = new XMLHttpRequest();
243
+ const formData = new FormData();
244
+ formData.append('file', file);
245
+ formData.append('folderId', String(folderId));
246
+ if (options?.title) formData.append('title', options.title);
247
+
248
+ xhr.upload.onprogress = (event) => {
249
+ if (event.lengthComputable) {
250
+ const percent = Math.round((event.loaded / event.total) * 100);
251
+ onProgress(percent);
252
+ }
253
+ };
254
+
255
+ xhr.onload = () => {
256
+ if (xhr.status >= 200 && xhr.status < 300) {
257
+ try {
258
+ resolve(JSON.parse(xhr.responseText));
259
+ } catch {
260
+ reject(new Error('Invalid JSON response'));
261
+ }
262
+ } else {
263
+ reject(new Error(xhr.statusText || `Upload failed: ${xhr.status}`));
264
+ }
265
+ };
266
+
267
+ xhr.onerror = () => reject(new Error('Network error during upload'));
268
+ xhr.onabort = () => reject(new Error('Upload aborted'));
269
+
270
+ xhr.open('POST', `${API_BASE}/upload`);
271
+ xhr.withCredentials = true; // Send cookies for cross-origin
272
+ xhr.send(formData);
273
+ });
274
+ },
275
+
186
276
  uploadVersion: (file: File, documentId: number, note?: string) => {
187
277
  const formData = new FormData();
188
278
  formData.append('file', file);
@@ -194,4 +284,40 @@ export const api = {
194
284
  // Launch
195
285
  getLaunchUrl: (documentId: number) =>
196
286
  request<{ url: string }>('GET', `/documents/${documentId}/launch`),
287
+
288
+ // Wrap & Download (PA-Patcher wrapping without CDS upload)
289
+ wrapAndDownload: async (file: File, options?: { skin?: string; classMappings?: CourseClassMappings }): Promise<void> => {
290
+ const formData = new FormData();
291
+ formData.append('file', file);
292
+ if (options?.skin) formData.append('skin', options.skin);
293
+ if (options?.classMappings) formData.append('classMappings', JSON.stringify(options.classMappings));
294
+
295
+ const response = await fetch(`${API_BASE}/wrap`, {
296
+ method: 'POST',
297
+ body: formData,
298
+ credentials: 'include',
299
+ });
300
+
301
+ if (!response.ok) {
302
+ const error = await response.text();
303
+ throw new Error(error || `Wrap failed: ${response.status}`);
304
+ }
305
+
306
+ const blob = await response.blob();
307
+
308
+ // Extract filename from Content-Disposition header or generate one
309
+ const disposition = response.headers.get('Content-Disposition');
310
+ const filenameMatch = disposition?.match(/filename="(.+)"/);
311
+ const filename = filenameMatch?.[1] || file.name.replace(/\.zip$/i, '-wrapped.zip');
312
+
313
+ // Trigger browser download
314
+ const url = window.URL.createObjectURL(blob);
315
+ const a = document.createElement('a');
316
+ a.href = url;
317
+ a.download = filename;
318
+ document.body.appendChild(a);
319
+ a.click();
320
+ document.body.removeChild(a);
321
+ window.URL.revokeObjectURL(url);
322
+ },
197
323
  };
@@ -0,0 +1,211 @@
1
+ import { useState } from 'react';
2
+
3
+ /** Block within a lesson */
4
+ interface CourseStructureBlock {
5
+ id: string;
6
+ type: string;
7
+ family?: string;
8
+ variant?: string;
9
+ title?: string;
10
+ }
11
+
12
+ /** Lesson in the course */
13
+ interface CourseStructureLesson {
14
+ id: string;
15
+ title: string;
16
+ type: string;
17
+ blocks: CourseStructureBlock[];
18
+ }
19
+
20
+ /** Full parsed course structure */
21
+ export interface CourseStructure {
22
+ courseTitle: string;
23
+ courseId?: string;
24
+ lessons: CourseStructureLesson[];
25
+ }
26
+
27
+ /** Class assignments at course/lesson/block levels */
28
+ export interface CourseClassMappings {
29
+ course?: string;
30
+ lessons?: Record<string, string>;
31
+ blocks?: Record<string, string>;
32
+ }
33
+
34
+ interface CourseStructureStepProps {
35
+ structure: CourseStructure;
36
+ classMappings: CourseClassMappings;
37
+ onChange: (mappings: CourseClassMappings) => void;
38
+ }
39
+
40
+ export default function CourseStructureStep({
41
+ structure,
42
+ classMappings,
43
+ onChange,
44
+ }: CourseStructureStepProps) {
45
+ const [expandedLessons, setExpandedLessons] = useState<Set<string>>(new Set());
46
+
47
+ const toggleLesson = (lessonId: string) => {
48
+ setExpandedLessons((prev) => {
49
+ const next = new Set(prev);
50
+ if (next.has(lessonId)) {
51
+ next.delete(lessonId);
52
+ } else {
53
+ next.add(lessonId);
54
+ }
55
+ return next;
56
+ });
57
+ };
58
+
59
+ const setCourseClass = (value: string) => {
60
+ onChange({ ...classMappings, course: value || undefined });
61
+ };
62
+
63
+ const setLessonClass = (lessonId: string, value: string) => {
64
+ const lessons = { ...classMappings.lessons };
65
+ if (value) {
66
+ lessons[lessonId] = value;
67
+ } else {
68
+ delete lessons[lessonId];
69
+ }
70
+ onChange({ ...classMappings, lessons: Object.keys(lessons).length > 0 ? lessons : undefined });
71
+ };
72
+
73
+ const setBlockClass = (blockId: string, value: string) => {
74
+ const blocks = { ...classMappings.blocks };
75
+ if (value) {
76
+ blocks[blockId] = value;
77
+ } else {
78
+ delete blocks[blockId];
79
+ }
80
+ onChange({ ...classMappings, blocks: Object.keys(blocks).length > 0 ? blocks : undefined });
81
+ };
82
+
83
+ const clearAll = () => {
84
+ onChange({});
85
+ };
86
+
87
+ // Count total assignments
88
+ const assignmentCount =
89
+ (classMappings.course ? 1 : 0) +
90
+ Object.keys(classMappings.lessons || {}).length +
91
+ Object.keys(classMappings.blocks || {}).length;
92
+
93
+ const blockCount = structure.lessons.reduce((sum, l) => sum + l.blocks.length, 0);
94
+
95
+ const formatBlockLabel = (block: CourseStructureBlock): string => {
96
+ const parts = [block.type];
97
+ if (block.variant) parts.push(block.variant);
98
+ return parts.join(' / ');
99
+ };
100
+
101
+ return (
102
+ <div className="course-structure-step">
103
+ <div className="course-structure-header">
104
+ <div>
105
+ <h4>Course Structure</h4>
106
+ <span className="course-structure-meta">
107
+ {structure.lessons.length} lessons, {blockCount} blocks
108
+ {assignmentCount > 0 && (
109
+ <span className="course-structure-badge">{assignmentCount} class{assignmentCount !== 1 ? 'es' : ''} assigned</span>
110
+ )}
111
+ </span>
112
+ </div>
113
+ {assignmentCount > 0 && (
114
+ <button className="btn btn-sm btn-secondary" onClick={clearAll}>
115
+ Clear All
116
+ </button>
117
+ )}
118
+ </div>
119
+
120
+ {/* Course-level class */}
121
+ <div className="tree-node tree-course">
122
+ <div className="tree-node-row">
123
+ <span className="tree-node-icon">&#x1F4DA;</span>
124
+ <span className="tree-node-label">{structure.courseTitle}</span>
125
+ </div>
126
+ <input
127
+ type="text"
128
+ className="tree-class-input"
129
+ placeholder="Course-level class..."
130
+ value={classMappings.course || ''}
131
+ onChange={(e) => setCourseClass(e.target.value)}
132
+ />
133
+ </div>
134
+
135
+ {/* Lessons */}
136
+ <div className="tree-lessons">
137
+ {structure.lessons.map((lesson, lessonIdx) => {
138
+ const isExpanded = expandedLessons.has(lesson.id);
139
+ const lessonHasClass = !!classMappings.lessons?.[lesson.id];
140
+ const blocksWithClass = lesson.blocks.filter(
141
+ (b) => !!classMappings.blocks?.[b.id]
142
+ ).length;
143
+
144
+ return (
145
+ <div key={lesson.id} className="tree-lesson">
146
+ <div
147
+ className="tree-node tree-node-lesson"
148
+ onClick={() => toggleLesson(lesson.id)}
149
+ >
150
+ <div className="tree-node-row">
151
+ <span className="tree-expand">{isExpanded ? '\u25BC' : '\u25B6'}</span>
152
+ <span className="tree-node-label">
153
+ <span className="tree-lesson-num">{lessonIdx + 1}.</span>
154
+ {' '}{lesson.title}
155
+ </span>
156
+ {(lessonHasClass || blocksWithClass > 0) && !isExpanded && (
157
+ <span className="tree-node-indicator">
158
+ {lessonHasClass && blocksWithClass > 0
159
+ ? `lesson + ${blocksWithClass} block${blocksWithClass > 1 ? 's' : ''}`
160
+ : lessonHasClass
161
+ ? 'lesson'
162
+ : `${blocksWithClass} block${blocksWithClass > 1 ? 's' : ''}`}
163
+ </span>
164
+ )}
165
+ </div>
166
+ </div>
167
+
168
+ {isExpanded && (
169
+ <div className="tree-lesson-content">
170
+ <div className="tree-lesson-class">
171
+ <input
172
+ type="text"
173
+ className="tree-class-input"
174
+ placeholder="Lesson-level class..."
175
+ value={classMappings.lessons?.[lesson.id] || ''}
176
+ onChange={(e) => setLessonClass(lesson.id, e.target.value)}
177
+ onClick={(e) => e.stopPropagation()}
178
+ />
179
+ </div>
180
+
181
+ {lesson.blocks.map((block) => (
182
+ <div key={block.id} className="tree-node tree-node-block">
183
+ <div className="tree-node-row">
184
+ <span className="tree-block-pipe">|--</span>
185
+ <span className="tree-block-type">{formatBlockLabel(block)}</span>
186
+ {block.title && (
187
+ <span className="tree-block-title" title={block.title}>
188
+ {block.title.length > 40
189
+ ? block.title.substring(0, 40) + '...'
190
+ : block.title}
191
+ </span>
192
+ )}
193
+ </div>
194
+ <input
195
+ type="text"
196
+ className="tree-class-input tree-class-input-sm"
197
+ placeholder="class..."
198
+ value={classMappings.blocks?.[block.id] || ''}
199
+ onChange={(e) => setBlockClass(block.id, e.target.value)}
200
+ />
201
+ </div>
202
+ ))}
203
+ </div>
204
+ )}
205
+ </div>
206
+ );
207
+ })}
208
+ </div>
209
+ </div>
210
+ );
211
+ }