@hydralms/components 0.3.0 → 0.3.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/StudentProfile-BPsZBaJj.cjs +1 -0
- package/dist/{StudentProfile-DeMxdrL3.js → StudentProfile-Cw2p-RZn.js} +577 -579
- package/dist/index.cjs +1 -1
- package/dist/index.js +172 -166
- package/dist/license/index.d.ts +2 -2
- package/dist/license/tiers.d.ts +3 -0
- package/dist/modules.cjs +1 -1
- package/dist/modules.js +111 -110
- package/dist/sections/AdaptiveLearningPath/AdaptiveLearningPath.d.ts +5 -0
- package/dist/sections/AdaptiveLearningPath/path-connector.d.ts +8 -0
- package/dist/sections/AdaptiveLearningPath/path-milestone-marker.d.ts +7 -0
- package/dist/sections/AdaptiveLearningPath/path-node-card.d.ts +10 -0
- package/dist/sections/AdaptiveLearningPath/path-skill-bar.d.ts +8 -0
- package/dist/sections/AdaptiveLearningPath/types.d.ts +136 -0
- package/dist/sections/ContentAuthoringStudio/ContentAuthoringStudio.d.ts +5 -0
- package/dist/sections/ContentAuthoringStudio/block-editor-item.d.ts +14 -0
- package/dist/sections/ContentAuthoringStudio/block-type-picker.d.ts +12 -0
- package/dist/sections/ContentAuthoringStudio/types.d.ts +67 -0
- package/dist/sections/index.d.ts +4 -0
- package/dist/sections.cjs +1 -1
- package/dist/sections.js +1325 -232
- package/dist/withProGate-BJdu1T9Y.cjs +2 -0
- package/dist/withProGate-BvFc7Jwy.js +4975 -0
- package/package.json +24 -7
- package/src/license/index.ts +2 -2
- package/src/license/tiers.ts +12 -2
- package/src/modules/CoursePlayer/CoursePlayer.tsx +3 -1
- package/src/progress/stat-card.tsx +10 -5
- package/src/sections/AdaptiveLearningPath/AdaptiveLearningPath.tsx +251 -0
- package/src/sections/AdaptiveLearningPath/path-connector.tsx +27 -0
- package/src/sections/AdaptiveLearningPath/path-milestone-marker.tsx +50 -0
- package/src/sections/AdaptiveLearningPath/path-node-card.tsx +166 -0
- package/src/sections/AdaptiveLearningPath/path-skill-bar.tsx +49 -0
- package/src/sections/AdaptiveLearningPath/types.ts +159 -0
- package/src/sections/ContentAuthoringStudio/ContentAuthoringStudio.tsx +289 -0
- package/src/sections/ContentAuthoringStudio/block-editor-item.tsx +487 -0
- package/src/sections/ContentAuthoringStudio/block-type-picker.tsx +123 -0
- package/src/sections/ContentAuthoringStudio/types.ts +67 -0
- package/src/sections/ForumBoard/ForumBoard.tsx +8 -6
- package/src/sections/LessonPage/LessonPage.tsx +4 -7
- package/src/sections/index.ts +18 -0
- package/src/video/video-player.tsx +14 -5
- package/dist/StudentProfile-BVfZMbnV.cjs +0 -1
- package/dist/tabs-BsfVo2Bl.cjs +0 -173
- package/dist/tabs-BuY1iNJE.js +0 -22305
- package/dist/withProGate-BWqcKdPM.js +0 -137
- package/dist/withProGate-DX6XqKLp.cjs +0 -1
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { memo, useCallback } from "react";
|
|
2
|
+
import {
|
|
3
|
+
GripVertical,
|
|
4
|
+
ChevronDown,
|
|
5
|
+
ChevronRight,
|
|
6
|
+
Trash2,
|
|
7
|
+
Copy,
|
|
8
|
+
} from "lucide-react";
|
|
9
|
+
import { Button } from "../../ui/button";
|
|
10
|
+
import { Input } from "../../ui/input";
|
|
11
|
+
import { Badge } from "../../ui/badge";
|
|
12
|
+
import { RichTextEditor } from "../../ui/rich-text-editor";
|
|
13
|
+
import { Separator } from "../../ui/separator";
|
|
14
|
+
import { cn } from "../../lib/utils";
|
|
15
|
+
import type { LessonBlock } from "../../content/types";
|
|
16
|
+
import type { AuthoringBlock } from "./types";
|
|
17
|
+
import { getBlockIcon, getBlockLabel } from "./block-type-picker";
|
|
18
|
+
|
|
19
|
+
interface BlockEditorItemProps {
|
|
20
|
+
item: AuthoringBlock;
|
|
21
|
+
onChange: (id: string, block: LessonBlock) => void;
|
|
22
|
+
onRemove: (id: string) => void;
|
|
23
|
+
onDuplicate: (id: string) => void;
|
|
24
|
+
onToggleCollapse: (id: string) => void;
|
|
25
|
+
dragProps: Record<string, unknown>;
|
|
26
|
+
isDragging: boolean;
|
|
27
|
+
isDragOver: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const BlockEditorItem = memo(function BlockEditorItem({
|
|
31
|
+
item,
|
|
32
|
+
onChange,
|
|
33
|
+
onRemove,
|
|
34
|
+
onDuplicate,
|
|
35
|
+
onToggleCollapse,
|
|
36
|
+
dragProps,
|
|
37
|
+
isDragging,
|
|
38
|
+
isDragOver,
|
|
39
|
+
}: BlockEditorItemProps) {
|
|
40
|
+
const { id, block, collapsed } = item;
|
|
41
|
+
const Icon = getBlockIcon(block.type);
|
|
42
|
+
const label = getBlockLabel(block.type);
|
|
43
|
+
|
|
44
|
+
const handleChange = useCallback(
|
|
45
|
+
(updated: LessonBlock) => onChange(id, updated),
|
|
46
|
+
[id, onChange],
|
|
47
|
+
);
|
|
48
|
+
const handleRemove = useCallback(() => onRemove(id), [id, onRemove]);
|
|
49
|
+
const handleDuplicate = useCallback(() => onDuplicate(id), [id, onDuplicate]);
|
|
50
|
+
const handleToggle = useCallback(() => onToggleCollapse(id), [id, onToggleCollapse]);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
{...dragProps}
|
|
55
|
+
className={cn(
|
|
56
|
+
"group rounded-lg border border-border bg-background transition-all",
|
|
57
|
+
isDragging && "opacity-50",
|
|
58
|
+
isDragOver && "border-primary shadow-sm",
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
{/* Header bar */}
|
|
62
|
+
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/50">
|
|
63
|
+
<span
|
|
64
|
+
className="cursor-grab text-muted-foreground/50 hover:text-muted-foreground"
|
|
65
|
+
aria-label="Drag to reorder"
|
|
66
|
+
>
|
|
67
|
+
<GripVertical className="size-4" />
|
|
68
|
+
</span>
|
|
69
|
+
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={handleToggle}
|
|
73
|
+
className="text-muted-foreground hover:text-foreground p-0.5"
|
|
74
|
+
>
|
|
75
|
+
{collapsed ? (
|
|
76
|
+
<ChevronRight className="size-3.5" />
|
|
77
|
+
) : (
|
|
78
|
+
<ChevronDown className="size-3.5" />
|
|
79
|
+
)}
|
|
80
|
+
</button>
|
|
81
|
+
|
|
82
|
+
<Badge variant="secondary" className="text-[10px] gap-1 px-1.5 py-0">
|
|
83
|
+
<Icon className="size-3" />
|
|
84
|
+
{label}
|
|
85
|
+
</Badge>
|
|
86
|
+
|
|
87
|
+
<div className="flex-1" />
|
|
88
|
+
|
|
89
|
+
<Button
|
|
90
|
+
variant="ghost"
|
|
91
|
+
size="sm"
|
|
92
|
+
onClick={handleDuplicate}
|
|
93
|
+
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 text-muted-foreground"
|
|
94
|
+
>
|
|
95
|
+
<Copy className="size-3" />
|
|
96
|
+
</Button>
|
|
97
|
+
<Button
|
|
98
|
+
variant="ghost"
|
|
99
|
+
size="sm"
|
|
100
|
+
onClick={handleRemove}
|
|
101
|
+
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 text-destructive"
|
|
102
|
+
>
|
|
103
|
+
<Trash2 className="size-3" />
|
|
104
|
+
</Button>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{/* Editor body */}
|
|
108
|
+
{!collapsed && (
|
|
109
|
+
<div className="p-3">
|
|
110
|
+
<BlockEditor block={block} onChange={handleChange} />
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/* ── Per-type editors ──────────────────────────────────── */
|
|
118
|
+
|
|
119
|
+
function BlockEditor({
|
|
120
|
+
block,
|
|
121
|
+
onChange,
|
|
122
|
+
}: {
|
|
123
|
+
block: LessonBlock;
|
|
124
|
+
onChange: (block: LessonBlock) => void;
|
|
125
|
+
}) {
|
|
126
|
+
switch (block.type) {
|
|
127
|
+
case "richtext":
|
|
128
|
+
return (
|
|
129
|
+
<RichTextEditor
|
|
130
|
+
value={block.html}
|
|
131
|
+
onChange={(html) => onChange({ ...block, html })}
|
|
132
|
+
placeholder="Write your content..."
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
case "heading":
|
|
137
|
+
return (
|
|
138
|
+
<div className="flex gap-2">
|
|
139
|
+
<select
|
|
140
|
+
value={block.level ?? 2}
|
|
141
|
+
onChange={(e) =>
|
|
142
|
+
onChange({ ...block, level: Number(e.target.value) as 1 | 2 | 3 })
|
|
143
|
+
}
|
|
144
|
+
className="h-9 rounded-md border border-input bg-transparent px-2 text-sm"
|
|
145
|
+
>
|
|
146
|
+
<option value={1}>H1</option>
|
|
147
|
+
<option value={2}>H2</option>
|
|
148
|
+
<option value={3}>H3</option>
|
|
149
|
+
</select>
|
|
150
|
+
<Input
|
|
151
|
+
value={block.text}
|
|
152
|
+
onChange={(e) => onChange({ ...block, text: e.target.value })}
|
|
153
|
+
placeholder="Heading text..."
|
|
154
|
+
className="flex-1"
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
case "image":
|
|
160
|
+
return (
|
|
161
|
+
<div className="space-y-2">
|
|
162
|
+
<Input
|
|
163
|
+
value={block.src}
|
|
164
|
+
onChange={(e) => onChange({ ...block, src: e.target.value })}
|
|
165
|
+
placeholder="Image URL..."
|
|
166
|
+
/>
|
|
167
|
+
<div className="flex gap-2">
|
|
168
|
+
<Input
|
|
169
|
+
value={block.alt ?? ""}
|
|
170
|
+
onChange={(e) => onChange({ ...block, alt: e.target.value })}
|
|
171
|
+
placeholder="Alt text..."
|
|
172
|
+
className="flex-1"
|
|
173
|
+
/>
|
|
174
|
+
<Input
|
|
175
|
+
value={block.caption ?? ""}
|
|
176
|
+
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
|
177
|
+
placeholder="Caption..."
|
|
178
|
+
className="flex-1"
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
{block.src && (
|
|
182
|
+
<img
|
|
183
|
+
src={block.src}
|
|
184
|
+
alt={block.alt ?? ""}
|
|
185
|
+
className="max-h-40 rounded-md object-cover"
|
|
186
|
+
/>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
case "video":
|
|
192
|
+
return (
|
|
193
|
+
<div className="space-y-2">
|
|
194
|
+
<Input
|
|
195
|
+
value={block.video.src ?? ""}
|
|
196
|
+
onChange={(e) =>
|
|
197
|
+
onChange({ ...block, video: { ...block.video, src: e.target.value } })
|
|
198
|
+
}
|
|
199
|
+
placeholder="Video URL..."
|
|
200
|
+
/>
|
|
201
|
+
<Input
|
|
202
|
+
value={block.video.title ?? ""}
|
|
203
|
+
onChange={(e) =>
|
|
204
|
+
onChange({ ...block, video: { ...block.video, title: e.target.value } })
|
|
205
|
+
}
|
|
206
|
+
placeholder="Video title..."
|
|
207
|
+
/>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
case "code":
|
|
212
|
+
return (
|
|
213
|
+
<div className="space-y-2">
|
|
214
|
+
<div className="flex gap-2">
|
|
215
|
+
<Input
|
|
216
|
+
value={block.language ?? ""}
|
|
217
|
+
onChange={(e) => onChange({ ...block, language: e.target.value })}
|
|
218
|
+
placeholder="Language (e.g. javascript)"
|
|
219
|
+
className="w-40"
|
|
220
|
+
/>
|
|
221
|
+
<Input
|
|
222
|
+
value={block.filename ?? ""}
|
|
223
|
+
onChange={(e) => onChange({ ...block, filename: e.target.value })}
|
|
224
|
+
placeholder="Filename (optional)"
|
|
225
|
+
className="flex-1"
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
<textarea
|
|
229
|
+
value={block.code}
|
|
230
|
+
onChange={(e) => onChange({ ...block, code: e.target.value })}
|
|
231
|
+
placeholder="Paste your code..."
|
|
232
|
+
className="w-full min-h-24 rounded-md border border-input bg-muted p-3 font-mono text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
|
|
233
|
+
spellCheck={false}
|
|
234
|
+
/>
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
case "callout":
|
|
239
|
+
return (
|
|
240
|
+
<div className="space-y-2">
|
|
241
|
+
<select
|
|
242
|
+
value={block.variant ?? "info"}
|
|
243
|
+
onChange={(e) =>
|
|
244
|
+
onChange({
|
|
245
|
+
...block,
|
|
246
|
+
variant: e.target.value as "info" | "warning" | "tip",
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
className="h-9 rounded-md border border-input bg-transparent px-2 text-sm"
|
|
250
|
+
>
|
|
251
|
+
<option value="info">Info</option>
|
|
252
|
+
<option value="warning">Warning</option>
|
|
253
|
+
<option value="tip">Tip</option>
|
|
254
|
+
</select>
|
|
255
|
+
<Input
|
|
256
|
+
value={block.content}
|
|
257
|
+
onChange={(e) => onChange({ ...block, content: e.target.value })}
|
|
258
|
+
placeholder="Callout text..."
|
|
259
|
+
/>
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
case "audio":
|
|
264
|
+
return (
|
|
265
|
+
<div className="space-y-2">
|
|
266
|
+
<Input
|
|
267
|
+
value={block.src}
|
|
268
|
+
onChange={(e) => onChange({ ...block, src: e.target.value })}
|
|
269
|
+
placeholder="Audio URL..."
|
|
270
|
+
/>
|
|
271
|
+
<Input
|
|
272
|
+
value={block.title ?? ""}
|
|
273
|
+
onChange={(e) => onChange({ ...block, title: e.target.value })}
|
|
274
|
+
placeholder="Title (optional)"
|
|
275
|
+
/>
|
|
276
|
+
</div>
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
case "embed":
|
|
280
|
+
return (
|
|
281
|
+
<div className="space-y-2">
|
|
282
|
+
<Input
|
|
283
|
+
value={block.src}
|
|
284
|
+
onChange={(e) => onChange({ ...block, src: e.target.value })}
|
|
285
|
+
placeholder="Embed URL..."
|
|
286
|
+
/>
|
|
287
|
+
<div className="flex gap-2">
|
|
288
|
+
<Input
|
|
289
|
+
value={block.title ?? ""}
|
|
290
|
+
onChange={(e) => onChange({ ...block, title: e.target.value })}
|
|
291
|
+
placeholder="Title (optional)"
|
|
292
|
+
className="flex-1"
|
|
293
|
+
/>
|
|
294
|
+
<select
|
|
295
|
+
value={block.aspectRatio ?? "16/9"}
|
|
296
|
+
onChange={(e) =>
|
|
297
|
+
onChange({
|
|
298
|
+
...block,
|
|
299
|
+
aspectRatio: e.target.value as "16/9" | "4/3" | "1/1",
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
className="h-9 rounded-md border border-input bg-transparent px-2 text-sm"
|
|
303
|
+
>
|
|
304
|
+
<option value="16/9">16:9</option>
|
|
305
|
+
<option value="4/3">4:3</option>
|
|
306
|
+
<option value="1/1">1:1</option>
|
|
307
|
+
</select>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
case "table":
|
|
313
|
+
return <TableEditor block={block} onChange={onChange} />;
|
|
314
|
+
|
|
315
|
+
case "file":
|
|
316
|
+
return (
|
|
317
|
+
<p className="text-sm text-muted-foreground">
|
|
318
|
+
File attachments are managed via the <code className="text-xs">files</code> prop.
|
|
319
|
+
{block.files.length > 0 && (
|
|
320
|
+
<span className="ml-1">
|
|
321
|
+
({block.files.length} file{block.files.length !== 1 && "s"} attached)
|
|
322
|
+
</span>
|
|
323
|
+
)}
|
|
324
|
+
</p>
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
case "divider":
|
|
328
|
+
return <Separator />;
|
|
329
|
+
|
|
330
|
+
case "question":
|
|
331
|
+
case "flashcards":
|
|
332
|
+
return (
|
|
333
|
+
<p className="text-sm text-muted-foreground italic">
|
|
334
|
+
Interactive {block.type} blocks are configured via props.
|
|
335
|
+
</p>
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
case "custom":
|
|
339
|
+
return (
|
|
340
|
+
<p className="text-sm text-muted-foreground italic">
|
|
341
|
+
Custom blocks are not editable in the authoring studio.
|
|
342
|
+
</p>
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
default:
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/* ── Table editor ──────────────────────────────────────── */
|
|
351
|
+
|
|
352
|
+
function TableEditor({
|
|
353
|
+
block,
|
|
354
|
+
onChange,
|
|
355
|
+
}: {
|
|
356
|
+
block: Extract<LessonBlock, { type: "table" }>;
|
|
357
|
+
onChange: (block: LessonBlock) => void;
|
|
358
|
+
}) {
|
|
359
|
+
const { headers, rows, caption } = block;
|
|
360
|
+
|
|
361
|
+
function updateHeader(colIndex: number, value: string) {
|
|
362
|
+
const next = [...headers];
|
|
363
|
+
next[colIndex] = value;
|
|
364
|
+
onChange({ ...block, headers: next });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function updateCell(rowIndex: number, colIndex: number, value: string) {
|
|
368
|
+
const next = rows.map((row) => [...row]);
|
|
369
|
+
next[rowIndex][colIndex] = value;
|
|
370
|
+
onChange({ ...block, rows: next });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function addColumn() {
|
|
374
|
+
onChange({
|
|
375
|
+
...block,
|
|
376
|
+
headers: [...headers, ""],
|
|
377
|
+
rows: rows.map((row) => [...row, ""]),
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function removeColumn(colIndex: number) {
|
|
382
|
+
if (headers.length <= 1) return;
|
|
383
|
+
onChange({
|
|
384
|
+
...block,
|
|
385
|
+
headers: headers.filter((_, i) => i !== colIndex),
|
|
386
|
+
rows: rows.map((row) => row.filter((_, i) => i !== colIndex)),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function addRow() {
|
|
391
|
+
onChange({
|
|
392
|
+
...block,
|
|
393
|
+
rows: [...rows, headers.map(() => "")],
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function removeRow(rowIndex: number) {
|
|
398
|
+
onChange({
|
|
399
|
+
...block,
|
|
400
|
+
rows: rows.filter((_, i) => i !== rowIndex),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return (
|
|
405
|
+
<div className="space-y-2">
|
|
406
|
+
<Input
|
|
407
|
+
value={caption ?? ""}
|
|
408
|
+
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
|
409
|
+
placeholder="Table caption (optional)"
|
|
410
|
+
className="text-sm"
|
|
411
|
+
/>
|
|
412
|
+
<div className="overflow-x-auto">
|
|
413
|
+
<table className="w-full text-sm">
|
|
414
|
+
<thead>
|
|
415
|
+
<tr>
|
|
416
|
+
{headers.map((header, i) => (
|
|
417
|
+
<th key={i} className="p-1">
|
|
418
|
+
<div className="flex gap-0.5">
|
|
419
|
+
<Input
|
|
420
|
+
value={header}
|
|
421
|
+
onChange={(e) => updateHeader(i, e.target.value)}
|
|
422
|
+
placeholder={`Col ${i + 1}`}
|
|
423
|
+
className="h-7 text-xs font-semibold"
|
|
424
|
+
/>
|
|
425
|
+
{headers.length > 1 && (
|
|
426
|
+
<Button
|
|
427
|
+
variant="ghost"
|
|
428
|
+
size="sm"
|
|
429
|
+
onClick={() => removeColumn(i)}
|
|
430
|
+
className="h-7 w-7 p-0 text-destructive shrink-0"
|
|
431
|
+
>
|
|
432
|
+
<Trash2 className="size-3" />
|
|
433
|
+
</Button>
|
|
434
|
+
)}
|
|
435
|
+
</div>
|
|
436
|
+
</th>
|
|
437
|
+
))}
|
|
438
|
+
<th className="p-1 w-8">
|
|
439
|
+
<Button
|
|
440
|
+
variant="ghost"
|
|
441
|
+
size="sm"
|
|
442
|
+
onClick={addColumn}
|
|
443
|
+
className="h-7 w-7 p-0 text-muted-foreground"
|
|
444
|
+
>
|
|
445
|
+
+
|
|
446
|
+
</Button>
|
|
447
|
+
</th>
|
|
448
|
+
</tr>
|
|
449
|
+
</thead>
|
|
450
|
+
<tbody>
|
|
451
|
+
{rows.map((row, ri) => (
|
|
452
|
+
<tr key={ri}>
|
|
453
|
+
{row.map((cell, ci) => (
|
|
454
|
+
<td key={ci} className="p-1">
|
|
455
|
+
<Input
|
|
456
|
+
value={cell}
|
|
457
|
+
onChange={(e) => updateCell(ri, ci, e.target.value)}
|
|
458
|
+
className="h-7 text-xs"
|
|
459
|
+
/>
|
|
460
|
+
</td>
|
|
461
|
+
))}
|
|
462
|
+
<td className="p-1">
|
|
463
|
+
<Button
|
|
464
|
+
variant="ghost"
|
|
465
|
+
size="sm"
|
|
466
|
+
onClick={() => removeRow(ri)}
|
|
467
|
+
className="h-7 w-7 p-0 text-destructive"
|
|
468
|
+
>
|
|
469
|
+
<Trash2 className="size-3" />
|
|
470
|
+
</Button>
|
|
471
|
+
</td>
|
|
472
|
+
</tr>
|
|
473
|
+
))}
|
|
474
|
+
</tbody>
|
|
475
|
+
</table>
|
|
476
|
+
</div>
|
|
477
|
+
<Button
|
|
478
|
+
variant="ghost"
|
|
479
|
+
size="sm"
|
|
480
|
+
onClick={addRow}
|
|
481
|
+
className="text-xs text-muted-foreground"
|
|
482
|
+
>
|
|
483
|
+
+ Add row
|
|
484
|
+
</Button>
|
|
485
|
+
</div>
|
|
486
|
+
);
|
|
487
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Type,
|
|
4
|
+
Heading,
|
|
5
|
+
Image,
|
|
6
|
+
Video,
|
|
7
|
+
Code,
|
|
8
|
+
MessageSquare,
|
|
9
|
+
Music,
|
|
10
|
+
Globe,
|
|
11
|
+
Table,
|
|
12
|
+
Paperclip,
|
|
13
|
+
Minus,
|
|
14
|
+
Plus,
|
|
15
|
+
} from "lucide-react";
|
|
16
|
+
import { Button } from "../../ui/button";
|
|
17
|
+
import { cn } from "../../lib/utils";
|
|
18
|
+
import type { LessonBlock } from "../../content/types";
|
|
19
|
+
import type { BlockTypeOption } from "./types";
|
|
20
|
+
|
|
21
|
+
const ALL_BLOCK_TYPES: BlockTypeOption[] = [
|
|
22
|
+
{ type: "richtext", label: "Rich Text", description: "Formatted text content" },
|
|
23
|
+
{ type: "heading", label: "Heading", description: "Section heading" },
|
|
24
|
+
{ type: "image", label: "Image", description: "Image with caption" },
|
|
25
|
+
{ type: "video", label: "Video", description: "Video player" },
|
|
26
|
+
{ type: "code", label: "Code", description: "Code snippet" },
|
|
27
|
+
{ type: "callout", label: "Callout", description: "Info, warning, or tip" },
|
|
28
|
+
{ type: "audio", label: "Audio", description: "Audio player" },
|
|
29
|
+
{ type: "embed", label: "Embed", description: "External embed" },
|
|
30
|
+
{ type: "table", label: "Table", description: "Data table" },
|
|
31
|
+
{ type: "file", label: "Files", description: "File attachments" },
|
|
32
|
+
{ type: "divider", label: "Divider", description: "Horizontal separator" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
36
|
+
richtext: Type,
|
|
37
|
+
heading: Heading,
|
|
38
|
+
image: Image,
|
|
39
|
+
video: Video,
|
|
40
|
+
code: Code,
|
|
41
|
+
callout: MessageSquare,
|
|
42
|
+
audio: Music,
|
|
43
|
+
embed: Globe,
|
|
44
|
+
table: Table,
|
|
45
|
+
file: Paperclip,
|
|
46
|
+
divider: Minus,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function getBlockIcon(type: string) {
|
|
50
|
+
return BLOCK_ICONS[type] ?? Type;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getBlockLabel(type: string) {
|
|
54
|
+
return ALL_BLOCK_TYPES.find((b) => b.type === type)?.label ?? type;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface BlockTypePickerProps {
|
|
58
|
+
onSelect: (type: LessonBlock["type"]) => void;
|
|
59
|
+
allowedTypes?: LessonBlock["type"][];
|
|
60
|
+
className?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function BlockTypePicker({
|
|
64
|
+
onSelect,
|
|
65
|
+
allowedTypes,
|
|
66
|
+
className,
|
|
67
|
+
}: BlockTypePickerProps) {
|
|
68
|
+
const [open, setOpen] = useState(false);
|
|
69
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
70
|
+
|
|
71
|
+
const types = allowedTypes
|
|
72
|
+
? ALL_BLOCK_TYPES.filter((t) => allowedTypes.includes(t.type))
|
|
73
|
+
: ALL_BLOCK_TYPES;
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!open) return;
|
|
77
|
+
function handleClick(e: MouseEvent) {
|
|
78
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
79
|
+
setOpen(false);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
document.addEventListener("mousedown", handleClick);
|
|
83
|
+
return () => document.removeEventListener("mousedown", handleClick);
|
|
84
|
+
}, [open]);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className={cn("relative", className)} ref={ref}>
|
|
88
|
+
<Button
|
|
89
|
+
variant="ghost"
|
|
90
|
+
size="sm"
|
|
91
|
+
onClick={() => setOpen(!open)}
|
|
92
|
+
className="text-muted-foreground hover:text-foreground gap-1 h-7 text-xs"
|
|
93
|
+
>
|
|
94
|
+
<Plus className="size-3.5" />
|
|
95
|
+
Add block
|
|
96
|
+
</Button>
|
|
97
|
+
|
|
98
|
+
{open && (
|
|
99
|
+
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1 z-50 w-72 rounded-lg border border-border bg-background shadow-lg p-2">
|
|
100
|
+
<div className="grid grid-cols-3 gap-1">
|
|
101
|
+
{types.map(({ type, label }) => {
|
|
102
|
+
const Icon = BLOCK_ICONS[type] ?? Type;
|
|
103
|
+
return (
|
|
104
|
+
<button
|
|
105
|
+
key={type}
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={() => {
|
|
108
|
+
onSelect(type);
|
|
109
|
+
setOpen(false);
|
|
110
|
+
}}
|
|
111
|
+
className="flex flex-col items-center gap-1 rounded-md p-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
|
112
|
+
>
|
|
113
|
+
<Icon className="size-4" />
|
|
114
|
+
<span>{label}</span>
|
|
115
|
+
</button>
|
|
116
|
+
);
|
|
117
|
+
})}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { LessonBlock } from "../../content/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A single authoring block wrapping a LessonBlock with editor metadata.
|
|
5
|
+
*/
|
|
6
|
+
export interface AuthoringBlock {
|
|
7
|
+
/** Unique identifier for keying and drag operations */
|
|
8
|
+
id: string;
|
|
9
|
+
/** The underlying content block data */
|
|
10
|
+
block: LessonBlock;
|
|
11
|
+
/** Whether this block is collapsed in the editor UI */
|
|
12
|
+
collapsed?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Metadata for a block type option in the picker.
|
|
17
|
+
*/
|
|
18
|
+
export interface BlockTypeOption {
|
|
19
|
+
type: LessonBlock["type"];
|
|
20
|
+
label: string;
|
|
21
|
+
description: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* ContentAuthoringStudio is a premium block-based content editor
|
|
26
|
+
* for creating lessons. It produces the same `LessonBlock[]` data
|
|
27
|
+
* model that `LessonPage` renders, providing a WYSIWYG authoring
|
|
28
|
+
* experience with drag-and-drop reordering, per-block editors,
|
|
29
|
+
* and live preview.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* <ContentAuthoringStudio
|
|
33
|
+
* title="React Hooks Deep Dive"
|
|
34
|
+
* blocks={lessonBlocks}
|
|
35
|
+
* onBlocksChange={setLessonBlocks}
|
|
36
|
+
* onTitleChange={setTitle}
|
|
37
|
+
* onSave={({ title, blocks }) => api.saveLesson(title, blocks)}
|
|
38
|
+
* />
|
|
39
|
+
*/
|
|
40
|
+
export interface ContentAuthoringStudioProps {
|
|
41
|
+
/** Lesson title (editable in edit mode) */
|
|
42
|
+
title?: string;
|
|
43
|
+
/** Initial content blocks */
|
|
44
|
+
blocks?: LessonBlock[];
|
|
45
|
+
/** Called when blocks change (add, edit, remove, reorder) */
|
|
46
|
+
onBlocksChange?: (blocks: LessonBlock[]) => void;
|
|
47
|
+
/** Called when the title changes */
|
|
48
|
+
onTitleChange?: (title: string) => void;
|
|
49
|
+
/** Called when the user clicks Save */
|
|
50
|
+
onSave?: (data: { title: string; blocks: LessonBlock[] }) => void;
|
|
51
|
+
/** Show the edit/preview toggle. @default true */
|
|
52
|
+
showPreviewToggle?: boolean;
|
|
53
|
+
/** Restrict available block types. Defaults to all types. */
|
|
54
|
+
allowedBlockTypes?: LessonBlock["type"][];
|
|
55
|
+
/** When true, shows preview only (no editing controls). @default false */
|
|
56
|
+
readOnly?: boolean;
|
|
57
|
+
/** Render skeleton placeholders instead of content */
|
|
58
|
+
isLoading?: boolean;
|
|
59
|
+
/** Error message — renders an error state with optional retry */
|
|
60
|
+
error?: string | null;
|
|
61
|
+
/** Called when the user clicks retry in the error state */
|
|
62
|
+
onRetry?: () => void;
|
|
63
|
+
/** CSS class name for the root element */
|
|
64
|
+
className?: string;
|
|
65
|
+
/** Inline styles for the root element */
|
|
66
|
+
style?: React.CSSProperties;
|
|
67
|
+
}
|