Skip to content

Commit 216856b

Browse files
author
Rajat
committed
scorm lessons
1 parent 17e6974 commit 216856b

32 files changed

Lines changed: 1758 additions & 68 deletions

File tree

apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
Video,
2828
HelpCircle,
2929
ChevronDown,
30+
Droplets,
3031
} from "lucide-react";
3132
import Link from "next/link";
3233
import {
@@ -52,7 +53,6 @@ import {
5253
TooltipProvider,
5354
TooltipTrigger,
5455
} from "@/components/ui/tooltip";
55-
import { Droplets } from "lucide-react";
5656
const { permissions } = UIConstants;
5757

5858
export default function ContentPage() {

apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-content-renderer.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,45 @@ export function LessonContentRenderer({
211211
)}
212212
</div>
213213
);
214+
case Constants.LessonType.SCORM:
215+
return (
216+
<div className="space-y-4">
217+
<div className="p-4 rounded-lg border bg-muted/50">
218+
{(lesson.content as any)?.mediaId ? (
219+
<div className="space-y-2">
220+
<div className="flex items-center gap-2 text-sm">
221+
<span className="font-medium">
222+
SCORM Package:
223+
</span>
224+
<span className="text-muted-foreground">
225+
{(lesson.content as any)?.title ||
226+
"Uploaded"}
227+
</span>
228+
</div>
229+
<div className="text-xs text-muted-foreground">
230+
Version:{" "}
231+
{(lesson.content as any)?.version || "1.2"}
232+
{(lesson.content as any)?.fileCount &&
233+
` • ${(lesson.content as any)?.fileCount} files`}
234+
</div>
235+
</div>
236+
) : (
237+
<div className="text-center py-4">
238+
<p className="text-sm text-muted-foreground mb-2">
239+
Save the lesson first, then upload the SCORM
240+
package.
241+
</p>
242+
</div>
243+
)}
244+
</div>
245+
{!lesson?.lessonId && (
246+
<p className="text-xs text-muted-foreground flex items-center gap-2">
247+
<Info className="w-4 h-4" />
248+
Save the lesson to enable SCORM package upload
249+
</p>
250+
)}
251+
</div>
252+
);
214253
default:
215254
return null;
216255
}

apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
HelpCircle,
1313
File,
1414
Tv,
15+
Package,
1516
} from "lucide-react";
1617
import { Button } from "@/components/ui/button";
1718
import { Input } from "@/components/ui/input";
@@ -55,6 +56,7 @@ import { isTextEditorNonEmpty, truncate } from "@ui-lib/utils";
5556
import { Separator } from "@components/ui/separator";
5657
import { emptyDoc as TextEditorEmptyDoc } from "@courselit/text-editor";
5758
import { LessonSkeleton } from "./skeleton";
59+
import { ScormLessonUpload } from "./scorm-lesson-upload";
5860

5961
const { permissions } = UIConstants;
6062

@@ -66,6 +68,7 @@ const lessonTypes = [
6668
{ value: Constants.LessonType.FILE, label: "File", icon: File },
6769
{ value: Constants.LessonType.EMBED, label: "Embed", icon: Tv },
6870
{ value: Constants.LessonType.QUIZ, label: "Quiz", icon: HelpCircle },
71+
{ value: Constants.LessonType.SCORM, label: "SCORM", icon: Package },
6972
] as const;
7073

7174
type LessonError = Partial<Record<keyof Lesson, string>>;
@@ -640,6 +643,23 @@ export default function LessonPage() {
640643
</div>
641644
</>
642645
)}
646+
{lesson.type === Constants.LessonType.SCORM &&
647+
lesson.lessonId && (
648+
<>
649+
<Separator />
650+
<ScormLessonUpload
651+
lessonId={lesson.lessonId}
652+
content={lesson.content as any}
653+
onUploadComplete={(newContent) => {
654+
setLesson({
655+
...lesson,
656+
content: newContent,
657+
});
658+
setContent(newContent);
659+
}}
660+
/>
661+
</>
662+
)}
643663
</>
644664
)}
645665
</DashboardContent>
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"use client";
2+
3+
import { useState, useContext } from "react";
4+
import { Button } from "@/components/ui/button";
5+
import { Label } from "@/components/ui/label";
6+
import {
7+
Upload,
8+
Package,
9+
CheckCircle,
10+
Loader2,
11+
FileWarning,
12+
} from "lucide-react";
13+
import { useToast, useMediaLit } from "@courselit/components-library";
14+
import { AddressContext } from "@components/contexts";
15+
import { Progress as ShadProgress } from "@/components/ui/progress";
16+
import { ScormContent } from "@courselit/common-models";
17+
import constants from "@config/constants";
18+
19+
interface ScormLessonUploadProps {
20+
lessonId: string;
21+
content?: ScormContent;
22+
onUploadComplete: (content: ScormContent) => void;
23+
}
24+
25+
export function ScormLessonUpload({
26+
lessonId,
27+
content,
28+
onUploadComplete,
29+
}: ScormLessonUploadProps) {
30+
const address = useContext(AddressContext);
31+
const { toast } = useToast();
32+
const [uploading, setUploading] = useState(false);
33+
const [processing, setProcessing] = useState(false);
34+
const [error, setError] = useState<string | null>(null);
35+
36+
const { uploadFile, isUploading, uploadProgress } = useMediaLit({
37+
signatureEndpoint: `${address.backend}/api/media/presigned`,
38+
access: "private",
39+
onUploadComplete: async (response) => {
40+
setUploading(false);
41+
setProcessing(true);
42+
43+
try {
44+
const result = await fetch(
45+
`${address.backend}/api/lessons/${lessonId}/scorm/upload`,
46+
{
47+
method: "POST",
48+
headers: { "Content-Type": "application/json" },
49+
body: JSON.stringify({ mediaId: response.mediaId }),
50+
},
51+
);
52+
53+
const data = await result.json();
54+
55+
if (!result.ok) {
56+
throw new Error(
57+
data.message || "Failed to process SCORM package",
58+
);
59+
}
60+
61+
toast({
62+
title: "Success",
63+
description: "SCORM package uploaded successfully",
64+
});
65+
66+
onUploadComplete({
67+
mediaId: response.mediaId,
68+
launchUrl: data.packageInfo.entryPoint,
69+
version: data.packageInfo.version,
70+
title: data.packageInfo.title,
71+
scoCount: data.packageInfo.scoCount,
72+
fileCount: data.packageInfo.fileCount,
73+
});
74+
75+
setError(null);
76+
} catch (err: any) {
77+
setError(err.message);
78+
toast({
79+
title: "Error",
80+
description: err.message,
81+
variant: "destructive",
82+
});
83+
} finally {
84+
setProcessing(false);
85+
}
86+
},
87+
onUploadError: (err) => {
88+
setUploading(false);
89+
setError(err.message || "Upload failed");
90+
toast({
91+
title: "Upload Failed",
92+
description: err.message,
93+
variant: "destructive",
94+
});
95+
},
96+
});
97+
98+
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
99+
const file = e.target.files?.[0];
100+
if (!file) return;
101+
102+
if (!file.name.endsWith(".zip")) {
103+
setError("Please select a ZIP file");
104+
return;
105+
}
106+
107+
if (file.size > constants.scormPackageSizeLimit) {
108+
setError(
109+
`File size must be less than ${constants.scormPackageSizeLimit / 1024 / 1024}MB`,
110+
);
111+
return;
112+
}
113+
114+
setError(null);
115+
setUploading(true);
116+
await uploadFile(file);
117+
};
118+
119+
const hasPackage = content?.mediaId;
120+
const showProgress = uploading || isUploading;
121+
122+
return (
123+
<div className="space-y-4">
124+
<Label className="font-semibold">SCORM Package</Label>
125+
126+
{error && (
127+
<div className="flex items-center gap-2 p-3 rounded-md bg-destructive/10 text-destructive text-sm">
128+
<FileWarning className="h-4 w-4" />
129+
{error}
130+
</div>
131+
)}
132+
133+
{hasPackage && (
134+
<div className="p-4 rounded-lg border bg-muted/50">
135+
<div className="flex items-start justify-between">
136+
<div className="flex items-center gap-3">
137+
<div className="p-2 rounded-md bg-primary/10">
138+
<Package className="h-5 w-5 text-primary" />
139+
</div>
140+
<div>
141+
<p className="font-medium text-sm">
142+
{content?.title || "SCORM Package"}
143+
</p>
144+
<p className="text-xs text-muted-foreground mt-1">
145+
Version {content?.version || "1.2"}
146+
{content?.fileCount &&
147+
` • ${content.fileCount} files`}
148+
</p>
149+
</div>
150+
</div>
151+
<CheckCircle className="h-5 w-5 text-green-500" />
152+
</div>
153+
</div>
154+
)}
155+
156+
{showProgress || processing ? (
157+
<div className="space-y-2">
158+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
159+
<Loader2 className="h-4 w-4 animate-spin" />
160+
{processing
161+
? "Processing SCORM package..."
162+
: "Uploading..."}
163+
</div>
164+
{showProgress && (
165+
<ShadProgress value={uploadProgress} className="h-2" />
166+
)}
167+
</div>
168+
) : (
169+
<div>
170+
<input
171+
type="file"
172+
accept=".zip"
173+
onChange={handleFileSelect}
174+
className="hidden"
175+
id={`scorm-upload-${lessonId}`}
176+
/>
177+
<label htmlFor={`scorm-upload-${lessonId}`}>
178+
<Button
179+
variant={hasPackage ? "outline" : "default"}
180+
className="cursor-pointer"
181+
asChild
182+
>
183+
<span>
184+
<Upload className="mr-2 h-4 w-4" />
185+
{hasPackage
186+
? "Replace Package"
187+
: "Upload SCORM Package"}
188+
</span>
189+
</Button>
190+
</label>
191+
<p className="text-xs text-muted-foreground mt-2">
192+
Upload a SCORM 1.2 or 2004 package (ZIP file, max 500MB)
193+
</p>
194+
</div>
195+
)}
196+
</div>
197+
);
198+
}

apps/web/app/(with-contexts)/dashboard/(sidebar)/products/page.tsx

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -217,26 +217,27 @@ export default function Page() {
217217
</Link>
218218
</div>
219219
</div>
220-
{totalPages > 0 && (
221-
<div className="flex flex-col sm:flex-row justify-between gap-4">
222-
<Select value={filter} onValueChange={handleFilterChange}>
223-
<SelectTrigger className="w-full sm:w-[180px]">
224-
<SelectValue placeholder="Filter by status" />
225-
</SelectTrigger>
226-
<SelectContent>
227-
<SelectItem value="all">All</SelectItem>
228-
{[
229-
Constants.CourseType.COURSE,
230-
Constants.CourseType.DOWNLOAD,
231-
].map((status) => (
232-
<SelectItem value={status} key={status}>
233-
{capitalize(status)}
234-
</SelectItem>
235-
))}
236-
</SelectContent>
237-
</Select>
238-
</div>
239-
)}
220+
<div className="flex flex-col sm:flex-row justify-between gap-4">
221+
<Select value={filter} onValueChange={handleFilterChange}>
222+
<SelectTrigger className="w-full sm:w-[180px]">
223+
<SelectValue placeholder="Filter by status" />
224+
</SelectTrigger>
225+
<SelectContent>
226+
<SelectItem value="all">All</SelectItem>
227+
{[
228+
Constants.CourseType.COURSE,
229+
Constants.CourseType.DOWNLOAD,
230+
].map((status) => (
231+
<SelectItem value={status} key={status}>
232+
{capitalize(status)}
233+
</SelectItem>
234+
))}
235+
</SelectContent>
236+
</Select>
237+
</div>
238+
{totalPages > 0 ? (
239+
<div className="mt-6" /> // spacer
240+
) : null}
240241
{loading ? (
241242
<SkeletonGrid />
242243
) : (

0 commit comments

Comments
 (0)