@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.
Files changed (47) hide show
  1. package/dist/StudentProfile-BPsZBaJj.cjs +1 -0
  2. package/dist/{StudentProfile-DeMxdrL3.js → StudentProfile-Cw2p-RZn.js} +577 -579
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.js +172 -166
  5. package/dist/license/index.d.ts +2 -2
  6. package/dist/license/tiers.d.ts +3 -0
  7. package/dist/modules.cjs +1 -1
  8. package/dist/modules.js +111 -110
  9. package/dist/sections/AdaptiveLearningPath/AdaptiveLearningPath.d.ts +5 -0
  10. package/dist/sections/AdaptiveLearningPath/path-connector.d.ts +8 -0
  11. package/dist/sections/AdaptiveLearningPath/path-milestone-marker.d.ts +7 -0
  12. package/dist/sections/AdaptiveLearningPath/path-node-card.d.ts +10 -0
  13. package/dist/sections/AdaptiveLearningPath/path-skill-bar.d.ts +8 -0
  14. package/dist/sections/AdaptiveLearningPath/types.d.ts +136 -0
  15. package/dist/sections/ContentAuthoringStudio/ContentAuthoringStudio.d.ts +5 -0
  16. package/dist/sections/ContentAuthoringStudio/block-editor-item.d.ts +14 -0
  17. package/dist/sections/ContentAuthoringStudio/block-type-picker.d.ts +12 -0
  18. package/dist/sections/ContentAuthoringStudio/types.d.ts +67 -0
  19. package/dist/sections/index.d.ts +4 -0
  20. package/dist/sections.cjs +1 -1
  21. package/dist/sections.js +1325 -232
  22. package/dist/withProGate-BJdu1T9Y.cjs +2 -0
  23. package/dist/withProGate-BvFc7Jwy.js +4975 -0
  24. package/package.json +24 -7
  25. package/src/license/index.ts +2 -2
  26. package/src/license/tiers.ts +12 -2
  27. package/src/modules/CoursePlayer/CoursePlayer.tsx +3 -1
  28. package/src/progress/stat-card.tsx +10 -5
  29. package/src/sections/AdaptiveLearningPath/AdaptiveLearningPath.tsx +251 -0
  30. package/src/sections/AdaptiveLearningPath/path-connector.tsx +27 -0
  31. package/src/sections/AdaptiveLearningPath/path-milestone-marker.tsx +50 -0
  32. package/src/sections/AdaptiveLearningPath/path-node-card.tsx +166 -0
  33. package/src/sections/AdaptiveLearningPath/path-skill-bar.tsx +49 -0
  34. package/src/sections/AdaptiveLearningPath/types.ts +159 -0
  35. package/src/sections/ContentAuthoringStudio/ContentAuthoringStudio.tsx +289 -0
  36. package/src/sections/ContentAuthoringStudio/block-editor-item.tsx +487 -0
  37. package/src/sections/ContentAuthoringStudio/block-type-picker.tsx +123 -0
  38. package/src/sections/ContentAuthoringStudio/types.ts +67 -0
  39. package/src/sections/ForumBoard/ForumBoard.tsx +8 -6
  40. package/src/sections/LessonPage/LessonPage.tsx +4 -7
  41. package/src/sections/index.ts +18 -0
  42. package/src/video/video-player.tsx +14 -5
  43. package/dist/StudentProfile-BVfZMbnV.cjs +0 -1
  44. package/dist/tabs-BsfVo2Bl.cjs +0 -173
  45. package/dist/tabs-BuY1iNJE.js +0 -22305
  46. package/dist/withProGate-BWqcKdPM.js +0 -137
  47. 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.0",
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",
@@ -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';
@@ -1,6 +1,6 @@
1
1
  // ─── HydraLMS Pro Tier Definitions ──────────────────────────────
2
- // All 12 modules are Pro tier. UI primitives, base components, and
3
- // sections are free and always will be.
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
- [activeItem],
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
+ });