@learnpack/learnpack 5.0.98 → 5.0.106

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,109 @@
1
+ import React, { useState } from "react"
2
+
3
+ export interface ParsedLink {
4
+ url: string
5
+ title?: string
6
+ text?: string
7
+ transcript?: string
8
+ description?: string
9
+ author?: string
10
+ duration?: number
11
+ thumbnail?: string
12
+ }
13
+
14
+ interface LinkUploaderProps {
15
+ onResult: (links: ParsedLink[]) => void
16
+ apiBase?: string
17
+ }
18
+
19
+ const toBase64Url = (str: string) =>
20
+ btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
21
+
22
+ const LinkUploader: React.FC<LinkUploaderProps> = ({
23
+ onResult,
24
+ apiBase = "http://localhost:3000",
25
+ }) => {
26
+ const [url, setUrl] = useState("")
27
+ const [loading, setLoading] = useState(false)
28
+ const [error, setError] = useState<string | null>(null)
29
+
30
+ const handleAdd = async () => {
31
+ const raw = url.trim()
32
+ if (!raw) return setError("Please enter a URL.")
33
+ setError(null)
34
+ setLoading(true)
35
+
36
+ try {
37
+ const key = toBase64Url(raw)
38
+ const resp = await fetch(`${apiBase}/actions/fetch/${key}`)
39
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
40
+ const data = (await resp.json()) as ParsedLink
41
+
42
+ onResult([data]) // emit the parsed link immediately
43
+ setUrl("") // clear input
44
+ } catch (err: any) {
45
+ console.error(err)
46
+ setError(err.message || "Failed to fetch link.")
47
+ } finally {
48
+ setLoading(false)
49
+ }
50
+ }
51
+
52
+ return (
53
+ <div className="max-w-md mx-auto p-6 bg-white rounded-xl shadow-lg">
54
+ <div className="flex space-x-2">
55
+ <input
56
+ type="url"
57
+ value={url}
58
+ onChange={(e) => setUrl(e.target.value)}
59
+ onKeyDown={(e) => e.key === "Enter" && handleAdd()}
60
+ disabled={loading}
61
+ placeholder="Paste your link here…"
62
+ className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-200 transition disabled:bg-gray-100"
63
+ />
64
+ <button
65
+ onClick={handleAdd}
66
+ disabled={loading}
67
+ className={`w-12 h-12 flex items-center justify-center rounded-lg text-white transition ${
68
+ loading
69
+ ? "bg-gray-400 cursor-not-allowed"
70
+ : "bg-learnpack hover:bg-opacity-90"
71
+ }`}
72
+ >
73
+ {loading ? (
74
+ <svg
75
+ className="animate-spin h-5 w-5 text-white"
76
+ xmlns="http://www.w3.org/2000/svg"
77
+ fill="none"
78
+ viewBox="0 0 24 24"
79
+ >
80
+ <circle
81
+ className="opacity-25"
82
+ cx="12"
83
+ cy="12"
84
+ r="10"
85
+ stroke="currentColor"
86
+ strokeWidth="4"
87
+ />
88
+ <path
89
+ className="opacity-75"
90
+ fill="currentColor"
91
+ d="M4 12a8 8 0 018-8v4l3.5-3.5L12 0v4a8 8 0 100 16v-4l-3.5 3.5L4 12z"
92
+ />
93
+ </svg>
94
+ ) : (
95
+ <span className="text-2xl leading-none">+</span>
96
+ )}
97
+ </button>
98
+ </div>
99
+
100
+ {error && (
101
+ <p className="mt-3 text-sm text-red-600 bg-red-50 p-2 rounded">
102
+ {error}
103
+ </p>
104
+ )}
105
+ </div>
106
+ )
107
+ }
108
+
109
+ export default LinkUploader
@@ -0,0 +1,95 @@
1
+ import React, { useEffect } from "react"
2
+ import html2canvas from "html2canvas"
3
+
4
+ import useStore from "../utils/store"
5
+ import { uploadImageToBucket } from "../utils/lib"
6
+ import { slugify } from "../utils/creatorUtils"
7
+ import { eventBus } from "../utils/eventBus"
8
+ // import { SVGS } from "../assets/svgs"
9
+
10
+ function proxify(link: string) {
11
+ // Validar que el enlace no sea vacío
12
+ if (!link) {
13
+ throw new Error("El enlace es requerido")
14
+ }
15
+
16
+ const encodedUrl = btoa(link)
17
+
18
+ return `http://localhost:3000/proxy?url=${encodedUrl}`
19
+ }
20
+
21
+ const PreviewGenerator: React.FC = () => {
22
+ const history = useStore((state) => state.history)
23
+ const auth = useStore((state) => state.auth)
24
+
25
+ const syllabus = history[history.length - 1]
26
+
27
+ useEffect(() => {
28
+ const handleDownload = () => {
29
+ eventBus.emit("course-generation", {
30
+ message: "📷 Generating preview image...",
31
+ })
32
+ const previewElement = document.getElementById("preview")
33
+ if (previewElement) {
34
+ html2canvas(previewElement, {
35
+ useCORS: true,
36
+ }).then(async (canvas) => {
37
+ // const anchor = document.createElement("a")
38
+ // anchor.href = canvas.toDataURL("image/png")
39
+ // anchor.download = "preview.png"
40
+ // anchor.click()
41
+
42
+ const imageUrl = canvas.toDataURL("image/png")
43
+
44
+ await uploadImageToBucket(
45
+ imageUrl,
46
+ `courses/${slugify(syllabus.courseInfo?.title || "")}/preview.png`
47
+ )
48
+ eventBus.emit("course-generation", {
49
+ message: "✅ Preview image generated and uploaded to course.",
50
+ })
51
+ })
52
+ }
53
+ }
54
+ handleDownload()
55
+ }, [])
56
+
57
+ return (
58
+ <div className="fixed">
59
+ <div
60
+ id="preview"
61
+ style={{
62
+ width: "1000px",
63
+ height: "630px",
64
+ background: "white",
65
+ }}
66
+ >
67
+ <div className="bg-learnpack-blue p-4 rounded-md" />
68
+ <div className="px-4 -mt-5">
69
+ <h1 className="text-2xl font-bold">{syllabus.courseInfo?.title}</h1>
70
+ <p className="mt-5 text-sm">{syllabus.courseInfo?.description}</p>
71
+ <div className="flex items-center gap-2 mt-5">
72
+ <img
73
+ src={proxify(auth.user?.profile?.avatar_url || "")}
74
+ alt="Profile"
75
+ className="w-10 h-10 rounded-full mt-3"
76
+ />
77
+ <div>
78
+ <p className=" text-sm font-bold">
79
+ Author: {auth.user?.first_name} {auth.user?.last_name}
80
+ </p>
81
+ <small className=" text-sm">
82
+ Published on: {new Date().toLocaleDateString()}
83
+ </small>
84
+ </div>
85
+ <div className="ml-auto">
86
+ <img src="logo.png" className="w-10 h-10" />
87
+ </div>
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ )
93
+ }
94
+
95
+ export default PreviewGenerator
@@ -0,0 +1,68 @@
1
+ import React from "react"
2
+ import { ParsedLink } from "./LinkUploader"
3
+ import useStore from "../utils/store"
4
+ import { SVGS } from "../assets/svgs"
5
+
6
+ interface SourceProps {
7
+ source: ParsedLink
8
+ }
9
+
10
+ // const formatDuration = (sec: number) => {
11
+ // const m = Math.floor(sec / 60)
12
+ // const s = sec % 60
13
+ // return `${m}:${s.toString().padStart(2, "0")}`
14
+ // }
15
+
16
+ const Source: React.FC<SourceProps> = ({ source }) => {
17
+ const setFormState = useStore((state) => state.setFormState)
18
+ const formState = useStore((state) => state.formState)
19
+
20
+ const {
21
+ url,
22
+ title,
23
+ // author,
24
+ thumbnail,
25
+ // duration,
26
+ // description,
27
+ // transcript,
28
+ // text,
29
+ } = source
30
+
31
+
32
+
33
+ return (
34
+ <div className="relative max-w-xs bg-white rounded-xl shadow-md overflow-hidden hover:shadow-lg transition-shadow">
35
+ <div className="p-4 flex flex-col space-y-2">
36
+ <button
37
+ className="absolute top-2 right-2"
38
+ onClick={() => {
39
+ setFormState({
40
+ sources: formState.sources.filter((s) => s !== source),
41
+ })
42
+ }}
43
+ >
44
+ {SVGS.redClose}
45
+ </button>
46
+ <div className="flex items-center gap-2 justify-start">
47
+ {thumbnail && (
48
+ <img
49
+ src={thumbnail}
50
+ alt={title || url}
51
+ className="w-10 h-10 object-cover"
52
+ />
53
+ )}
54
+ {title && (
55
+ <span
56
+ className="text-sm font-semibold text-left text-gray-800 "
57
+ title={title}
58
+ >
59
+ {title}
60
+ </span>
61
+ )}
62
+ </div>
63
+ </div>
64
+ </div>
65
+ )
66
+ }
67
+
68
+ export default Source
@@ -1,4 +1,4 @@
1
- import { useRef, useState } from "react"
1
+ import { useEffect, useRef, useState } from "react"
2
2
  import useStore from "../../utils/store"
3
3
  import { TMessage } from "../Message"
4
4
  import FileUploader from "../FileUploader"
@@ -15,12 +15,26 @@ export const Sidebar = ({
15
15
  sendPrompt: (prompt: string) => void
16
16
  handleSubmit: () => void
17
17
  }) => {
18
+ const sidebarRef = useRef<HTMLDivElement>(null)
18
19
  const inputRef = useRef<HTMLTextAreaElement>(null)
19
20
  const uploadedFiles = useStore((state) => state.uploadedFiles)
20
21
  const setUploadedFiles = useStore((state) => state.setUploadedFiles)
21
22
 
22
23
  const [isOpen, setIsOpen] = useState(false)
23
24
 
25
+ useEffect(() => {
26
+ const handleClickOutside = (e: MouseEvent) => {
27
+ if (
28
+ isOpen &&
29
+ sidebarRef.current &&
30
+ !sidebarRef.current.contains(e.target as Node)
31
+ ) {
32
+ setIsOpen(false)
33
+ }
34
+ }
35
+ document.addEventListener("mousedown", handleClickOutside)
36
+ return () => document.removeEventListener("mousedown", handleClickOutside)
37
+ }, [isOpen])
24
38
  return (
25
39
  <>
26
40
  {!isOpen && (
@@ -33,6 +47,7 @@ export const Sidebar = ({
33
47
  )}
34
48
 
35
49
  <div
50
+ ref={sidebarRef}
36
51
  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 border-C8DBFC overflow-y-auto scrollbar-hide p-6 transition-transform duration-300 ease-in-out lg:relative lg:transform-none lg:w-1/3 ${
37
52
  isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"
38
53
  }`}
@@ -81,16 +96,16 @@ export const Sidebar = ({
81
96
  onKeyUp={(e) => {
82
97
  if (e.key === "Enter" && !e.shiftKey) {
83
98
  e.preventDefault()
84
- sendPrompt(inputRef.current?.value || "")
99
+ const val = inputRef.current?.value.trim() || ""
100
+
101
+ if (val.toLowerCase() === "ok") {
102
+ handleSubmit()
103
+ } else {
104
+ sendPrompt(val)
105
+ }
106
+
85
107
  inputRef.current!.value = ""
86
108
  }
87
- if (
88
- e.key.toLowerCase() === "k" &&
89
- inputRef.current?.value.toLowerCase().trim() === "ok"
90
- ) {
91
- e.preventDefault()
92
- handleSubmit()
93
- }
94
109
  }}
95
110
  />
96
111
  <div className="absolute bottom-2 right-2 flex gap-1 items-center">
@@ -104,7 +119,17 @@ export const Sidebar = ({
104
119
 
105
120
  <button
106
121
  className="cursor-pointer blue-on-hover flex items-center justify-center w-6 h-6"
107
- onClick={() => sendPrompt(inputRef.current?.value || "")}
122
+ onClick={() => {
123
+ if (inputRef.current?.value) {
124
+ const val = inputRef.current?.value.trim() || ""
125
+ if (val.toLowerCase() === "ok") {
126
+ handleSubmit()
127
+ } else {
128
+ sendPrompt(val)
129
+ }
130
+ inputRef.current!.value = ""
131
+ }
132
+ }}
108
133
  >
109
134
  {SVGS.send}
110
135
  </button>
@@ -28,6 +28,7 @@ import { Sidebar } from "./Sidebar"
28
28
  import Login from "../Login"
29
29
  import { eventBus } from "../../utils/eventBus"
30
30
  import { useNavigate } from "react-router"
31
+ import PreviewGenerator from "../PreviewGenerator"
31
32
 
32
33
  const SyllabusEditor: React.FC = () => {
33
34
  const navigate = useNavigate()
@@ -115,6 +116,7 @@ const SyllabusEditor: React.FC = () => {
115
116
  courseInfo: {
116
117
  ...syllabus.courseInfo,
117
118
  title: res.parsed.title || syllabus.courseInfo.title,
119
+ description: res.parsed.description || syllabus.courseInfo.description,
118
120
  },
119
121
  })
120
122
  setMessages((prev) => {
@@ -135,6 +137,7 @@ const SyllabusEditor: React.FC = () => {
135
137
  toast.error("Please provide a title for the course")
136
138
  return
137
139
  }
140
+ setIsGenerating(true)
138
141
 
139
142
  let tokenToUse = auth.rigoToken
140
143
  const onValidRigoToken = (rigotoken: string) => {
@@ -201,14 +204,19 @@ const SyllabusEditor: React.FC = () => {
201
204
 
202
205
  if (!syllabus) return null
203
206
 
207
+ console.log(auth.user)
208
+
204
209
  return isGenerating ? (
205
- <Loader
206
- listeningTo="course-generation"
207
- icon={<img src={"rigo-float.gif"} alt="rigo" className="w-20 h-20" />}
208
- initialBuffer="🚀 Starting course generation..."
209
- text="Learnpack is setting up your tutorial.
210
+ <>
211
+ <Loader
212
+ listeningTo="course-generation"
213
+ icon={<img src={"rigo-float.gif"} alt="rigo" className="w-20 h-20" />}
214
+ initialBuffer="🚀 Starting course generation..."
215
+ text="Learnpack is setting up your tutorial.
210
216
  It may take a moment..."
211
- />
217
+ />
218
+ <PreviewGenerator />
219
+ </>
212
220
  ) : (
213
221
  <div className="flex w-full bg-white rounded-md shadow-md overflow-hidden h-screen ">
214
222
  {showLoginModal && (
@@ -226,7 +234,9 @@ It may take a moment..."
226
234
  sendPrompt={sendPrompt}
227
235
  handleSubmit={handleSubmit}
228
236
  />
229
-
237
+ <button onClick={() => setShowLoginModal(true)}>
238
+ Mostrar modal de Login
239
+ </button>
230
240
  <ContentIndex
231
241
  handleSubmit={handleSubmit}
232
242
  messages={messages}
@@ -44,6 +44,10 @@ h1 {
44
44
  background-color: var(--soft-blue);
45
45
  }
46
46
 
47
+ .bg-learnpack {
48
+ background-color: var(--learnpack-blue);
49
+ }
50
+
47
51
  .bg-gray-blue {
48
52
  background-color: #e7f1ff;
49
53
  }
@@ -5,6 +5,7 @@ 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
+ import PreviewGenerator from "./components/PreviewGenerator.tsx"
8
9
  createRoot(document.getElementById("root")!).render(
9
10
  <StrictMode>
10
11
  <Toaster />
@@ -12,6 +13,7 @@ createRoot(document.getElementById("root")!).render(
12
13
  <Routes>
13
14
  <Route path="/creator" element={<App />} />
14
15
  <Route path="/creator/syllabus" element={<SyllabusEditor />} />
16
+ <Route path="/creator/image" element={<PreviewGenerator />} />
15
17
  </Routes>
16
18
  </BrowserRouter>
17
19
  </StrictMode>
@@ -30,6 +30,7 @@ export const createLearnJson = (courseInfo: FormState) => {
30
30
  telemetry: {
31
31
  batch: "https://breathecode.herokuapp.com/v1/assignment/me/telemetry",
32
32
  },
33
+ preview: "preview.png",
33
34
  }
34
35
  return learnJson
35
36
  }
@@ -1,6 +1,7 @@
1
1
  import { create } from "zustand"
2
2
  import { persist } from "zustand/middleware"
3
3
  import { Lesson } from "../components/LessonItem"
4
+ import { ParsedLink } from "../components/LinkUploader"
4
5
 
5
6
  export type FormState = {
6
7
  description: string
@@ -8,6 +9,7 @@ export type FormState = {
8
9
  targetAudience: string
9
10
  hasContentIndex: boolean
10
11
  contentIndex: string
12
+ sources: ParsedLink[]
11
13
  isCompleted: boolean
12
14
  variables: string[]
13
15
  currentStep: string
@@ -66,12 +68,13 @@ const useStore = create<Store>()(
66
68
  targetAudience: "",
67
69
  hasContentIndex: false,
68
70
  contentIndex: "",
71
+ sources: [],
69
72
  isCompleted: false,
70
73
  currentStep: "description",
71
74
  variables: [
72
75
  "description",
73
76
  "duration",
74
- "targetAudience",
77
+ // "targetAudience",
75
78
  "hasContentIndex",
76
79
  ],
77
80
  },