@learnpack/learnpack 5.0.70 → 5.0.72

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.
Files changed (35) hide show
  1. package/README.md +13 -13
  2. package/lib/commands/init.js +1 -1
  3. package/lib/commands/serve.js +60 -4
  4. package/lib/creatorDist/assets/{index-Dqo9u2iR.css → index-BJ2JJzVC.css} +53 -26
  5. package/lib/creatorDist/assets/{index-Chx6V3zd.js → index-CKBeex0S.js} +35878 -29623
  6. package/lib/creatorDist/index.html +2 -2
  7. package/oclif.manifest.json +1 -1
  8. package/package.json +1 -1
  9. package/src/commands/init.ts +1 -1
  10. package/src/commands/serve.ts +70 -6
  11. package/src/creator/package-lock.json +49 -0
  12. package/src/creator/package.json +1 -0
  13. package/src/creator/src/App.tsx +28 -21
  14. package/src/creator/src/assets/svgs.tsx +1 -1
  15. package/src/creator/src/components/ConsumablesManager.tsx +12 -2
  16. package/src/creator/src/components/LessonItem.tsx +3 -2
  17. package/src/creator/src/components/Loader.tsx +5 -1
  18. package/src/creator/src/components/Login.tsx +58 -151
  19. package/src/creator/src/components/Message.tsx +11 -1
  20. package/src/creator/src/components/Redirector.tsx +12 -0
  21. package/src/creator/src/components/syllabus/ContentIndex.tsx +88 -58
  22. package/src/creator/src/components/syllabus/Sidebar.tsx +3 -12
  23. package/src/creator/src/components/syllabus/SyllabusEditor.tsx +63 -7
  24. package/src/creator/src/index.css +15 -0
  25. package/src/creator/src/main.tsx +0 -1
  26. package/src/creator/src/utils/creatorUtils.ts +33 -3
  27. package/src/creator/src/utils/lib.ts +156 -2
  28. package/src/creator/src/utils/rigo.ts +3 -3
  29. package/src/creator/src/utils/store.ts +2 -1
  30. package/src/creatorDist/assets/{index-Dqo9u2iR.css → index-BJ2JJzVC.css} +53 -26
  31. package/src/creatorDist/assets/{index-Chx6V3zd.js → index-CKBeex0S.js} +35878 -29623
  32. package/src/creatorDist/index.html +2 -2
  33. package/src/ui/_app/app.css +1 -1
  34. package/src/ui/_app/app.js +529 -529
  35. package/src/ui/app.tar.gz +0 -0
@@ -6,12 +6,15 @@ import {
6
6
  parseLesson,
7
7
  uploadFileToBucket,
8
8
  useConsumableCall,
9
+ validateTokens,
10
+ extractImagesFromMarkdown,
9
11
  } from "../../utils/lib"
10
12
  import {
11
13
  createLearnJson,
12
14
  processExercise,
13
15
  slugify,
14
16
  randomUUID,
17
+ processImage,
15
18
  } from "../../utils/creatorUtils"
16
19
 
17
20
  import Loader from "../Loader"
@@ -21,10 +24,25 @@ import { ConsumablesManager } from "../ConsumablesManager"
21
24
  import toast from "react-hot-toast"
22
25
  import { ContentIndex } from "./ContentIndex"
23
26
  import { Sidebar } from "./Sidebar"
27
+ import Login from "../Login"
28
+ import { eventBus } from "../../utils/eventBus"
24
29
 
25
30
  const SyllabusEditor: React.FC = () => {
26
- const [messages, setMessages] = useState<TMessage[]>([])
31
+ const [messages, setMessages] = useState<TMessage[]>([
32
+ {
33
+ type: "assistant",
34
+ content: "If you're satisfied, type 'OK' in the chat.",
35
+ },
36
+ {
37
+ type: "assistant",
38
+ content:
39
+ "If not, what would you like me to change? You can sat things like: 'Add more exercises', 'Make it more difficult', 'Remove step 1.1 and replace it with a new step that explains the concept of X'",
40
+ },
41
+ ])
27
42
  const [isGenerating, setIsGenerating] = useState(false)
43
+ const [showLoginModal, setShowLoginModal] = useState(false)
44
+ const [isThinking, setIsThinking] = useState(false)
45
+
28
46
  const prevLessons = useRef<Lesson[]>([])
29
47
  const { syllabus, setSyllabus, auth } = useStore(
30
48
  useShallow((state) => ({
@@ -35,20 +53,22 @@ const SyllabusEditor: React.FC = () => {
35
53
  )
36
54
 
37
55
  const sendPrompt = async (prompt: string) => {
56
+ setIsThinking(true)
57
+
38
58
  setMessages([
39
59
  ...messages,
40
60
  { type: "user", content: prompt },
41
61
  { type: "assistant", content: "" },
42
62
  ])
43
63
  prevLessons.current = syllabus.lessons
44
- const res = await interactiveCreation(auth.rigoToken, {
64
+ const res = await interactiveCreation({
45
65
  courseInfo: JSON.stringify(syllabus),
46
66
  prevInteractions:
47
67
  messages
48
68
  .map((message) => `${message.type}: ${message.content}`)
49
69
  .join("\n") + `\nUSER: ${prompt}`,
50
70
  })
51
- console.log(res, "RES")
71
+
52
72
  const lessons: Lesson[] = res.parsed.listOfSteps.map((step: any) =>
53
73
  parseLesson(step)
54
74
  )
@@ -65,9 +85,19 @@ const SyllabusEditor: React.FC = () => {
65
85
  newMessages[newMessages.length - 1].content = res.parsed.aiMessage
66
86
  return newMessages
67
87
  })
88
+ setIsThinking(false)
68
89
  }
69
90
 
70
91
  const handleSubmit = async () => {
92
+ if (!auth.bcToken || !auth.rigoToken) {
93
+ setShowLoginModal(true)
94
+ return
95
+ }
96
+ const isValid = await validateTokens(auth.bcToken)
97
+ if (!isValid) {
98
+ setShowLoginModal(true)
99
+ return
100
+ }
71
101
  const success = await useConsumableCall(auth.bcToken, "ai-generation")
72
102
  if (!success) {
73
103
  toast.error("You don't have enough credits to generate a course!")
@@ -75,18 +105,35 @@ const SyllabusEditor: React.FC = () => {
75
105
  }
76
106
  setIsGenerating(true)
77
107
 
108
+ const tutorialDir =
109
+ "courses/" + slugify(syllabus.courseInfo.title || randomUUID())
78
110
  const lessonsPromises = syllabus.lessons.map((lesson) =>
79
111
  processExercise(
80
112
  auth.rigoToken,
81
113
  syllabus.lessons,
82
114
  JSON.stringify(syllabus.courseInfo),
83
115
  lesson,
84
- "courses/" +
85
- slugify(syllabus.courseInfo.title || randomUUID()) +
86
- "/exercises"
116
+ tutorialDir + "/exercises"
87
117
  )
88
118
  )
89
- await Promise.all(lessonsPromises)
119
+ const readmeContents = await Promise.all(lessonsPromises)
120
+
121
+ let imagesArray: any[] = []
122
+
123
+ for (const content of readmeContents) {
124
+ imagesArray = [...imagesArray, ...extractImagesFromMarkdown(content)]
125
+ }
126
+
127
+ eventBus.emit("course-generation", {
128
+ message: "📷 Generating images...",
129
+ })
130
+
131
+ const imagePromises = imagesArray.map(
132
+ async (image: { alt: string; url: string }) => {
133
+ return processImage(tutorialDir, image.url, image.alt, auth.rigoToken)
134
+ }
135
+ )
136
+ await Promise.all(imagePromises)
90
137
 
91
138
  const learnJson = createLearnJson(syllabus.courseInfo)
92
139
  await uploadFileToBucket(
@@ -112,6 +159,14 @@ It may take a moment..."
112
159
  />
113
160
  ) : (
114
161
  <div className="flex w-full bg-white rounded-md shadow-md overflow-hidden h-screen ">
162
+ {showLoginModal && (
163
+ <Login
164
+ onFinish={() => {
165
+ setShowLoginModal(false)
166
+ }}
167
+ />
168
+ )}
169
+
115
170
  <ConsumablesManager />
116
171
 
117
172
  <Sidebar
@@ -125,6 +180,7 @@ It may take a moment..."
125
180
  prevLessons={prevLessons.current}
126
181
  handleSubmit={handleSubmit}
127
182
  messages={messages}
183
+ isThinking={isThinking}
128
184
  />
129
185
  </div>
130
186
  </div>
@@ -126,3 +126,18 @@ h1 {
126
126
  }
127
127
  }
128
128
  }
129
+
130
+ .border-learnpack-blue {
131
+ border-color: var(--learnpack-blue);
132
+ }
133
+
134
+ .red-ball {
135
+ width: 16px;
136
+ height: 16px;
137
+ border: 2px solid white;
138
+ background-color: #eb5757;
139
+ border-radius: 50%;
140
+ position: absolute;
141
+ top: -10px;
142
+ left: 10px;
143
+ }
@@ -5,7 +5,6 @@ import "./index.css"
5
5
  import App from "./App.tsx"
6
6
  import SyllabusEditor from "./components/syllabus/SyllabusEditor.tsx"
7
7
  import { Toaster } from "react-hot-toast"
8
-
9
8
  createRoot(document.getElementById("root")!).render(
10
9
  <StrictMode>
11
10
  <Toaster />
@@ -1,6 +1,11 @@
1
1
  import { Lesson } from "../components/LessonItem"
2
2
  import { eventBus } from "./eventBus"
3
- import { uploadFileToBucket } from "./lib"
3
+ import {
4
+ generateImage,
5
+ getFilenameFromUrl,
6
+ uploadFileToBucket,
7
+ uploadImageToBucket,
8
+ } from "./lib"
4
9
  import { makeReadmeReadable, readmeCreator, checkReadability } from "./rigo"
5
10
  import { FormState } from "./store"
6
11
 
@@ -80,8 +85,6 @@ export async function processExercise(
80
85
  expected_grade_level: PARAMS.expected_grade_level,
81
86
  })
82
87
 
83
- // console.log("REDUCED README START", reducedReadme, "REDUCED README END")
84
-
85
88
  if (!reducedReadme) break
86
89
 
87
90
  readability = checkReadability(
@@ -134,3 +137,30 @@ export async function processExercise(
134
137
  export const randomUUID = () => {
135
138
  return Math.random().toString(36).substring(2, 15)
136
139
  }
140
+
141
+ export const processImage = async (
142
+ tutorialDir: string,
143
+ url: string,
144
+ description: string,
145
+ rigoToken: string
146
+ ) => {
147
+ try {
148
+ const filename = getFilenameFromUrl(url)
149
+
150
+ const imagePath = tutorialDir + "/.learn" + "/assets/" + filename
151
+
152
+ eventBus.emit("course-generation", {
153
+ message: `🖼️ Generating image ${imagePath}`,
154
+ })
155
+
156
+ const res = await generateImage(rigoToken, { prompt: description })
157
+ await uploadImageToBucket(res.image_url, imagePath)
158
+
159
+ eventBus.emit("course-generation", {
160
+ message: `✅ Image ${imagePath} generated successfully!`,
161
+ })
162
+ return true
163
+ } catch {
164
+ return false
165
+ }
166
+ }
@@ -1,5 +1,5 @@
1
1
  import axios from "axios"
2
- import { BREATHECODE_HOST } from "./constants"
2
+ import { BREATHECODE_HOST, RIGOBOT_HOST } from "./constants"
3
3
 
4
4
  type ParsedLesson = {
5
5
  id: string
@@ -26,7 +26,7 @@ export function parseLesson(input: string): ParsedLesson | null {
26
26
  }
27
27
  }
28
28
 
29
- // export const CREATOR_API_URL = "http://localhost:3000"
29
+ export const CREATOR_API_URL = "http://localhost:3000"
30
30
 
31
31
  export const uploadFileToBucket = async (content: string, path: string) => {
32
32
  const response = await axios.post(`/upload`, {
@@ -35,6 +35,13 @@ export const uploadFileToBucket = async (content: string, path: string) => {
35
35
  })
36
36
  return response.data
37
37
  }
38
+ export const uploadImageToBucket = async (imageUrl: string, path: string) => {
39
+ const response = await axios.post(`/upload-image`, {
40
+ image_url: imageUrl,
41
+ destination: path,
42
+ })
43
+ return response.data
44
+ }
38
45
 
39
46
  export const checkParams = () => {
40
47
  const urlParams = new URLSearchParams(window.location.search)
@@ -120,3 +127,150 @@ export const parseConsumables = (
120
127
 
121
128
  return result
122
129
  }
130
+
131
+ type LoginInfo = {
132
+ email: string
133
+ password: string
134
+ }
135
+
136
+ export const getRigobotJSON = async (breathecodeToken: string) => {
137
+ const rigoUrl = `${RIGOBOT_HOST}/v1/auth/me/token?breathecode_token=${breathecodeToken}`
138
+ const rigoResp = await fetch(rigoUrl)
139
+ if (!rigoResp.ok) {
140
+ throw new Error("Unable to obtain Rigobot token")
141
+ }
142
+ const rigobotJson = await rigoResp.json()
143
+ return rigobotJson
144
+ }
145
+ export const validateUser = async (breathecodeToken: string) => {
146
+ const config = {
147
+ method: "GET",
148
+ headers: {
149
+ "Content-Type": "application/json",
150
+ Authorization: `Token ${breathecodeToken}`,
151
+ },
152
+ }
153
+
154
+ const res = await fetch(`${BREATHECODE_HOST}/v1/auth/user/me`, config)
155
+ if (!res.ok) {
156
+ console.log("ERROR", res)
157
+ return null
158
+ }
159
+ const json = await res.json()
160
+
161
+ if ("roles" in json) {
162
+ delete json.roles
163
+ }
164
+ if ("permissions" in json) {
165
+ delete json.permissions
166
+ }
167
+ if ("settings" in json) {
168
+ delete json.settings
169
+ }
170
+
171
+ return json
172
+ }
173
+
174
+ export const login4Geeks = async (loginInfo: LoginInfo) => {
175
+ const url = `${BREATHECODE_HOST}/v1/auth/login/`
176
+
177
+ const res = await fetch(url, {
178
+ body: JSON.stringify(loginInfo),
179
+ method: "post",
180
+ headers: {
181
+ "Content-Type": "application/json",
182
+ },
183
+ })
184
+
185
+ if (!res.ok) {
186
+ throw Error("Unable to login with provided credentials")
187
+ }
188
+
189
+ const json = await res.json()
190
+
191
+ const rigoJson = await getRigobotJSON(json.token)
192
+
193
+ const user = await validateUser(json.token)
194
+ const returns = { ...json, rigobot: { ...rigoJson }, user }
195
+
196
+ return returns
197
+ }
198
+
199
+ export const loginWithToken = async (token: string) => {
200
+ const rigoJson = await getRigobotJSON(token)
201
+
202
+ const user = await validateUser(token)
203
+
204
+ const returns = { rigobot: { ...rigoJson }, ...user }
205
+
206
+ return returns
207
+ }
208
+
209
+ export const validateTokens = async (breathecodeToken: string) => {
210
+ const user = await validateUser(breathecodeToken)
211
+ console.log("USER", user)
212
+ if (!user) {
213
+ return false
214
+ }
215
+
216
+ const rigobotJson = await getRigobotJSON(breathecodeToken)
217
+ console.log("RIGOBOT", rigobotJson)
218
+
219
+ return true
220
+ }
221
+
222
+ export function extractImagesFromMarkdown(markdown: string) {
223
+ const imageRegex = /!\[([^\]]*)]\(([^)]+)\)/g
224
+ const images = []
225
+ let match
226
+
227
+ while ((match = imageRegex.exec(markdown)) !== null) {
228
+ const altText = match[1]
229
+ const url = match[2]
230
+ images.push({ alt: altText, url: url })
231
+ }
232
+
233
+ return images
234
+ }
235
+
236
+ export function getFilenameFromUrl(url: string): string {
237
+ try {
238
+ // 1) Use the URL constructor to strip off protocol/host/search/hash
239
+ const pathname = new URL(url, location.href).pathname
240
+ // 2) Grab everything after the last “/”
241
+ return pathname.substring(pathname.lastIndexOf("/") + 1)
242
+ } catch {
243
+ // Fallback for non-absolute URLs or invalid inputs
244
+ const clean = url.split("?")[0].split("#")[0]
245
+ return clean.substring(clean.lastIndexOf("/") + 1)
246
+ }
247
+ }
248
+
249
+ type TGenerateImageParams = {
250
+ prompt: string
251
+ }
252
+
253
+ export const generateImage = async (
254
+ token: string,
255
+ { prompt }: TGenerateImageParams
256
+ ) => {
257
+ try {
258
+ const response = await axios.post(
259
+ `${RIGOBOT_HOST}/v1/learnpack/tools/images`,
260
+ {
261
+ prompt,
262
+ },
263
+ {
264
+ headers: {
265
+ "Content-Type": "application/json",
266
+ Authorization: "Token " + token,
267
+ },
268
+ }
269
+ )
270
+
271
+ return response.data
272
+ } catch (error) {
273
+ console.error("Error generating image:", error)
274
+ return null
275
+ }
276
+ }
@@ -10,11 +10,11 @@ type TInteractiveCreationInputs = {
10
10
  prevInteractions: string
11
11
  }
12
12
  export const interactiveCreation = async (
13
- token: string,
13
+ // token: string,
14
14
  inputs: TInteractiveCreationInputs
15
15
  ) => {
16
16
  const response = await axios.post(
17
- `${RIGOBOT_HOST}/v1/prompting/completion/390/`,
17
+ `${RIGOBOT_HOST}/v1/prompting/public/completion/390/`,
18
18
  {
19
19
  inputs: inputs,
20
20
  include_purpose_objective: false,
@@ -23,7 +23,7 @@ export const interactiveCreation = async (
23
23
  {
24
24
  headers: {
25
25
  "Content-Type": "application/json",
26
- Authorization: "Token " + token,
26
+ // Authorization: "Token " + token,
27
27
  },
28
28
  }
29
29
  )
@@ -18,6 +18,7 @@ type Auth = {
18
18
  bcToken: string
19
19
  rigoToken: string
20
20
  userId: string
21
+ user: any
21
22
  }
22
23
  export type Syllabus = {
23
24
  lessons: Lesson[]
@@ -50,6 +51,7 @@ const useStore = create<Store>()(
50
51
  bcToken: "",
51
52
  rigoToken: "",
52
53
  userId: "",
54
+ user: null,
53
55
  },
54
56
  formState: {
55
57
  description: "",
@@ -62,7 +64,6 @@ const useStore = create<Store>()(
62
64
  variables: [
63
65
  "description",
64
66
  "duration",
65
- "login",
66
67
  "targetAudience",
67
68
  "hasContentIndex",
68
69
  ],
@@ -57,7 +57,6 @@
57
57
  --color-red-300: oklch(80.8% 0.114 19.571);
58
58
  --color-red-500: oklch(63.7% 0.237 25.331);
59
59
  --color-red-700: oklch(50.5% 0.213 27.518);
60
- --color-yellow-50: oklch(98.7% 0.026 102.212);
61
60
  --color-sky-500: oklch(68.5% 0.169 237.323);
62
61
  --color-sky-600: oklch(58.8% 0.158 241.966);
63
62
  --color-blue-50: oklch(97% 0.014 254.604);
@@ -78,6 +77,7 @@
78
77
  --color-gray-700: oklch(37.3% 0.034 259.733);
79
78
  --color-gray-800: oklch(27.8% 0.033 256.848);
80
79
  --color-gray-900: oklch(21% 0.034 264.665);
80
+ --color-black: #000;
81
81
  --color-white: #fff;
82
82
  --spacing: 0.25rem;
83
83
  --container-sm: 24rem;
@@ -86,8 +86,6 @@
86
86
  --text-sm--line-height: calc(1.25 / 0.875);
87
87
  --text-lg: 1.125rem;
88
88
  --text-lg--line-height: calc(1.75 / 1.125);
89
- --text-xl: 1.25rem;
90
- --text-xl--line-height: calc(1.75 / 1.25);
91
89
  --text-4xl: 2.25rem;
92
90
  --text-4xl--line-height: calc(2.5 / 2.25);
93
91
  --font-weight-medium: 500;
@@ -373,6 +371,9 @@
373
371
  .relative {
374
372
  position: relative;
375
373
  }
374
+ .inset-0 {
375
+ inset: calc(var(--spacing) * 0);
376
+ }
376
377
  .-top-1 {
377
378
  top: calc(var(--spacing) * -1);
378
379
  }
@@ -421,6 +422,9 @@
421
422
  .z-50 {
422
423
  z-index: 50;
423
424
  }
425
+ .z-1000 {
426
+ z-index: 1000;
427
+ }
424
428
  .container {
425
429
  width: 100%;
426
430
  }
@@ -455,9 +459,6 @@
455
459
  .mx-2 {
456
460
  margin-inline: calc(var(--spacing) * 2);
457
461
  }
458
- .mx-auto {
459
- margin-inline: auto;
460
- }
461
462
  .mt-1 {
462
463
  margin-top: calc(var(--spacing) * 1);
463
464
  }
@@ -470,9 +471,6 @@
470
471
  .mt-6 {
471
472
  margin-top: calc(var(--spacing) * 6);
472
473
  }
473
- .mt-10 {
474
- margin-top: calc(var(--spacing) * 10);
475
- }
476
474
  .mr-1 {
477
475
  margin-right: calc(var(--spacing) * 1);
478
476
  }
@@ -521,8 +519,11 @@
521
519
  .h-40 {
522
520
  height: calc(var(--spacing) * 40);
523
521
  }
524
- .h-\[70\%\] {
525
- height: 70%;
522
+ .h-60 {
523
+ height: calc(var(--spacing) * 60);
524
+ }
525
+ .h-\[85\%\] {
526
+ height: 85%;
526
527
  }
527
528
  .h-full {
528
529
  height: 100%;
@@ -530,12 +531,15 @@
530
531
  .h-screen {
531
532
  height: 100vh;
532
533
  }
533
- .max-h-\[70vh\] {
534
- max-height: 70vh;
534
+ .max-h-\[80vh\] {
535
+ max-height: 80vh;
535
536
  }
536
537
  .max-h-\[300px\] {
537
538
  max-height: 300px;
538
539
  }
540
+ .min-h-\[70vh\] {
541
+ min-height: 70vh;
542
+ }
539
543
  .min-h-screen {
540
544
  min-height: 100vh;
541
545
  }
@@ -717,6 +721,18 @@
717
721
  .border-transparent {
718
722
  border-color: #0000;
719
723
  }
724
+ .bg-black\/50 {
725
+ background-color: #00000080;
726
+ }
727
+ @supports (color: color-mix(in lab, red, red)) {
728
+ .bg-black\/50 {
729
+ background-color: color-mix(
730
+ in oklab,
731
+ var(--color-black) 50%,
732
+ transparent
733
+ );
734
+ }
735
+ }
720
736
  .bg-blue-50 {
721
737
  background-color: var(--color-blue-50);
722
738
  }
@@ -744,9 +760,6 @@
744
760
  .bg-white {
745
761
  background-color: var(--color-white);
746
762
  }
747
- .bg-yellow-50 {
748
- background-color: var(--color-yellow-50);
749
- }
750
763
  .bg-gradient-to-t {
751
764
  --tw-gradient-position: to top in oklab;
752
765
  background-image: linear-gradient(var(--tw-gradient-stops));
@@ -829,10 +842,6 @@
829
842
  font-size: var(--text-sm);
830
843
  line-height: var(--tw-leading, var(--text-sm--line-height));
831
844
  }
832
- .text-xl {
833
- font-size: var(--text-xl);
834
- line-height: var(--tw-leading, var(--text-xl--line-height));
835
- }
836
845
  .text-\[10px\] {
837
846
  font-size: 10px;
838
847
  }
@@ -897,12 +906,6 @@
897
906
  .opacity-30 {
898
907
  opacity: 0.3;
899
908
  }
900
- .shadow {
901
- --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, #0000001a),
902
- 0 1px 2px -1px var(--tw-shadow-color, #0000001a);
903
- box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow),
904
- var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
905
- }
906
909
  .shadow-md {
907
910
  --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, #0000001a),
908
911
  0 2px 4px -2px var(--tw-shadow-color, #0000001a);
@@ -915,6 +918,17 @@
915
918
  box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow),
916
919
  var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
917
920
  }
921
+ .transition {
922
+ transition-property: color, background-color, border-color, outline-color,
923
+ text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via,
924
+ --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate,
925
+ filter, -webkit-backdrop-filter, backdrop-filter;
926
+ transition-timing-function: var(
927
+ --tw-ease,
928
+ var(--default-transition-timing-function)
929
+ );
930
+ transition-duration: var(--tw-duration, var(--default-transition-duration));
931
+ }
918
932
  .transition-all {
919
933
  transition-property: all;
920
934
  transition-timing-function: var(
@@ -1091,6 +1105,19 @@ h1 {
1091
1105
  .blue-on-hover:hover svg path {
1092
1106
  fill: var(--learnpack-blue);
1093
1107
  }
1108
+ .border-learnpack-blue {
1109
+ border-color: var(--learnpack-blue);
1110
+ }
1111
+ .red-ball {
1112
+ background-color: #eb5757;
1113
+ border: 2px solid #fff;
1114
+ border-radius: 50%;
1115
+ width: 16px;
1116
+ height: 16px;
1117
+ position: absolute;
1118
+ top: -10px;
1119
+ left: 10px;
1120
+ }
1094
1121
  @property --tw-translate-x {
1095
1122
  syntax: "*";
1096
1123
  inherits: false;