@hydralms/components 0.1.1 → 0.1.3
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/package.json +52 -1
- package/src/__tests__/setup.ts +1 -0
- package/src/assessment-toolbar/assessment-toolbar.tsx +96 -0
- package/src/assessment-toolbar/index.ts +10 -0
- package/src/assessment-toolbar/question-navigator.tsx +86 -0
- package/src/assessment-toolbar/timer-display.tsx +73 -0
- package/src/assessment-toolbar/types.ts +92 -0
- package/src/assets/hydra-icon.png +0 -0
- package/src/assets/hydra-icon.svg +18 -0
- package/src/assets/hydra-lms-icon.png +0 -0
- package/src/assets/hydra-lms-icon.svg +9 -0
- package/src/common/confirm-dialog.tsx +60 -0
- package/src/common/due-date-display.tsx +64 -0
- package/src/common/empty-state.tsx +24 -0
- package/src/common/index.ts +12 -0
- package/src/common/search-input.tsx +68 -0
- package/src/common/status-badge.test.tsx +43 -0
- package/src/common/status-badge.tsx +81 -0
- package/src/common/types.ts +129 -0
- package/src/content/content-block.tsx +116 -0
- package/src/content/file-upload-zone.tsx +109 -0
- package/src/content/index.ts +7 -0
- package/src/content/types.ts +76 -0
- package/src/curriculum/curriculum-item.tsx +81 -0
- package/src/curriculum/curriculum-tree.tsx +69 -0
- package/src/curriculum/index.ts +11 -0
- package/src/curriculum/learning-object-icon.tsx +44 -0
- package/src/curriculum/types.ts +83 -0
- package/src/feedback/feedback-banner.tsx +46 -0
- package/src/feedback/index.ts +8 -0
- package/src/feedback/likert-scale.tsx +58 -0
- package/src/feedback/star-rating.tsx +65 -0
- package/src/feedback/types.ts +86 -0
- package/src/flashcards/flashcard-deck.tsx +130 -0
- package/src/flashcards/flashcard.tsx +108 -0
- package/src/flashcards/index.ts +3 -0
- package/src/flashcards/types.ts +60 -0
- package/src/index.ts +38 -0
- package/src/lib/utils.ts +6 -0
- package/src/modules/CoursePlayer/CoursePlayer.tsx +281 -0
- package/src/modules/CoursePlayer/types.ts +48 -0
- package/src/modules/FlashcardLab/FlashcardLab.tsx +275 -0
- package/src/modules/FlashcardLab/types.ts +58 -0
- package/src/modules/QuizModule/QuizModule.tsx +241 -0
- package/src/modules/QuizModule/types.ts +56 -0
- package/src/modules/index.ts +12 -0
- package/src/progress/grade-indicator.tsx +65 -0
- package/src/progress/index.ts +8 -0
- package/src/progress/progress-ring.tsx +56 -0
- package/src/progress/stat-card.tsx +42 -0
- package/src/progress/types.ts +73 -0
- package/src/provider/HydraProvider.tsx +26 -0
- package/src/provider/index.ts +2 -0
- package/src/questions/choice.tsx +90 -0
- package/src/questions/essay.tsx +59 -0
- package/src/questions/fill-in-the-blank.tsx +69 -0
- package/src/questions/index.ts +14 -0
- package/src/questions/multiple-choice.test.tsx +104 -0
- package/src/questions/multiple-choice.tsx +97 -0
- package/src/questions/question-renderer.tsx +37 -0
- package/src/questions/true-false.test.tsx +89 -0
- package/src/questions/true-false.tsx +90 -0
- package/src/questions/types.ts +53 -0
- package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +141 -0
- package/src/sections/AnnouncementFeed/types.ts +50 -0
- package/src/sections/AssessmentReview/AssessmentReview.tsx +148 -0
- package/src/sections/AssessmentReview/types.ts +61 -0
- package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +190 -0
- package/src/sections/AssignmentSubmission/types.ts +60 -0
- package/src/sections/CertificateViewer/CertificateViewer.tsx +117 -0
- package/src/sections/CertificateViewer/types.ts +45 -0
- package/src/sections/CourseOutline/CourseOutline.tsx +79 -0
- package/src/sections/CourseOutline/types.ts +53 -0
- package/src/sections/DiscussionThread/DiscussionThread.tsx +186 -0
- package/src/sections/DiscussionThread/types.ts +77 -0
- package/src/sections/ExamSession/ExamSession.tsx +182 -0
- package/src/sections/ExamSession/types.ts +64 -0
- package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +76 -0
- package/src/sections/FlashcardStudySession/types.ts +42 -0
- package/src/sections/GradebookTable/GradebookTable.tsx +229 -0
- package/src/sections/GradebookTable/types.ts +75 -0
- package/src/sections/LecturePlayer/LecturePlayer.tsx +60 -0
- package/src/sections/LecturePlayer/types.ts +48 -0
- package/src/sections/LessonPage/LessonPage.tsx +91 -0
- package/src/sections/LessonPage/types.ts +41 -0
- package/src/sections/PracticeQuiz/PracticeQuiz.tsx +199 -0
- package/src/sections/PracticeQuiz/types.ts +44 -0
- package/src/sections/ProgressDashboard/ProgressDashboard.tsx +140 -0
- package/src/sections/ProgressDashboard/types.ts +74 -0
- package/src/sections/QuizSession/QuizSession.tsx +113 -0
- package/src/sections/QuizSession/types.ts +47 -0
- package/src/sections/ResourceLibrary/ResourceLibrary.tsx +218 -0
- package/src/sections/ResourceLibrary/types.ts +57 -0
- package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +170 -0
- package/src/sections/ScrollableQuiz/types.ts +40 -0
- package/src/sections/SurveyForm/SurveyForm.tsx +180 -0
- package/src/sections/SurveyForm/types.ts +69 -0
- package/src/sections/index.ts +90 -0
- package/src/social/index.ts +3 -0
- package/src/social/post-card.tsx +91 -0
- package/src/social/types.ts +57 -0
- package/src/social/user-avatar.tsx +76 -0
- package/src/styles/globals.css +125 -0
- package/src/ui/alert-dialog.tsx +343 -0
- package/src/ui/alert.tsx +65 -0
- package/src/ui/avatar.tsx +52 -0
- package/src/ui/badge.tsx +53 -0
- package/src/ui/button.tsx +62 -0
- package/src/ui/card.tsx +92 -0
- package/src/ui/index.ts +44 -0
- package/src/ui/input.tsx +21 -0
- package/src/ui/progress.tsx +73 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/skeleton.tsx +15 -0
- package/src/ui/slot.tsx +48 -0
- package/src/ui/table.tsx +108 -0
- package/src/ui/tabs.tsx +147 -0
- package/src/ui/textarea.tsx +20 -0
- package/src/ui/tooltip.tsx +177 -0
- package/src/utils/debounce.test.ts +59 -0
- package/src/utils/debounce.ts +10 -0
- package/src/utils/format-duration.test.ts +55 -0
- package/src/utils/format-duration.ts +30 -0
- package/src/video/index.ts +17 -0
- package/src/video/types.ts +216 -0
- package/src/video/video-bookmark.tsx +76 -0
- package/src/video/video-chapter-list.tsx +93 -0
- package/src/video/video-player.tsx +103 -0
- package/src/video/video-playlist-item.tsx +90 -0
- package/src/video/video-thumbnail-card.tsx +74 -0
- package/src/video/video-transcript.tsx +102 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { Download, Grid, List as ListIcon } from "lucide-react";
|
|
3
|
+
import { LearningObjectIcon } from "../../curriculum";
|
|
4
|
+
import { SearchInput, EmptyState } from "../../common";
|
|
5
|
+
import { Button } from "../../ui/button";
|
|
6
|
+
import { Card, CardContent } from "../../ui/card";
|
|
7
|
+
import { Tabs, TabsList, TabsTrigger } from "../../ui/tabs";
|
|
8
|
+
import { Tooltip, TooltipTrigger, TooltipContent } from "../../ui/tooltip";
|
|
9
|
+
import type { ResourceLibraryProps, Resource } from "./types";
|
|
10
|
+
import { cn } from "../../lib/utils";
|
|
11
|
+
|
|
12
|
+
function formatBytes(bytes: number): string {
|
|
13
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
14
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
15
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TYPE_TO_ICON: Record<string, string> = {
|
|
19
|
+
pdf: "document",
|
|
20
|
+
document: "document",
|
|
21
|
+
video: "video",
|
|
22
|
+
link: "link",
|
|
23
|
+
image: "document",
|
|
24
|
+
archive: "document",
|
|
25
|
+
other: "document",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function ResourceLibrary({
|
|
29
|
+
resources,
|
|
30
|
+
categories,
|
|
31
|
+
onResourceClick,
|
|
32
|
+
onDownload,
|
|
33
|
+
viewMode: initialViewMode = "list",
|
|
34
|
+
allowViewToggle = true,
|
|
35
|
+
showSearch = true,
|
|
36
|
+
emptyMessage = "No resources found",
|
|
37
|
+
readOnly = false,
|
|
38
|
+
className,
|
|
39
|
+
style,
|
|
40
|
+
}: ResourceLibraryProps) {
|
|
41
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
42
|
+
const [activeCategoryUid, setActiveCategoryUid] = useState<string | null>(null);
|
|
43
|
+
const [viewMode, setViewMode] = useState(initialViewMode);
|
|
44
|
+
|
|
45
|
+
const filtered = useMemo(() => {
|
|
46
|
+
let result = resources;
|
|
47
|
+
if (activeCategoryUid) {
|
|
48
|
+
result = result.filter((r) => r.categoryUid === activeCategoryUid);
|
|
49
|
+
}
|
|
50
|
+
if (searchQuery.trim()) {
|
|
51
|
+
const q = searchQuery.toLowerCase();
|
|
52
|
+
result = result.filter(
|
|
53
|
+
(r) =>
|
|
54
|
+
r.name.toLowerCase().includes(q) ||
|
|
55
|
+
r.description?.toLowerCase().includes(q),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}, [resources, activeCategoryUid, searchQuery]);
|
|
60
|
+
|
|
61
|
+
function renderResource(resource: Resource) {
|
|
62
|
+
const iconType = TYPE_TO_ICON[resource.type] ?? "document";
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div
|
|
66
|
+
key={resource.uid}
|
|
67
|
+
className={cn(
|
|
68
|
+
"flex items-center gap-3 px-3 py-2",
|
|
69
|
+
!readOnly && "cursor-pointer hover:bg-muted",
|
|
70
|
+
readOnly && "opacity-70",
|
|
71
|
+
)}
|
|
72
|
+
onClick={() => !readOnly && onResourceClick(resource)}
|
|
73
|
+
>
|
|
74
|
+
<div className="min-w-10">
|
|
75
|
+
<LearningObjectIcon type={iconType} size={20} />
|
|
76
|
+
</div>
|
|
77
|
+
<div className="flex-1 min-w-0">
|
|
78
|
+
<span className="text-sm text-foreground">{resource.name}</span>
|
|
79
|
+
{(resource.description || resource.fileSize != null) && (
|
|
80
|
+
<span className="text-sm text-muted-foreground">
|
|
81
|
+
{[
|
|
82
|
+
resource.description,
|
|
83
|
+
resource.fileSize != null && formatBytes(resource.fileSize),
|
|
84
|
+
]
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.join(" \u00b7 ")}
|
|
87
|
+
</span>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
{onDownload && (
|
|
91
|
+
<Tooltip>
|
|
92
|
+
<TooltipTrigger>
|
|
93
|
+
<Button
|
|
94
|
+
variant="ghost"
|
|
95
|
+
size="icon-xs"
|
|
96
|
+
aria-label="Download"
|
|
97
|
+
onClick={(e) => {
|
|
98
|
+
e.stopPropagation();
|
|
99
|
+
onDownload(resource);
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<Download size={16} />
|
|
103
|
+
</Button>
|
|
104
|
+
</TooltipTrigger>
|
|
105
|
+
<TooltipContent>Download</TooltipContent>
|
|
106
|
+
</Tooltip>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className={className} style={style}>
|
|
114
|
+
{/* Toolbar */}
|
|
115
|
+
<div className="flex gap-2 items-center mb-2">
|
|
116
|
+
{showSearch && (
|
|
117
|
+
<div className="flex-1 max-w-80">
|
|
118
|
+
<SearchInput
|
|
119
|
+
value={searchQuery}
|
|
120
|
+
onChange={setSearchQuery}
|
|
121
|
+
placeholder="Search resources..."
|
|
122
|
+
size="small"
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
{allowViewToggle && (
|
|
127
|
+
<div className="flex gap-0.5">
|
|
128
|
+
<Tooltip>
|
|
129
|
+
<TooltipTrigger>
|
|
130
|
+
<Button
|
|
131
|
+
variant="ghost"
|
|
132
|
+
size="icon-xs"
|
|
133
|
+
aria-label="List view"
|
|
134
|
+
className={cn(viewMode === "list" && "text-primary")}
|
|
135
|
+
onClick={() => setViewMode("list")}
|
|
136
|
+
>
|
|
137
|
+
<ListIcon size={18} />
|
|
138
|
+
</Button>
|
|
139
|
+
</TooltipTrigger>
|
|
140
|
+
<TooltipContent>List view</TooltipContent>
|
|
141
|
+
</Tooltip>
|
|
142
|
+
<Tooltip>
|
|
143
|
+
<TooltipTrigger>
|
|
144
|
+
<Button
|
|
145
|
+
variant="ghost"
|
|
146
|
+
size="icon-xs"
|
|
147
|
+
aria-label="Grid view"
|
|
148
|
+
className={cn(viewMode === "grid" && "text-primary")}
|
|
149
|
+
onClick={() => setViewMode("grid")}
|
|
150
|
+
>
|
|
151
|
+
<Grid size={18} />
|
|
152
|
+
</Button>
|
|
153
|
+
</TooltipTrigger>
|
|
154
|
+
<TooltipContent>Grid view</TooltipContent>
|
|
155
|
+
</Tooltip>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* Category tabs */}
|
|
161
|
+
{categories && categories.length > 0 && (
|
|
162
|
+
<Tabs
|
|
163
|
+
value={activeCategoryUid ?? "all"}
|
|
164
|
+
onValueChange={(v) => setActiveCategoryUid(v === "all" ? null : v)}
|
|
165
|
+
className="mb-2"
|
|
166
|
+
>
|
|
167
|
+
<TabsList>
|
|
168
|
+
<TabsTrigger value="all">All</TabsTrigger>
|
|
169
|
+
{categories.map((cat) => (
|
|
170
|
+
<TabsTrigger key={cat.uid} value={cat.uid}>{cat.label}</TabsTrigger>
|
|
171
|
+
))}
|
|
172
|
+
</TabsList>
|
|
173
|
+
</Tabs>
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
{/* Resource list */}
|
|
177
|
+
{filtered.length === 0 ? (
|
|
178
|
+
<EmptyState title={emptyMessage} description="Try adjusting your search or filter." />
|
|
179
|
+
) : viewMode === "list" ? (
|
|
180
|
+
<Card>
|
|
181
|
+
{filtered.map(renderResource)}
|
|
182
|
+
</Card>
|
|
183
|
+
) : (
|
|
184
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-2">
|
|
185
|
+
{filtered.map((resource) => (
|
|
186
|
+
<Card
|
|
187
|
+
key={resource.uid}
|
|
188
|
+
className={cn(
|
|
189
|
+
"transition-colors",
|
|
190
|
+
!readOnly && "cursor-pointer hover:border-primary",
|
|
191
|
+
)}
|
|
192
|
+
onClick={() => !readOnly && onResourceClick(resource)}
|
|
193
|
+
>
|
|
194
|
+
<CardContent className="pt-4 pb-4">
|
|
195
|
+
<div className="flex gap-1 items-center mb-1">
|
|
196
|
+
<LearningObjectIcon type={TYPE_TO_ICON[resource.type] ?? "document"} size={20} />
|
|
197
|
+
<span className="font-semibold text-sm text-foreground truncate">
|
|
198
|
+
{resource.name}
|
|
199
|
+
</span>
|
|
200
|
+
</div>
|
|
201
|
+
{resource.description && (
|
|
202
|
+
<p className="text-sm text-muted-foreground mb-1 truncate">
|
|
203
|
+
{resource.description}
|
|
204
|
+
</p>
|
|
205
|
+
)}
|
|
206
|
+
{resource.fileSize != null && (
|
|
207
|
+
<span className="text-xs text-muted-foreground">
|
|
208
|
+
{formatBytes(resource.fileSize)}
|
|
209
|
+
</span>
|
|
210
|
+
)}
|
|
211
|
+
</CardContent>
|
|
212
|
+
</Card>
|
|
213
|
+
))}
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* ResourceLibrary section — a searchable resource catalog.
|
|
4
|
+
*
|
|
5
|
+
* Displays downloadable course resources in a grid or list view with
|
|
6
|
+
* search, category tabs, and view mode toggling.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* <ResourceLibrary
|
|
10
|
+
* resources={resources}
|
|
11
|
+
* categories={categories}
|
|
12
|
+
* onResourceClick={(r) => download(r)}
|
|
13
|
+
* />
|
|
14
|
+
*/
|
|
15
|
+
export interface ResourceLibraryProps {
|
|
16
|
+
/** Resources to display */
|
|
17
|
+
resources: Resource[];
|
|
18
|
+
/** Optional categories for tab filtering */
|
|
19
|
+
categories?: { uid: string; label: string }[];
|
|
20
|
+
/** Called when the user clicks a resource */
|
|
21
|
+
onResourceClick: (resource: Resource) => void;
|
|
22
|
+
/** Called when the user downloads a resource */
|
|
23
|
+
onDownload?: (resource: Resource) => void;
|
|
24
|
+
/** Layout view mode */
|
|
25
|
+
viewMode?: "grid" | "list";
|
|
26
|
+
/** Whether the user can toggle between grid and list */
|
|
27
|
+
allowViewToggle?: boolean;
|
|
28
|
+
/** Whether to show search */
|
|
29
|
+
showSearch?: boolean;
|
|
30
|
+
/** Empty state message */
|
|
31
|
+
emptyMessage?: string;
|
|
32
|
+
/** When true, disables interactions */
|
|
33
|
+
readOnly?: boolean;
|
|
34
|
+
/** CSS class name for the root element */
|
|
35
|
+
className?: string;
|
|
36
|
+
/** Inline styles for the root element */
|
|
37
|
+
style?: React.CSSProperties;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Resource {
|
|
41
|
+
/** Unique identifier */
|
|
42
|
+
uid: string;
|
|
43
|
+
/** Resource name */
|
|
44
|
+
name: string;
|
|
45
|
+
/** Optional description */
|
|
46
|
+
description?: string;
|
|
47
|
+
/** Resource type */
|
|
48
|
+
type: "pdf" | "document" | "video" | "link" | "image" | "archive" | "other";
|
|
49
|
+
/** Resource URL */
|
|
50
|
+
url: string;
|
|
51
|
+
/** File size in bytes */
|
|
52
|
+
fileSize?: number;
|
|
53
|
+
/** Category UID for filtering */
|
|
54
|
+
categoryUid?: string;
|
|
55
|
+
/** Date added as ISO string */
|
|
56
|
+
addedAt?: string;
|
|
57
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { QuestionRenderer } from "../../questions";
|
|
3
|
+
import type { SessionAnswer } from "../../questions/types";
|
|
4
|
+
import { Button } from "../../ui/button";
|
|
5
|
+
import { Card, CardContent } from "../../ui/card";
|
|
6
|
+
import { cn } from "../../lib/utils";
|
|
7
|
+
import type { ScrollableQuizProps } from "./types";
|
|
8
|
+
|
|
9
|
+
export function ScrollableQuiz({
|
|
10
|
+
questions,
|
|
11
|
+
initialAnswers = [],
|
|
12
|
+
onSubmit,
|
|
13
|
+
onAnswerChange,
|
|
14
|
+
showNavigator = true,
|
|
15
|
+
showQuestionNumbers = true,
|
|
16
|
+
questionGroups,
|
|
17
|
+
isSubmitting = false,
|
|
18
|
+
readOnly = false,
|
|
19
|
+
className,
|
|
20
|
+
style,
|
|
21
|
+
}: ScrollableQuizProps) {
|
|
22
|
+
const [sessionAnswers, setSessionAnswers] = useState<SessionAnswer[]>(initialAnswers);
|
|
23
|
+
const [activeUid, setActiveUid] = useState<string | null>(questions[0]?.uid ?? null);
|
|
24
|
+
const questionRefs = useRef<Map<string, HTMLElement>>(new Map());
|
|
25
|
+
|
|
26
|
+
const answeredCount = useMemo(() => {
|
|
27
|
+
const answered = new Set(sessionAnswers.map((a) => a.uid));
|
|
28
|
+
return questions.filter((q) => answered.has(q.uid)).length;
|
|
29
|
+
}, [questions, sessionAnswers]);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const observer = new IntersectionObserver(
|
|
33
|
+
(entries) => {
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (entry.isIntersecting) {
|
|
36
|
+
setActiveUid(entry.target.getAttribute("data-question-uid"));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
{ rootMargin: "-20% 0px -60% 0px" },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
questionRefs.current.forEach((el) => observer.observe(el));
|
|
44
|
+
return () => observer.disconnect();
|
|
45
|
+
}, [questions]);
|
|
46
|
+
|
|
47
|
+
const setRef = useCallback((uid: string, el: HTMLElement | null) => {
|
|
48
|
+
if (el) questionRefs.current.set(uid, el);
|
|
49
|
+
else questionRefs.current.delete(uid);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
function handleAnswer(questionUid: string, rawAnswers: { uid: string; content?: string }[]) {
|
|
53
|
+
const newAnswers: SessionAnswer[] = rawAnswers.map((a) => ({
|
|
54
|
+
uid: questionUid,
|
|
55
|
+
answerUid: a.uid,
|
|
56
|
+
content: a.content,
|
|
57
|
+
}));
|
|
58
|
+
setSessionAnswers((prev) => {
|
|
59
|
+
const filtered = prev.filter((a) => a.uid !== questionUid);
|
|
60
|
+
const merged = [...filtered, ...newAnswers];
|
|
61
|
+
onAnswerChange?.(merged);
|
|
62
|
+
return merged;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function scrollToQuestion(uid: string) {
|
|
67
|
+
questionRefs.current.get(uid)?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const orderedQuestions = useMemo(() => {
|
|
71
|
+
if (!questionGroups) return [{ label: null, questions }];
|
|
72
|
+
const grouped: { label: string | null; questions: typeof questions }[] = questionGroups.map(
|
|
73
|
+
(g) => ({
|
|
74
|
+
label: g.label,
|
|
75
|
+
questions: g.questionUids
|
|
76
|
+
.map((uid) => questions.find((q) => q.uid === uid))
|
|
77
|
+
.filter(Boolean) as typeof questions,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
const groupedUids = new Set(questionGroups.flatMap((g) => g.questionUids));
|
|
81
|
+
const ungrouped = questions.filter((q) => !groupedUids.has(q.uid));
|
|
82
|
+
if (ungrouped.length > 0) grouped.push({ label: null, questions: ungrouped });
|
|
83
|
+
return grouped;
|
|
84
|
+
}, [questions, questionGroups]);
|
|
85
|
+
|
|
86
|
+
let globalIndex = 0;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className={cn("flex gap-3", className)} style={style}>
|
|
90
|
+
{/* Main content */}
|
|
91
|
+
<div className="flex-1 min-w-0">
|
|
92
|
+
{orderedQuestions.map((group, gi) => (
|
|
93
|
+
<div key={gi}>
|
|
94
|
+
{group.label && (
|
|
95
|
+
<p className={cn("text-lg font-semibold mb-2 text-foreground", gi > 0 && "mt-4")}>
|
|
96
|
+
{group.label}
|
|
97
|
+
</p>
|
|
98
|
+
)}
|
|
99
|
+
{group.questions.map((q) => {
|
|
100
|
+
const qIndex = globalIndex++;
|
|
101
|
+
return (
|
|
102
|
+
<Card
|
|
103
|
+
key={q.uid}
|
|
104
|
+
ref={(el: HTMLElement | null) => setRef(q.uid, el)}
|
|
105
|
+
data-question-uid={q.uid}
|
|
106
|
+
className="mb-2"
|
|
107
|
+
>
|
|
108
|
+
<CardContent className="pt-6">
|
|
109
|
+
{showQuestionNumbers && (
|
|
110
|
+
<p className="font-semibold text-sm text-muted-foreground mb-1">
|
|
111
|
+
Question {qIndex + 1}
|
|
112
|
+
</p>
|
|
113
|
+
)}
|
|
114
|
+
<QuestionRenderer
|
|
115
|
+
question={q}
|
|
116
|
+
sessionAnswers={sessionAnswers.filter((a) => a.uid === q.uid)}
|
|
117
|
+
onAnswer={(answers) => handleAnswer(q.uid, answers)}
|
|
118
|
+
readOnly={readOnly}
|
|
119
|
+
/>
|
|
120
|
+
</CardContent>
|
|
121
|
+
</Card>
|
|
122
|
+
);
|
|
123
|
+
})}
|
|
124
|
+
</div>
|
|
125
|
+
))}
|
|
126
|
+
<div className="mt-3 flex justify-between items-center">
|
|
127
|
+
<span className="text-sm text-muted-foreground">
|
|
128
|
+
{answeredCount} of {questions.length} answered
|
|
129
|
+
</span>
|
|
130
|
+
<Button
|
|
131
|
+
onClick={() => onSubmit(sessionAnswers)}
|
|
132
|
+
disabled={isSubmitting || readOnly}
|
|
133
|
+
>
|
|
134
|
+
{isSubmitting ? "Submitting..." : "Submit"}
|
|
135
|
+
</Button>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Sidebar navigator */}
|
|
140
|
+
{showNavigator && (
|
|
141
|
+
<Card className="hidden md:block w-50 shrink-0 sticky top-4 self-start p-3">
|
|
142
|
+
<p className="font-semibold text-sm mb-1 text-foreground">Questions</p>
|
|
143
|
+
<div className="flex flex-wrap gap-1">
|
|
144
|
+
{questions.map((q, i) => {
|
|
145
|
+
const isAnswered = sessionAnswers.some((a) => a.uid === q.uid);
|
|
146
|
+
const isActive = activeUid === q.uid;
|
|
147
|
+
return (
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
key={q.uid}
|
|
151
|
+
className={cn(
|
|
152
|
+
"inline-flex items-center justify-center text-xs font-medium min-w-9 px-1 py-0.5 rounded-full cursor-pointer border transition-colors",
|
|
153
|
+
isActive
|
|
154
|
+
? "bg-primary border-primary text-primary-foreground"
|
|
155
|
+
: isAnswered
|
|
156
|
+
? "border-success text-success bg-transparent"
|
|
157
|
+
: "border-border text-foreground bg-transparent hover:bg-muted",
|
|
158
|
+
)}
|
|
159
|
+
onClick={() => scrollToQuestion(q.uid)}
|
|
160
|
+
>
|
|
161
|
+
{i + 1}
|
|
162
|
+
</button>
|
|
163
|
+
);
|
|
164
|
+
})}
|
|
165
|
+
</div>
|
|
166
|
+
</Card>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { QuestionData, SessionAnswer } from "../../questions/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ScrollableQuiz section — all questions on a single scrollable page.
|
|
5
|
+
*
|
|
6
|
+
* Renders every question in a vertical list with a sticky sidebar navigator
|
|
7
|
+
* that highlights the currently visible question. Supports optional question
|
|
8
|
+
* grouping by section labels.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* <ScrollableQuiz
|
|
12
|
+
* questions={questions}
|
|
13
|
+
* onSubmit={(answers) => submit(answers)}
|
|
14
|
+
* showNavigator
|
|
15
|
+
* />
|
|
16
|
+
*/
|
|
17
|
+
export interface ScrollableQuizProps {
|
|
18
|
+
/** Ordered list of questions to present */
|
|
19
|
+
questions: QuestionData[];
|
|
20
|
+
/** Pre-populated answers for resuming */
|
|
21
|
+
initialAnswers?: SessionAnswer[];
|
|
22
|
+
/** Called when the user clicks Submit with the full answers array */
|
|
23
|
+
onSubmit: (answers: SessionAnswer[]) => void;
|
|
24
|
+
/** Called whenever the user changes any answer (useful for auto-save) */
|
|
25
|
+
onAnswerChange?: (answers: SessionAnswer[]) => void;
|
|
26
|
+
/** Whether to show the sticky sidebar navigator */
|
|
27
|
+
showNavigator?: boolean;
|
|
28
|
+
/** Whether to show question numbers beside each question */
|
|
29
|
+
showQuestionNumbers?: boolean;
|
|
30
|
+
/** Optional grouping of questions into labeled sections */
|
|
31
|
+
questionGroups?: { label: string; questionUids: string[] }[];
|
|
32
|
+
/** Whether the submit action is in flight */
|
|
33
|
+
isSubmitting?: boolean;
|
|
34
|
+
/** When true, all inputs are disabled */
|
|
35
|
+
readOnly?: boolean;
|
|
36
|
+
/** CSS class name for the root element */
|
|
37
|
+
className?: string;
|
|
38
|
+
/** Inline styles for the root element */
|
|
39
|
+
style?: React.CSSProperties;
|
|
40
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { useState, useMemo } from "react";
|
|
2
|
+
import { LikertScale, StarRating } from "../../feedback";
|
|
3
|
+
import { Button } from "../../ui/button";
|
|
4
|
+
import { Card, CardContent } from "../../ui/card";
|
|
5
|
+
import { Progress } from "../../ui/progress";
|
|
6
|
+
import { Textarea } from "../../ui/textarea";
|
|
7
|
+
import type { SurveyFormProps, SurveyAnswer } from "./types";
|
|
8
|
+
|
|
9
|
+
export function SurveyForm({
|
|
10
|
+
title,
|
|
11
|
+
description,
|
|
12
|
+
questions,
|
|
13
|
+
initialAnswers = [],
|
|
14
|
+
onSubmit,
|
|
15
|
+
onAnswerChange,
|
|
16
|
+
showProgress = true,
|
|
17
|
+
requireAll = false,
|
|
18
|
+
submitLabel = "Submit Survey",
|
|
19
|
+
isSubmitting = false,
|
|
20
|
+
readOnly = false,
|
|
21
|
+
className,
|
|
22
|
+
style,
|
|
23
|
+
}: SurveyFormProps) {
|
|
24
|
+
const [answers, setAnswers] = useState<SurveyAnswer[]>(initialAnswers);
|
|
25
|
+
|
|
26
|
+
const answeredCount = useMemo(() => {
|
|
27
|
+
const answered = new Set(answers.map((a) => a.questionUid));
|
|
28
|
+
return questions.filter((q) => answered.has(q.uid)).length;
|
|
29
|
+
}, [questions, answers]);
|
|
30
|
+
|
|
31
|
+
const canSubmit = !requireAll || answeredCount === questions.length;
|
|
32
|
+
|
|
33
|
+
function setAnswer(questionUid: string, value: string | number) {
|
|
34
|
+
setAnswers((prev) => {
|
|
35
|
+
const filtered = prev.filter((a) => a.questionUid !== questionUid);
|
|
36
|
+
const next = [...filtered, { questionUid, value }];
|
|
37
|
+
onAnswerChange?.(next);
|
|
38
|
+
return next;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getAnswer(questionUid: string): SurveyAnswer | undefined {
|
|
43
|
+
return answers.find((a) => a.questionUid === questionUid);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className={className} style={style}>
|
|
48
|
+
<p className="text-xl font-bold text-foreground mb-2">{title}</p>
|
|
49
|
+
{description && (
|
|
50
|
+
<div className="mb-2 text-muted-foreground text-sm">
|
|
51
|
+
{typeof description === "string" ? (
|
|
52
|
+
<span>{description}</span>
|
|
53
|
+
) : (
|
|
54
|
+
description
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
{showProgress && (
|
|
59
|
+
<div className="mb-3">
|
|
60
|
+
<Progress value={(answeredCount / questions.length) * 100} />
|
|
61
|
+
<span className="block text-xs text-muted-foreground mt-0.5">
|
|
62
|
+
{answeredCount} of {questions.length} answered
|
|
63
|
+
</span>
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
|
|
67
|
+
{questions.map((q, index) => {
|
|
68
|
+
const answer = getAnswer(q.uid);
|
|
69
|
+
return (
|
|
70
|
+
<Card key={q.uid} className="mb-2"><CardContent className="pt-6">
|
|
71
|
+
<p className="font-medium text-foreground mb-2">
|
|
72
|
+
{index + 1}. {q.content}
|
|
73
|
+
{q.required && (
|
|
74
|
+
<span className="text-destructive ml-0.5">*</span>
|
|
75
|
+
)}
|
|
76
|
+
</p>
|
|
77
|
+
|
|
78
|
+
{q.type === "likert" && (
|
|
79
|
+
<LikertScale
|
|
80
|
+
value={answer ? Number(answer.value) : null}
|
|
81
|
+
onChange={(v) => setAnswer(q.uid, v)}
|
|
82
|
+
points={q.scalePoints}
|
|
83
|
+
lowLabel={q.scaleLabels?.low}
|
|
84
|
+
highLabel={q.scaleLabels?.high}
|
|
85
|
+
readOnly={readOnly}
|
|
86
|
+
/>
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
{q.type === "rating" && (
|
|
90
|
+
<StarRating
|
|
91
|
+
value={answer ? Number(answer.value) : 0}
|
|
92
|
+
onChange={(v) => setAnswer(q.uid, v)}
|
|
93
|
+
readOnly={readOnly}
|
|
94
|
+
/>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{q.type === "open_text" && (
|
|
98
|
+
<Textarea
|
|
99
|
+
placeholder="Type your response..."
|
|
100
|
+
value={(answer?.value as string) ?? ""}
|
|
101
|
+
onChange={(e) => setAnswer(q.uid, e.target.value)}
|
|
102
|
+
disabled={readOnly}
|
|
103
|
+
className="min-h-24"
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{q.type === "choice" && q.answers && (
|
|
108
|
+
<div className="flex flex-col gap-2">
|
|
109
|
+
{q.answers.map((opt) => (
|
|
110
|
+
<label key={opt.uid} className="flex items-center gap-2 cursor-pointer py-1 text-sm text-foreground has-[input:disabled]:cursor-default has-[input:disabled]:opacity-60">
|
|
111
|
+
<input
|
|
112
|
+
type="radio"
|
|
113
|
+
className="accent-primary m-0 shrink-0"
|
|
114
|
+
name={`survey-q-${q.uid}`}
|
|
115
|
+
value={opt.uid}
|
|
116
|
+
checked={(answer?.value as string) === opt.uid}
|
|
117
|
+
onChange={() => setAnswer(q.uid, opt.uid)}
|
|
118
|
+
disabled={readOnly}
|
|
119
|
+
/>
|
|
120
|
+
<span>{opt.content}</span>
|
|
121
|
+
</label>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{q.type === "multiple_choice" && q.answers && (
|
|
127
|
+
<div className="flex flex-col gap-2">
|
|
128
|
+
{q.answers.map((opt) => {
|
|
129
|
+
const selected = answers
|
|
130
|
+
.filter((a) => a.questionUid === q.uid)
|
|
131
|
+
.map((a) => String(a.value));
|
|
132
|
+
return (
|
|
133
|
+
<label key={opt.uid} className="flex items-center gap-2 cursor-pointer py-1 text-sm text-foreground has-[input:disabled]:cursor-default has-[input:disabled]:opacity-60">
|
|
134
|
+
<input
|
|
135
|
+
type="checkbox"
|
|
136
|
+
className="accent-primary m-0 shrink-0"
|
|
137
|
+
checked={selected.includes(opt.uid)}
|
|
138
|
+
disabled={readOnly}
|
|
139
|
+
onChange={(e) => {
|
|
140
|
+
const prev = answers.filter((a) => a.questionUid === q.uid);
|
|
141
|
+
let next: SurveyAnswer[];
|
|
142
|
+
if (e.target.checked) {
|
|
143
|
+
next = [...prev, { questionUid: q.uid, value: opt.uid }];
|
|
144
|
+
} else {
|
|
145
|
+
next = prev.filter((a) => String(a.value) !== opt.uid);
|
|
146
|
+
}
|
|
147
|
+
setAnswers((all) => [
|
|
148
|
+
...all.filter((a) => a.questionUid !== q.uid),
|
|
149
|
+
...next,
|
|
150
|
+
]);
|
|
151
|
+
onAnswerChange?.([
|
|
152
|
+
...answers.filter((a) => a.questionUid !== q.uid),
|
|
153
|
+
...next,
|
|
154
|
+
]);
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
<span>{opt.content}</span>
|
|
158
|
+
</label>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
</CardContent></Card>
|
|
164
|
+
);
|
|
165
|
+
})}
|
|
166
|
+
|
|
167
|
+
<div className="flex justify-between items-center mt-3">
|
|
168
|
+
<span className="text-sm text-muted-foreground">
|
|
169
|
+
{answeredCount} of {questions.length} answered
|
|
170
|
+
</span>
|
|
171
|
+
<Button
|
|
172
|
+
onClick={() => onSubmit(answers)}
|
|
173
|
+
disabled={!canSubmit || isSubmitting || readOnly}
|
|
174
|
+
>
|
|
175
|
+
{isSubmitting ? "Submitting..." : submitLabel}
|
|
176
|
+
</Button>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|