@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hydralms/components",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "React component library for LMS platforms",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "HydraLMS",
|
|
@@ -113,14 +113,31 @@
|
|
|
113
113
|
"peerDependencies": {
|
|
114
114
|
"react": "^18.0.0 || ^19.0.0",
|
|
115
115
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
116
|
-
"tailwindcss": "^4.0.0"
|
|
116
|
+
"tailwindcss": "^4.0.0",
|
|
117
|
+
"@tiptap/react": "^3.0.0",
|
|
118
|
+
"@tiptap/starter-kit": "^3.0.0",
|
|
119
|
+
"@tiptap/extension-link": "^3.0.0",
|
|
120
|
+
"@tiptap/extension-placeholder": "^3.0.0",
|
|
121
|
+
"@tiptap/extension-underline": "^3.0.0"
|
|
122
|
+
},
|
|
123
|
+
"peerDependenciesMeta": {
|
|
124
|
+
"@tiptap/react": {
|
|
125
|
+
"optional": true
|
|
126
|
+
},
|
|
127
|
+
"@tiptap/starter-kit": {
|
|
128
|
+
"optional": true
|
|
129
|
+
},
|
|
130
|
+
"@tiptap/extension-link": {
|
|
131
|
+
"optional": true
|
|
132
|
+
},
|
|
133
|
+
"@tiptap/extension-placeholder": {
|
|
134
|
+
"optional": true
|
|
135
|
+
},
|
|
136
|
+
"@tiptap/extension-underline": {
|
|
137
|
+
"optional": true
|
|
138
|
+
}
|
|
117
139
|
},
|
|
118
140
|
"dependencies": {
|
|
119
|
-
"@tiptap/extension-link": "^3.20.1",
|
|
120
|
-
"@tiptap/extension-placeholder": "^3.20.1",
|
|
121
|
-
"@tiptap/extension-underline": "^3.20.1",
|
|
122
|
-
"@tiptap/react": "^3.20.1",
|
|
123
|
-
"@tiptap/starter-kit": "^3.20.1",
|
|
124
141
|
"class-variance-authority": "^0.7.1",
|
|
125
142
|
"clsx": "^2.1.1",
|
|
126
143
|
"lucide-react": "^0.475.0",
|
package/src/license/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { HydraLicenseProvider, HydraLicenseContext } from './HydraContext';
|
|
2
2
|
export type { HydraPlan, HydraLicenseContextValue, HydraLicenseProviderProps } from './HydraContext';
|
|
3
3
|
export { useHydraLicense } from './useHydraLicense';
|
|
4
|
-
export { isProModule, PRO_MODULES } from './tiers';
|
|
5
|
-
export type { ProModuleName } from './tiers';
|
|
4
|
+
export { isProModule, PRO_MODULES, isProSection, PRO_SECTIONS } from './tiers';
|
|
5
|
+
export type { ProModuleName, ProSectionName } from './tiers';
|
|
6
6
|
export { ProBadge } from './ProBadge';
|
|
7
7
|
export { withProGate } from './withProGate';
|
package/src/license/tiers.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// ─── HydraLMS Pro Tier Definitions ──────────────────────────────
|
|
2
|
-
// All 12 modules are Pro tier.
|
|
3
|
-
//
|
|
2
|
+
// All 12 modules and premium sections are Pro tier.
|
|
3
|
+
// UI primitives, base components, and standard sections are free.
|
|
4
4
|
|
|
5
5
|
export const PRO_MODULES = new Set([
|
|
6
6
|
'QuizModule',
|
|
@@ -22,3 +22,13 @@ export type ProModuleName = typeof PRO_MODULES extends Set<infer T> ? T : never;
|
|
|
22
22
|
export function isProModule(name: string): boolean {
|
|
23
23
|
return PRO_MODULES.has(name as any);
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
export const PRO_SECTIONS = new Set([
|
|
27
|
+
'ContentAuthoringStudio',
|
|
28
|
+
] as const);
|
|
29
|
+
|
|
30
|
+
export type ProSectionName = typeof PRO_SECTIONS extends Set<infer T> ? T : never;
|
|
31
|
+
|
|
32
|
+
export function isProSection(name: string): boolean {
|
|
33
|
+
return PRO_SECTIONS.has(name as any);
|
|
34
|
+
}
|
|
@@ -76,7 +76,9 @@ function CoursePlayerBase({
|
|
|
76
76
|
() => activeItem?.type === "video"
|
|
77
77
|
? { src: activeItem.src, poster: activeItem.poster, title: activeItem.title }
|
|
78
78
|
: null,
|
|
79
|
-
|
|
79
|
+
// activeItem is looked up from a stable Map — uid change means new item
|
|
80
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
81
|
+
[activeItem?.uid],
|
|
80
82
|
);
|
|
81
83
|
|
|
82
84
|
useEffect(() => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo } from "react";
|
|
1
|
+
import { memo, useMemo } from "react";
|
|
2
2
|
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
|
3
3
|
import { Card, CardContent } from "../ui/card";
|
|
4
4
|
import type { StatCardProps } from "./types";
|
|
@@ -24,16 +24,21 @@ export const StatCard = memo(function StatCard({
|
|
|
24
24
|
}: StatCardProps) {
|
|
25
25
|
const TrendIcon = trend ? TREND_ICONS[trend.direction] : null;
|
|
26
26
|
|
|
27
|
+
const iconStyle = useMemo(
|
|
28
|
+
() => ({
|
|
29
|
+
backgroundColor: `color-mix(in oklch, ${accent ?? "var(--primary)"} 10%, transparent)`,
|
|
30
|
+
color: accent ?? "var(--primary)",
|
|
31
|
+
}),
|
|
32
|
+
[accent],
|
|
33
|
+
);
|
|
34
|
+
|
|
27
35
|
return (
|
|
28
36
|
<Card className={cn(className)} style={style}>
|
|
29
37
|
<CardContent className="p-4">
|
|
30
38
|
{icon && (
|
|
31
39
|
<div
|
|
32
40
|
className="mb-2 w-9 h-9 rounded-lg flex items-center justify-center [&>svg]:size-5"
|
|
33
|
-
style={
|
|
34
|
-
backgroundColor: `color-mix(in oklch, ${accent ?? "var(--primary)"} 10%, transparent)`,
|
|
35
|
-
color: accent ?? "var(--primary)",
|
|
36
|
-
}}
|
|
41
|
+
style={iconStyle}
|
|
37
42
|
>
|
|
38
43
|
{icon}
|
|
39
44
|
</div>
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Target,
|
|
4
|
+
BookOpen,
|
|
5
|
+
Clock,
|
|
6
|
+
TrendingUp,
|
|
7
|
+
Flame,
|
|
8
|
+
} from "lucide-react";
|
|
9
|
+
import { StatCard } from "../../progress/stat-card";
|
|
10
|
+
import { ProgressRing } from "../../progress/progress-ring";
|
|
11
|
+
import { Badge } from "../../ui/badge";
|
|
12
|
+
import { Separator } from "../../ui/separator";
|
|
13
|
+
import { Skeleton } from "../../ui/skeleton";
|
|
14
|
+
import { SectionShell } from "../_shared/section-shell";
|
|
15
|
+
import { withProGate } from "../../license/withProGate";
|
|
16
|
+
import { formatDuration } from "../../utils/format-duration";
|
|
17
|
+
import type { AdaptiveLearningPathProps, PathMilestone } from "./types";
|
|
18
|
+
import { PathNodeCard } from "./path-node-card";
|
|
19
|
+
import { PathConnector } from "./path-connector";
|
|
20
|
+
import { PathMilestoneMarker } from "./path-milestone-marker";
|
|
21
|
+
import { PathSkillBar } from "./path-skill-bar";
|
|
22
|
+
|
|
23
|
+
const DIFFICULTY_CONFIG = {
|
|
24
|
+
beginner: { label: "Beginner", variant: "success" as const },
|
|
25
|
+
intermediate: { label: "Intermediate", variant: "warning" as const },
|
|
26
|
+
advanced: { label: "Advanced", variant: "destructive" as const },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* AdaptiveLearningPath section — a premium visualization of a personalized
|
|
31
|
+
* learning sequence that adapts based on learner performance.
|
|
32
|
+
*
|
|
33
|
+
* Renders a vertical path map with nodes, connectors, milestones, skill bars,
|
|
34
|
+
* and summary statistics. Each node represents a learning activity with status,
|
|
35
|
+
* score, and recommended-next indicators.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* <AdaptiveLearningPath
|
|
39
|
+
* title="React Mastery Path"
|
|
40
|
+
* description="A personalized journey for mastering React"
|
|
41
|
+
* nodes={pathNodes}
|
|
42
|
+
* progress={pathProgress}
|
|
43
|
+
* onNodeClick={(uid) => navigate(`/activity/${uid}`)}
|
|
44
|
+
* onNodeStart={(uid) => startActivity(uid)}
|
|
45
|
+
* />
|
|
46
|
+
*/
|
|
47
|
+
function AdaptiveLearningPathBase({
|
|
48
|
+
title,
|
|
49
|
+
description,
|
|
50
|
+
difficulty,
|
|
51
|
+
nodes = [],
|
|
52
|
+
progress,
|
|
53
|
+
skills,
|
|
54
|
+
milestones,
|
|
55
|
+
onNodeClick,
|
|
56
|
+
onNodeStart,
|
|
57
|
+
onSkillClick,
|
|
58
|
+
readOnly = false,
|
|
59
|
+
isLoading,
|
|
60
|
+
error,
|
|
61
|
+
onRetry,
|
|
62
|
+
className,
|
|
63
|
+
style,
|
|
64
|
+
}: AdaptiveLearningPathProps) {
|
|
65
|
+
const milestoneMap = useMemo(() => {
|
|
66
|
+
const map = new Map<number, PathMilestone>();
|
|
67
|
+
if (milestones) {
|
|
68
|
+
for (const m of milestones) {
|
|
69
|
+
map.set(m.afterNodeIndex, m);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return map;
|
|
73
|
+
}, [milestones]);
|
|
74
|
+
|
|
75
|
+
const safeNodes = Array.isArray(nodes) ? nodes : [];
|
|
76
|
+
const safeSkills = Array.isArray(skills) ? skills : [];
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<SectionShell
|
|
80
|
+
isLoading={isLoading}
|
|
81
|
+
error={error}
|
|
82
|
+
onRetry={onRetry}
|
|
83
|
+
className={className}
|
|
84
|
+
style={style}
|
|
85
|
+
skeleton={
|
|
86
|
+
<>
|
|
87
|
+
<Skeleton className="h-8 w-64" />
|
|
88
|
+
<Skeleton className="h-4 w-96 mt-2" />
|
|
89
|
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-6">
|
|
90
|
+
{Array.from({ length: 4 }, (_, i) => (
|
|
91
|
+
<Skeleton key={i} className="h-28" />
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
<div className="mt-8 space-y-3">
|
|
95
|
+
{Array.from({ length: 5 }, (_, i) => (
|
|
96
|
+
<Skeleton key={i} className="h-20" />
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
</>
|
|
100
|
+
}
|
|
101
|
+
>
|
|
102
|
+
<div className={className} style={style}>
|
|
103
|
+
{/* Header */}
|
|
104
|
+
<div className="flex items-start justify-between gap-4 flex-wrap">
|
|
105
|
+
<div className="flex items-center gap-4">
|
|
106
|
+
<ProgressRing
|
|
107
|
+
value={progress.completionPercentage}
|
|
108
|
+
size={64}
|
|
109
|
+
strokeWidth={5}
|
|
110
|
+
className="shrink-0 text-primary"
|
|
111
|
+
/>
|
|
112
|
+
<div>
|
|
113
|
+
<h2 className="text-xl font-bold text-foreground">{title}</h2>
|
|
114
|
+
{description && (
|
|
115
|
+
<p className="text-sm text-muted-foreground mt-0.5">
|
|
116
|
+
{description}
|
|
117
|
+
</p>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
{difficulty && (
|
|
122
|
+
<Badge variant={DIFFICULTY_CONFIG[difficulty].variant}>
|
|
123
|
+
{DIFFICULTY_CONFIG[difficulty].label}
|
|
124
|
+
</Badge>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{/* Stats row */}
|
|
129
|
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-6">
|
|
130
|
+
<StatCard
|
|
131
|
+
icon={<Target />}
|
|
132
|
+
label="Progress"
|
|
133
|
+
value={`${Math.round(progress.completionPercentage)}%`}
|
|
134
|
+
subtitle="of path completed"
|
|
135
|
+
accent="var(--primary)"
|
|
136
|
+
/>
|
|
137
|
+
<StatCard
|
|
138
|
+
icon={<BookOpen />}
|
|
139
|
+
label="Completed"
|
|
140
|
+
value={`${progress.completedNodes}/${progress.totalNodes}`}
|
|
141
|
+
subtitle="activities"
|
|
142
|
+
accent="var(--success)"
|
|
143
|
+
/>
|
|
144
|
+
<StatCard
|
|
145
|
+
icon={<Clock />}
|
|
146
|
+
label="Time Spent"
|
|
147
|
+
value={formatDuration(progress.totalTimeSpent)}
|
|
148
|
+
subtitle="total"
|
|
149
|
+
accent="var(--info)"
|
|
150
|
+
/>
|
|
151
|
+
{progress.averageScore != null ? (
|
|
152
|
+
<StatCard
|
|
153
|
+
icon={<TrendingUp />}
|
|
154
|
+
label="Avg Score"
|
|
155
|
+
value={`${Math.round(progress.averageScore)}%`}
|
|
156
|
+
subtitle="across activities"
|
|
157
|
+
accent="var(--warning)"
|
|
158
|
+
/>
|
|
159
|
+
) : progress.currentStreak != null ? (
|
|
160
|
+
<StatCard
|
|
161
|
+
icon={<Flame />}
|
|
162
|
+
label="Streak"
|
|
163
|
+
value={`${progress.currentStreak}`}
|
|
164
|
+
subtitle="days"
|
|
165
|
+
accent="var(--warning)"
|
|
166
|
+
/>
|
|
167
|
+
) : (
|
|
168
|
+
<StatCard
|
|
169
|
+
icon={<TrendingUp />}
|
|
170
|
+
label="Remaining"
|
|
171
|
+
value={`${progress.totalNodes - progress.completedNodes}`}
|
|
172
|
+
subtitle="activities left"
|
|
173
|
+
accent="var(--warning)"
|
|
174
|
+
/>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Skills */}
|
|
179
|
+
{safeSkills.length > 0 && (
|
|
180
|
+
<>
|
|
181
|
+
<Separator className="my-5" />
|
|
182
|
+
<div>
|
|
183
|
+
<h3 className="text-sm font-semibold text-foreground mb-3">
|
|
184
|
+
Skills
|
|
185
|
+
</h3>
|
|
186
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-2">
|
|
187
|
+
{safeSkills.map((skill) => (
|
|
188
|
+
<PathSkillBar
|
|
189
|
+
key={skill.uid}
|
|
190
|
+
skill={skill}
|
|
191
|
+
onClick={onSkillClick}
|
|
192
|
+
/>
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
</>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{/* Path visualization */}
|
|
200
|
+
<Separator className="my-5" />
|
|
201
|
+
<div
|
|
202
|
+
className="relative"
|
|
203
|
+
role="list"
|
|
204
|
+
aria-label={`Learning path: ${progress.completedNodes} of ${progress.totalNodes} completed`}
|
|
205
|
+
>
|
|
206
|
+
{safeNodes.map((node, index) => (
|
|
207
|
+
<div key={node.uid} role="listitem">
|
|
208
|
+
{/* Connector (between nodes) */}
|
|
209
|
+
{index > 0 && (
|
|
210
|
+
<PathConnector fromStatus={safeNodes[index - 1].status} />
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{/* Milestone marker (appears before node, after previous connector) */}
|
|
214
|
+
{milestoneMap.has(index - 1) && (
|
|
215
|
+
<PathMilestoneMarker
|
|
216
|
+
milestone={milestoneMap.get(index - 1)!}
|
|
217
|
+
className="my-1"
|
|
218
|
+
/>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{/* Node card */}
|
|
222
|
+
<PathNodeCard
|
|
223
|
+
node={node}
|
|
224
|
+
onClick={onNodeClick}
|
|
225
|
+
onStart={onNodeStart}
|
|
226
|
+
readOnly={readOnly}
|
|
227
|
+
/>
|
|
228
|
+
</div>
|
|
229
|
+
))}
|
|
230
|
+
|
|
231
|
+
{/* Final milestone (after last node) */}
|
|
232
|
+
{safeNodes.length > 0 &&
|
|
233
|
+
milestoneMap.has(safeNodes.length - 1) && (
|
|
234
|
+
<>
|
|
235
|
+
<PathConnector fromStatus={safeNodes[safeNodes.length - 1].status} />
|
|
236
|
+
<PathMilestoneMarker
|
|
237
|
+
milestone={milestoneMap.get(safeNodes.length - 1)!}
|
|
238
|
+
className="my-1"
|
|
239
|
+
/>
|
|
240
|
+
</>
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
</SectionShell>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export const AdaptiveLearningPath = withProGate(
|
|
249
|
+
AdaptiveLearningPathBase,
|
|
250
|
+
"AdaptiveLearningPath",
|
|
251
|
+
);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { cn } from "../../lib/utils";
|
|
2
|
+
import type { PathNodeStatus } from "./types";
|
|
3
|
+
|
|
4
|
+
interface PathConnectorProps {
|
|
5
|
+
/** Status of the node above this connector */
|
|
6
|
+
fromStatus: PathNodeStatus;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function PathConnector({ fromStatus, className }: PathConnectorProps) {
|
|
11
|
+
const isDone = fromStatus === "mastered" || fromStatus === "completed";
|
|
12
|
+
const isActive = fromStatus === "in_progress";
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className={cn("flex justify-center", className)}>
|
|
16
|
+
<div
|
|
17
|
+
className={cn(
|
|
18
|
+
"w-0.5 h-8",
|
|
19
|
+
isDone && "bg-success",
|
|
20
|
+
isActive && "bg-primary",
|
|
21
|
+
!isDone && !isActive && "bg-border",
|
|
22
|
+
fromStatus === "skipped" && "border-l border-dashed border-muted-foreground bg-transparent",
|
|
23
|
+
)}
|
|
24
|
+
/>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Trophy, Lock } from "lucide-react";
|
|
2
|
+
import { cn } from "../../lib/utils";
|
|
3
|
+
import type { PathMilestone } from "./types";
|
|
4
|
+
|
|
5
|
+
const VARIANT_COLORS = {
|
|
6
|
+
default: "text-primary",
|
|
7
|
+
gold: "text-palette-3",
|
|
8
|
+
silver: "text-muted-foreground",
|
|
9
|
+
bronze: "text-palette-3/70",
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
interface PathMilestoneMarkerProps {
|
|
13
|
+
milestone: PathMilestone;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function PathMilestoneMarker({
|
|
18
|
+
milestone,
|
|
19
|
+
className,
|
|
20
|
+
}: PathMilestoneMarkerProps) {
|
|
21
|
+
const variant = milestone.variant ?? "default";
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
className={cn(
|
|
26
|
+
"flex items-center gap-3 py-2 px-4",
|
|
27
|
+
!milestone.reached && "opacity-50",
|
|
28
|
+
className,
|
|
29
|
+
)}
|
|
30
|
+
>
|
|
31
|
+
<div className="flex-1 h-px bg-border" />
|
|
32
|
+
<div className="flex items-center gap-2">
|
|
33
|
+
{milestone.reached ? (
|
|
34
|
+
<Trophy size={16} className={VARIANT_COLORS[variant]} />
|
|
35
|
+
) : (
|
|
36
|
+
<Lock size={14} className="text-muted-foreground" />
|
|
37
|
+
)}
|
|
38
|
+
<span
|
|
39
|
+
className={cn(
|
|
40
|
+
"text-xs font-semibold whitespace-nowrap",
|
|
41
|
+
milestone.reached ? "text-foreground" : "text-muted-foreground",
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
{milestone.title}
|
|
45
|
+
</span>
|
|
46
|
+
</div>
|
|
47
|
+
<div className="flex-1 h-px bg-border" />
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { memo, useCallback } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Clock,
|
|
4
|
+
Lock,
|
|
5
|
+
CheckCircle2,
|
|
6
|
+
ShieldCheck,
|
|
7
|
+
SkipForward,
|
|
8
|
+
Sparkles,
|
|
9
|
+
Play,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import { Card, CardContent } from "../../ui/card";
|
|
12
|
+
import { Badge } from "../../ui/badge";
|
|
13
|
+
import { Button } from "../../ui/button";
|
|
14
|
+
import { LearningObjectIcon } from "../../curriculum/learning-object-icon";
|
|
15
|
+
import { formatDuration } from "../../utils/format-duration";
|
|
16
|
+
import { cn } from "../../lib/utils";
|
|
17
|
+
import type { PathNode, PathNodeStatus } from "./types";
|
|
18
|
+
|
|
19
|
+
interface PathNodeCardProps {
|
|
20
|
+
node: PathNode;
|
|
21
|
+
onClick?: (uid: string) => void;
|
|
22
|
+
onStart?: (uid: string) => void;
|
|
23
|
+
readOnly?: boolean;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const STATUS_CONFIG: Record<
|
|
28
|
+
PathNodeStatus,
|
|
29
|
+
{ label: string; variant: "success" | "destructive" | "warning" | "muted" | "info" | "default" | "secondary"; icon?: React.ElementType }
|
|
30
|
+
> = {
|
|
31
|
+
mastered: { label: "Mastered", variant: "success", icon: ShieldCheck },
|
|
32
|
+
completed: { label: "Completed", variant: "success", icon: CheckCircle2 },
|
|
33
|
+
in_progress: { label: "In Progress", variant: "info", icon: Play },
|
|
34
|
+
available: { label: "Available", variant: "secondary" },
|
|
35
|
+
locked: { label: "Locked", variant: "muted", icon: Lock },
|
|
36
|
+
skipped: { label: "Skipped", variant: "muted", icon: SkipForward },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const PathNodeCard = memo(function PathNodeCard({
|
|
40
|
+
node,
|
|
41
|
+
onClick,
|
|
42
|
+
onStart,
|
|
43
|
+
readOnly = false,
|
|
44
|
+
className,
|
|
45
|
+
}: PathNodeCardProps) {
|
|
46
|
+
const config = STATUS_CONFIG[node.status];
|
|
47
|
+
const StatusIcon = config.icon;
|
|
48
|
+
const isInteractive = node.status !== "locked" && !readOnly;
|
|
49
|
+
const showStart =
|
|
50
|
+
!readOnly &&
|
|
51
|
+
(node.status === "available" || node.recommended) &&
|
|
52
|
+
node.status !== "locked" &&
|
|
53
|
+
node.status !== "completed" &&
|
|
54
|
+
node.status !== "mastered" &&
|
|
55
|
+
onStart;
|
|
56
|
+
|
|
57
|
+
const handleClick = useCallback(() => {
|
|
58
|
+
if (isInteractive && onClick) onClick(node.uid);
|
|
59
|
+
}, [isInteractive, onClick, node.uid]);
|
|
60
|
+
|
|
61
|
+
const handleKeyDown = useCallback(
|
|
62
|
+
(e: React.KeyboardEvent) => {
|
|
63
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
onClick?.(node.uid);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
[onClick, node.uid],
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const handleStart = useCallback(
|
|
72
|
+
(e: React.MouseEvent) => {
|
|
73
|
+
e.stopPropagation();
|
|
74
|
+
onStart?.(node.uid);
|
|
75
|
+
},
|
|
76
|
+
[onStart, node.uid],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Card
|
|
81
|
+
className={cn(
|
|
82
|
+
"transition-all",
|
|
83
|
+
isInteractive && onClick && "cursor-pointer hover:border-primary/50 hover:shadow-md",
|
|
84
|
+
node.status === "in_progress" && "border-primary ring-1 ring-primary/20",
|
|
85
|
+
node.status === "locked" && "opacity-60",
|
|
86
|
+
node.status === "skipped" && "opacity-50",
|
|
87
|
+
node.recommended && node.status !== "locked" && "border-primary/40",
|
|
88
|
+
className,
|
|
89
|
+
)}
|
|
90
|
+
onClick={isInteractive && onClick ? handleClick : undefined}
|
|
91
|
+
role={isInteractive && onClick ? "button" : undefined}
|
|
92
|
+
tabIndex={isInteractive && onClick ? 0 : undefined}
|
|
93
|
+
onKeyDown={isInteractive && onClick ? handleKeyDown : undefined}
|
|
94
|
+
>
|
|
95
|
+
<CardContent className="p-4">
|
|
96
|
+
<div className="flex items-start gap-3">
|
|
97
|
+
{/* Icon */}
|
|
98
|
+
<div
|
|
99
|
+
className={cn(
|
|
100
|
+
"shrink-0 w-9 h-9 rounded-lg flex items-center justify-center",
|
|
101
|
+
node.status === "locked"
|
|
102
|
+
? "bg-muted text-muted-foreground"
|
|
103
|
+
: "bg-primary/10 text-primary",
|
|
104
|
+
)}
|
|
105
|
+
>
|
|
106
|
+
{node.icon ?? <LearningObjectIcon type={node.type} size={18} />}
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{/* Content */}
|
|
110
|
+
<div className="flex-1 min-w-0">
|
|
111
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
112
|
+
<h4 className="text-sm font-semibold text-foreground truncate">
|
|
113
|
+
{node.title}
|
|
114
|
+
</h4>
|
|
115
|
+
{node.recommended && node.status !== "locked" && (
|
|
116
|
+
<Badge variant="default" className="text-[10px] px-1.5 h-5 gap-0.5">
|
|
117
|
+
<Sparkles size={10} />
|
|
118
|
+
Recommended
|
|
119
|
+
</Badge>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{node.description && (
|
|
124
|
+
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
|
|
125
|
+
{node.description}
|
|
126
|
+
</p>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{/* Meta row */}
|
|
130
|
+
<div className="flex items-center gap-3 mt-2 flex-wrap">
|
|
131
|
+
<Badge variant={config.variant} className="text-[10px] px-1.5 h-5 gap-0.5">
|
|
132
|
+
{StatusIcon && <StatusIcon size={10} />}
|
|
133
|
+
{config.label}
|
|
134
|
+
</Badge>
|
|
135
|
+
|
|
136
|
+
{node.estimatedDuration != null && node.estimatedDuration > 0 && (
|
|
137
|
+
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
138
|
+
<Clock size={12} />
|
|
139
|
+
{formatDuration(node.estimatedDuration)}
|
|
140
|
+
</span>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{node.score != null && (
|
|
144
|
+
<span className="text-xs font-medium tabular-nums text-foreground">
|
|
145
|
+
{node.score}%
|
|
146
|
+
</span>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{/* Action */}
|
|
152
|
+
{showStart && (
|
|
153
|
+
<Button
|
|
154
|
+
size="sm"
|
|
155
|
+
variant={node.recommended ? "default" : "outline"}
|
|
156
|
+
className="shrink-0"
|
|
157
|
+
onClick={handleStart}
|
|
158
|
+
>
|
|
159
|
+
{node.status === "in_progress" ? "Continue" : "Start"}
|
|
160
|
+
</Button>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
</CardContent>
|
|
164
|
+
</Card>
|
|
165
|
+
);
|
|
166
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
import { Progress } from "../../ui/progress";
|
|
3
|
+
import { Tooltip, TooltipTrigger, TooltipContent } from "../../ui/tooltip";
|
|
4
|
+
import { cn } from "../../lib/utils";
|
|
5
|
+
import type { PathSkill } from "./types";
|
|
6
|
+
|
|
7
|
+
interface PathSkillBarProps {
|
|
8
|
+
skill: PathSkill;
|
|
9
|
+
onClick?: (uid: string) => void;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const PathSkillBar = memo(function PathSkillBar({ skill, onClick, className }: PathSkillBarProps) {
|
|
14
|
+
const target = skill.targetProficiency ?? 100;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<Tooltip>
|
|
18
|
+
<TooltipTrigger>
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
onClick={onClick ? () => onClick(skill.uid) : undefined}
|
|
22
|
+
disabled={!onClick}
|
|
23
|
+
className={cn(
|
|
24
|
+
"flex items-center gap-2 min-w-0 text-left",
|
|
25
|
+
onClick && "cursor-pointer hover:opacity-80",
|
|
26
|
+
!onClick && "cursor-default",
|
|
27
|
+
className,
|
|
28
|
+
)}
|
|
29
|
+
>
|
|
30
|
+
<span className="text-xs font-medium text-foreground whitespace-nowrap truncate min-w-16">
|
|
31
|
+
{skill.name}
|
|
32
|
+
</span>
|
|
33
|
+
<Progress
|
|
34
|
+
value={skill.proficiency}
|
|
35
|
+
max={target}
|
|
36
|
+
size="sm"
|
|
37
|
+
className="flex-1 min-w-20"
|
|
38
|
+
/>
|
|
39
|
+
<span className="text-xs tabular-nums text-muted-foreground whitespace-nowrap">
|
|
40
|
+
{skill.proficiency}%
|
|
41
|
+
</span>
|
|
42
|
+
</button>
|
|
43
|
+
</TooltipTrigger>
|
|
44
|
+
<TooltipContent>
|
|
45
|
+
{skill.name}: {skill.proficiency}%{target < 100 ? ` / ${target}% target` : ""}
|
|
46
|
+
</TooltipContent>
|
|
47
|
+
</Tooltip>
|
|
48
|
+
);
|
|
49
|
+
});
|