@learnpack/learnpack 5.0.68 → 5.0.70

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.
@@ -0,0 +1,202 @@
1
+ import { useEffect, useState, useRef } from "react"
2
+ import useStore, { Syllabus } from "../../utils/store"
3
+ import { Lesson, LessonItem } from "../LessonItem"
4
+ import { SVGS } from "../../assets/svgs"
5
+ import { TMessage } from "../Message"
6
+
7
+ const ContentIndexHeader = ({
8
+ messages,
9
+ syllabus,
10
+ }: {
11
+ messages: TMessage[]
12
+ syllabus: Syllabus
13
+ }) => {
14
+ return (
15
+ <div>
16
+ <h2 className="text-lg font-semibold">
17
+ {messages.filter((m) => m.type === "assistant" && m.content.length > 0)
18
+ .length === 0
19
+ ? "I've created a detailed structure for your course."
20
+ : "I've updated the structure based on your feedback."}
21
+ </h2>
22
+ <p className="text-sm text-gray-600">
23
+ {messages.filter((m) => m.type === "assistant" && m.content.length > 0)
24
+ .length === 0
25
+ ? `It includes a mix of reading, coding exercises, and quizzes. Give
26
+ it a look and let me know if it aligns with your expectations or if
27
+ there are any changes you'd like to make.`
28
+ : "Based on your input, here is the new syllabus, updates are highlighted in yellow"}
29
+ </p>
30
+ <h3 className="text-sm text-gray-600 mt-2 font-bold">
31
+ {syllabus.courseInfo.title}
32
+ </h3>
33
+ </div>
34
+ )
35
+ }
36
+
37
+ export const GenerateButton = ({
38
+ handleSubmit,
39
+ }: {
40
+ handleSubmit: () => void
41
+ }) => {
42
+ return (
43
+ <div className="flex justify-end mt-6">
44
+ <button
45
+ onClick={async () => {
46
+ handleSubmit()
47
+ }}
48
+ className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 cursor-pointer flex items-center gap-2"
49
+ >
50
+ <span>I'm ready. Create the course for me!</span>
51
+ {SVGS.rigoSoftBlue}
52
+ </button>
53
+ </div>
54
+ )
55
+ }
56
+ export const ContentIndex = ({
57
+ prevLessons,
58
+ handleSubmit,
59
+ messages,
60
+ }: {
61
+ prevLessons?: Lesson[]
62
+ handleSubmit: () => void
63
+ messages: TMessage[]
64
+ }) => {
65
+ const syllabus = useStore((state) => state.syllabus)
66
+ const setSyllabus = useStore((state) => state.setSyllabus)
67
+ const containerRef = useRef<HTMLDivElement>(null)
68
+ const [showScrollHint, setShowScrollHint] = useState(false)
69
+
70
+ const handleRemove = (lesson: Lesson) => {
71
+ setSyllabus({
72
+ ...syllabus,
73
+ lessons: syllabus.lessons.filter(
74
+ (l) => l.id !== lesson.id && l.title !== lesson.title
75
+ ),
76
+ })
77
+ }
78
+
79
+ const handleChange = (id: string, newTitle: string) => {
80
+ setSyllabus({
81
+ ...syllabus,
82
+ lessons: syllabus.lessons.map((lesson) =>
83
+ lesson.id === id ? { ...lesson, title: newTitle } : lesson
84
+ ),
85
+ })
86
+ }
87
+
88
+ const addLessonAfter = (index: number, id: string) => {
89
+ const newLesson: Lesson = {
90
+ id: (parseFloat(id) + 0.1).toFixed(1),
91
+ title: "Hello World",
92
+ type: "READ",
93
+ duration: 2,
94
+ description: "Hello World",
95
+ }
96
+ const updated = [...syllabus.lessons]
97
+ updated.splice(index + 1, 0, newLesson)
98
+
99
+ setSyllabus({
100
+ lessons: updated,
101
+ })
102
+ }
103
+
104
+ useEffect(() => {
105
+ const container = containerRef.current
106
+
107
+ const checkScroll = () => {
108
+ if (container) {
109
+ const nearBottom =
110
+ container.scrollHeight >
111
+ container.clientHeight + container.scrollTop + 100
112
+
113
+ setShowScrollHint(nearBottom)
114
+ }
115
+ }
116
+
117
+ checkScroll()
118
+ container?.addEventListener("scroll", checkScroll)
119
+ return () => container?.removeEventListener("scroll", checkScroll)
120
+ }, [syllabus.lessons])
121
+
122
+ const scrollToBottom = (target: "bottom" | "continue") => {
123
+ if (target === "continue") {
124
+ const container = containerRef.current
125
+ if (container) {
126
+ const scrollStep = container.clientHeight * 0.8
127
+ container.scrollBy({ top: scrollStep, behavior: "smooth" })
128
+ }
129
+ } else {
130
+ const container = containerRef.current
131
+ if (container) {
132
+ container.scrollTo({ top: container.scrollHeight, behavior: "smooth" })
133
+ }
134
+ }
135
+ }
136
+
137
+ return (
138
+ <div className="relative ">
139
+ <ContentIndexHeader messages={messages} syllabus={syllabus} />
140
+ <div
141
+ ref={containerRef}
142
+ className=" space-y-3 overflow-y-auto max-h-[70vh] pr-2 scrollbar-hide relative pb-5"
143
+ >
144
+ {syllabus.lessons.map((lesson, index) => (
145
+ <div key={lesson.id}>
146
+ <LessonItem
147
+ key={lesson.id + index + lesson.title}
148
+ lesson={lesson}
149
+ onChange={handleChange}
150
+ onRemove={() => handleRemove(lesson)}
151
+ isNew={Boolean(
152
+ prevLessons &&
153
+ prevLessons.length > 0 &&
154
+ !prevLessons.some(
155
+ (l) =>
156
+ l.id === lesson.id &&
157
+ l.title === lesson.title &&
158
+ l.type === lesson.type
159
+ )
160
+ )}
161
+ />
162
+ <div className="relative h-6">
163
+ <div className="absolute left-1/2 -translate-x-1/2 -top-3">
164
+ <button
165
+ onClick={() => addLessonAfter(index, lesson.id)}
166
+ className="w-6 h-6 flex items-center justify-center bg-blue-100 text-blue-600 rounded hover:bg-blue-200 shadow-sm text-sm font-semibold cursor-pointer"
167
+ >
168
+ +
169
+ </button>
170
+ </div>
171
+ </div>
172
+ </div>
173
+ ))}
174
+ <GenerateButton handleSubmit={handleSubmit} />
175
+ </div>
176
+
177
+ {showScrollHint && (
178
+ <div className="pointer-events-none relative">
179
+ <div className="absolute bottom-0 left-0 w-full h-20 bg-gradient-to-t from-white to-transparent z-10" />
180
+ <div className="absolute bottom-3 left-0 w-full flex justify-center z-20">
181
+ <button
182
+ style={{ color: "#0084FF" }}
183
+ onClick={() => scrollToBottom("continue")}
184
+ className="px-4 py-1 bg-white text-sm rounded shadow hover:bg-blue-50 cursor-pointer pointer-events-auto font-bold flex items-center gap-2"
185
+ >
186
+ Continue scrolling
187
+ {SVGS.downArrow}
188
+ </button>
189
+ <button
190
+ style={{ color: "#0084FF" }}
191
+ onClick={() => scrollToBottom("bottom")}
192
+ className="px-4 py-1 bg-white text-sm rounded shadow hover:bg-blue-50 cursor-pointer pointer-events-auto font-bold flex items-center gap-2"
193
+ >
194
+ Scroll to bottom
195
+ {SVGS.bottom}
196
+ </button>
197
+ </div>
198
+ </div>
199
+ )}
200
+ </div>
201
+ )
202
+ }
@@ -0,0 +1,123 @@
1
+ import { useRef, useState } from "react"
2
+ import useStore from "../../utils/store"
3
+ import { TMessage } from "../Message"
4
+ import FileUploader from "../FileUploader"
5
+ import { SVGS } from "../../assets/svgs"
6
+ import { Message } from "../Message"
7
+
8
+ export const Sidebar = ({
9
+ messages,
10
+ sendPrompt,
11
+ handleSubmit,
12
+ }: {
13
+ messages: TMessage[]
14
+ sendPrompt: (prompt: string) => void
15
+ handleSubmit: () => void
16
+ }) => {
17
+ const inputRef = useRef<HTMLTextAreaElement>(null)
18
+ const syllabus = useStore((state) => state.syllabus)
19
+ const setSyllabus = useStore((state) => state.setSyllabus)
20
+
21
+ const [isOpen, setIsOpen] = useState(false)
22
+
23
+ return (
24
+ <>
25
+ {!isOpen && (
26
+ <button
27
+ className="fixed top-2 left-2 z-50 lg:hidden bg-white p-1 rounded shadow-md cursor-pointer"
28
+ onClick={() => setIsOpen(true)}
29
+ >
30
+
31
+ </button>
32
+ )}
33
+
34
+ <div
35
+ className={`fixed z-40 top-0 left-0 h-full w-4/5 max-w-sm bg-learnpack-blue text-sm text-gray-700 border-r overflow-y-auto scrollbar-hide p-6 transition-transform duration-300 ease-in-out lg:relative lg:transform-none lg:w-1/3 ${
36
+ isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"
37
+ }`}
38
+ >
39
+ {isOpen && (
40
+ <button
41
+ className="lg:hidden bg-white p-1 rounded shadow-md mb-4 cursor-pointer absolute top-2 left-2"
42
+ onClick={() => setIsOpen(false)}
43
+ >
44
+
45
+ </button>
46
+ )}
47
+ {/* This should have the same width as the input area */}
48
+ <div className="space-y-2 mb-6 ">
49
+ <p className="w-full bg-white p-2 rounded">
50
+ If you're satisfied, type "OK" in the chat.
51
+ </p>
52
+ <p className="w-full bg-white p-2 rounded">
53
+ If not, use the chat to give more context.
54
+ </p>
55
+ </div>
56
+
57
+ <div className="space-y-2 pb-32 h-[70%] overflow-y-auto scrollbar-hide">
58
+ {messages.map((message, index) => (
59
+ <Message
60
+ key={index}
61
+ type={message.type}
62
+ content={message.content}
63
+ />
64
+ ))}
65
+ </div>
66
+
67
+ <div className="relative w-full rounded-md bg-white text-gray-700 h-24">
68
+ <textarea
69
+ ref={inputRef}
70
+ style={{ resize: "none" }}
71
+ className="w-full h-full p-2"
72
+ placeholder="How can Learnpack help you?"
73
+ autoFocus
74
+ onKeyUp={(e) => {
75
+ if (e.key === "Enter" && !e.shiftKey) {
76
+ e.preventDefault()
77
+ sendPrompt(inputRef.current?.value || "")
78
+ inputRef.current!.value = ""
79
+ }
80
+ if (
81
+ e.key.toLowerCase() === "k" &&
82
+ inputRef.current?.value.toLowerCase().trim() === "ok"
83
+ ) {
84
+ e.preventDefault()
85
+ handleSubmit()
86
+ }
87
+ }}
88
+ />
89
+ <div className="absolute bottom-2 right-2 flex gap-1 items-center">
90
+ <div className="relative inline-block">
91
+ {syllabus.uploadedFiles?.length > 0 && (
92
+ <span
93
+ className="absolute -top-1 right-0 inline-flex items-center justify-center w-3 h-3 text-[10px] text-white bg-blue-200 rounded-full hover:bg-red-300 cursor-pointer"
94
+ title="Remove uploaded files"
95
+ onClick={() => {
96
+ setSyllabus({ ...syllabus, uploadedFiles: [] })
97
+ }}
98
+ >
99
+ {syllabus.uploadedFiles?.length}
100
+ </span>
101
+ )}
102
+ <FileUploader
103
+ onResult={(res) => {
104
+ setSyllabus({
105
+ ...syllabus,
106
+ uploadedFiles: [...syllabus.uploadedFiles, ...res],
107
+ })
108
+ }}
109
+ />
110
+ </div>
111
+
112
+ <button
113
+ className="cursor-pointer blue-on-hover flex items-center justify-center w-6 h-6"
114
+ onClick={() => sendPrompt(inputRef.current?.value || "")}
115
+ >
116
+ {SVGS.send}
117
+ </button>
118
+ </div>
119
+ </div>
120
+ </div>
121
+ </>
122
+ )
123
+ }
@@ -0,0 +1,134 @@
1
+ import React, { useRef, useState } from "react"
2
+ import { useShallow } from "zustand/react/shallow"
3
+ import useStore from "../../utils/store"
4
+ import { interactiveCreation } from "../../utils/rigo"
5
+ import {
6
+ parseLesson,
7
+ uploadFileToBucket,
8
+ useConsumableCall,
9
+ } from "../../utils/lib"
10
+ import {
11
+ createLearnJson,
12
+ processExercise,
13
+ slugify,
14
+ randomUUID,
15
+ } from "../../utils/creatorUtils"
16
+
17
+ import Loader from "../Loader"
18
+ import { TMessage } from "../Message"
19
+ import { Lesson } from "../LessonItem"
20
+ import { ConsumablesManager } from "../ConsumablesManager"
21
+ import toast from "react-hot-toast"
22
+ import { ContentIndex } from "./ContentIndex"
23
+ import { Sidebar } from "./Sidebar"
24
+
25
+ const SyllabusEditor: React.FC = () => {
26
+ const [messages, setMessages] = useState<TMessage[]>([])
27
+ const [isGenerating, setIsGenerating] = useState(false)
28
+ const prevLessons = useRef<Lesson[]>([])
29
+ const { syllabus, setSyllabus, auth } = useStore(
30
+ useShallow((state) => ({
31
+ syllabus: state.syllabus,
32
+ setSyllabus: state.setSyllabus,
33
+ auth: state.auth,
34
+ }))
35
+ )
36
+
37
+ const sendPrompt = async (prompt: string) => {
38
+ setMessages([
39
+ ...messages,
40
+ { type: "user", content: prompt },
41
+ { type: "assistant", content: "" },
42
+ ])
43
+ prevLessons.current = syllabus.lessons
44
+ const res = await interactiveCreation(auth.rigoToken, {
45
+ courseInfo: JSON.stringify(syllabus),
46
+ prevInteractions:
47
+ messages
48
+ .map((message) => `${message.type}: ${message.content}`)
49
+ .join("\n") + `\nUSER: ${prompt}`,
50
+ })
51
+ console.log(res, "RES")
52
+ const lessons: Lesson[] = res.parsed.listOfSteps.map((step: any) =>
53
+ parseLesson(step)
54
+ )
55
+ setSyllabus({
56
+ ...syllabus,
57
+ lessons: lessons,
58
+ courseInfo: {
59
+ ...syllabus.courseInfo,
60
+ title: res.parsed.title || syllabus.courseInfo.title,
61
+ },
62
+ })
63
+ setMessages((prev) => {
64
+ const newMessages = [...prev]
65
+ newMessages[newMessages.length - 1].content = res.parsed.aiMessage
66
+ return newMessages
67
+ })
68
+ }
69
+
70
+ const handleSubmit = async () => {
71
+ const success = await useConsumableCall(auth.bcToken, "ai-generation")
72
+ if (!success) {
73
+ toast.error("You don't have enough credits to generate a course!")
74
+ return
75
+ }
76
+ setIsGenerating(true)
77
+
78
+ const lessonsPromises = syllabus.lessons.map((lesson) =>
79
+ processExercise(
80
+ auth.rigoToken,
81
+ syllabus.lessons,
82
+ JSON.stringify(syllabus.courseInfo),
83
+ lesson,
84
+ "courses/" +
85
+ slugify(syllabus.courseInfo.title || randomUUID()) +
86
+ "/exercises"
87
+ )
88
+ )
89
+ await Promise.all(lessonsPromises)
90
+
91
+ const learnJson = createLearnJson(syllabus.courseInfo)
92
+ await uploadFileToBucket(
93
+ JSON.stringify(learnJson),
94
+ "courses/" +
95
+ slugify(syllabus.courseInfo.title || randomUUID()) +
96
+ "/learn.json"
97
+ )
98
+ setIsGenerating(false)
99
+
100
+ window.location.href = `/?slug=${slugify(
101
+ syllabus.courseInfo.title || "exercises"
102
+ )}&token=${auth.bcToken}`
103
+ }
104
+
105
+ return isGenerating ? (
106
+ <Loader
107
+ listeningTo="course-generation"
108
+ icon={<img src={"rigo-float.gif"} alt="rigo" className="w-20 h-20" />}
109
+ initialBuffer="🚀 Starting course generation..."
110
+ text="Learnpack is setting up your tutorial.
111
+ It may take a moment..."
112
+ />
113
+ ) : (
114
+ <div className="flex w-full bg-white rounded-md shadow-md overflow-hidden h-screen ">
115
+ <ConsumablesManager />
116
+
117
+ <Sidebar
118
+ messages={messages}
119
+ sendPrompt={sendPrompt}
120
+ handleSubmit={handleSubmit}
121
+ />
122
+
123
+ <div className="w-full p-8 space-y-6">
124
+ <ContentIndex
125
+ prevLessons={prevLessons.current}
126
+ handleSubmit={handleSubmit}
127
+ messages={messages}
128
+ />
129
+ </div>
130
+ </div>
131
+ )
132
+ }
133
+
134
+ export default SyllabusEditor
@@ -88,15 +88,12 @@ h1 {
88
88
  .loader-icon {
89
89
  width: 40px;
90
90
  height: 40px;
91
-
92
- border: 2px solid var(--loader-color);
93
- border-top-color: transparent;
94
91
  position: relative;
95
92
  display: flex;
96
93
  align-items: center;
97
94
  justify-content: center;
98
95
  }
99
- .loader-icon::after {
96
+ /* .loader-icon::after {
100
97
  content: "";
101
98
  display: block;
102
99
  width: 100%;
@@ -106,11 +103,10 @@ h1 {
106
103
  height: 100%;
107
104
  border: 2px solid var(--gray-text);
108
105
  border-top: 2px solid var(--learnpack-blue);
109
- /* background-color: red; */
110
106
 
111
107
  border-radius: 50%;
112
108
  animation: spin 2s linear infinite;
113
- }
109
+ } */
114
110
 
115
111
  @keyframes glowing {
116
112
  0% {
@@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client"
3
3
  import { BrowserRouter, Route, Routes } from "react-router"
4
4
  import "./index.css"
5
5
  import App from "./App.tsx"
6
- import SyllabusEditor from "./components/SyllabusEditor.tsx"
6
+ import SyllabusEditor from "./components/syllabus/SyllabusEditor.tsx"
7
7
  import { Toaster } from "react-hot-toast"
8
8
 
9
9
  createRoot(document.getElementById("root")!).render(
@@ -9,7 +9,8 @@ export type FormState = {
9
9
  hasContentIndex: boolean
10
10
  contentIndex: string
11
11
  isCompleted: boolean
12
- currentStep: number
12
+ variables: string[]
13
+ currentStep: string
13
14
  title?: string
14
15
  }
15
16
 
@@ -18,7 +19,7 @@ type Auth = {
18
19
  rigoToken: string
19
20
  userId: string
20
21
  }
21
- type Syllabus = {
22
+ export type Syllabus = {
22
23
  lessons: Lesson[]
23
24
  courseInfo: FormState
24
25
  uploadedFiles: {
@@ -57,7 +58,14 @@ const useStore = create<Store>()(
57
58
  hasContentIndex: false,
58
59
  contentIndex: "",
59
60
  isCompleted: false,
60
- currentStep: 0,
61
+ currentStep: "description",
62
+ variables: [
63
+ "description",
64
+ "duration",
65
+ "login",
66
+ "targetAudience",
67
+ "hasContentIndex",
68
+ ],
61
69
  },
62
70
  setFormState: (formState: Partial<FormState>) =>
63
71
  set((state) => ({ formState: { ...state.formState, ...formState } })),
@@ -71,8 +79,9 @@ const useStore = create<Store>()(
71
79
  hasContentIndex: false,
72
80
  contentIndex: "",
73
81
  isCompleted: false,
74
- currentStep: 0,
82
+ currentStep: "description",
75
83
  title: "",
84
+ variables: [],
76
85
  },
77
86
  uploadedFiles: [],
78
87
  },