@hydralms/components 0.2.0 → 0.3.0
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-BVfZMbnV.cjs +1 -0
- package/dist/StudentProfile-DeMxdrL3.js +3275 -0
- package/dist/assessment-toolbar/question-navigator.d.ts +1 -1
- package/dist/assessment-toolbar/timer-display.d.ts +1 -1
- package/dist/common/index.d.ts +2 -1
- package/dist/common/pagination.d.ts +26 -0
- package/dist/common/types.d.ts +1 -0
- package/dist/components.css +1 -1
- package/dist/content/audio-player.d.ts +22 -0
- package/dist/content/code-block.d.ts +30 -0
- package/dist/content/embed-block.d.ts +28 -0
- package/dist/content/index.d.ts +6 -0
- package/dist/content/types.d.ts +24 -0
- package/dist/curriculum/course-card.d.ts +51 -0
- package/dist/curriculum/index.d.ts +2 -0
- package/dist/curriculum/types.d.ts +2 -2
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +494 -444
- package/dist/license/HydraContext.d.ts +16 -0
- package/dist/license/ProBadge.d.ts +6 -0
- package/dist/license/index.d.ts +7 -0
- package/dist/license/tiers.d.ts +3 -0
- package/dist/license/useHydraLicense.d.ts +6 -0
- package/dist/license/validate.d.ts +13 -0
- package/dist/license/withProGate.d.ts +6 -0
- package/dist/modules/AssignmentModule/AssignmentModule.d.ts +4 -7
- package/dist/modules/AssignmentModule/types.d.ts +5 -1
- package/dist/modules/CertificateModule/CertificateModule.d.ts +4 -8
- package/dist/modules/CertificateModule/types.d.ts +6 -4
- package/dist/modules/CourseCatalogModule/CourseCatalogModule.d.ts +5 -0
- package/dist/modules/CourseCatalogModule/types.d.ts +43 -0
- package/dist/modules/CoursePlayer/CoursePlayer.d.ts +4 -1
- package/dist/modules/DiscussionModule/DiscussionModule.d.ts +4 -7
- package/dist/modules/ExamModule/ExamModule.d.ts +4 -7
- package/dist/modules/ExamModule/types.d.ts +5 -14
- package/dist/modules/FlashcardLab/FlashcardLab.d.ts +4 -1
- package/dist/modules/FlashcardLab/types.d.ts +2 -0
- package/dist/modules/GradeCenterModule/GradeCenterModule.d.ts +4 -8
- package/dist/modules/GradeCenterModule/types.d.ts +2 -0
- package/dist/modules/QuizModule/QuizModule.d.ts +4 -1
- package/dist/modules/QuizModule/types.d.ts +5 -14
- package/dist/modules/StudentDashboardModule/StudentDashboardModule.d.ts +5 -0
- package/dist/modules/StudentDashboardModule/types.d.ts +54 -0
- package/dist/modules/StudentProfileModule/StudentProfileModule.d.ts +5 -0
- package/dist/modules/StudentProfileModule/types.d.ts +43 -0
- package/dist/modules/SurveyModule/SurveyModule.d.ts +4 -6
- package/dist/modules/SurveyModule/types.d.ts +2 -0
- package/dist/modules/_shared/assessment-intro.d.ts +16 -0
- package/dist/modules/_shared/assessment-results.d.ts +23 -0
- package/dist/modules/_shared/types.d.ts +10 -0
- package/dist/modules/_shared/use-timer.d.ts +9 -0
- package/dist/modules/index.d.ts +6 -0
- package/dist/modules.cjs +1 -1
- package/dist/modules.js +1266 -854
- package/dist/progress/types.d.ts +2 -0
- package/dist/provider/HydraProvider.d.ts +5 -1
- package/dist/questions/choice.d.ts +1 -1
- package/dist/questions/confidence-indicator.d.ts +37 -0
- package/dist/questions/essay.d.ts +1 -1
- package/dist/questions/fill-in-the-blank.d.ts +1 -1
- package/dist/questions/hotspot.d.ts +1 -1
- package/dist/questions/index.d.ts +2 -0
- package/dist/questions/inline-choice.d.ts +1 -1
- package/dist/questions/matching.d.ts +1 -1
- package/dist/questions/multiple-choice.d.ts +1 -1
- package/dist/questions/numeric.d.ts +1 -1
- package/dist/questions/ordering.d.ts +1 -1
- package/dist/questions/question-renderer.d.ts +1 -1
- package/dist/questions/scenario.d.ts +1 -1
- package/dist/questions/spreadsheet.d.ts +1 -1
- package/dist/questions/true-false.d.ts +1 -1
- package/dist/sections/AnnouncementFeed/AnnouncementFeed.d.ts +1 -1
- package/dist/sections/AnnouncementFeed/types.d.ts +15 -1
- package/dist/sections/AssessmentReview/AssessmentReview.d.ts +1 -1
- package/dist/sections/AssessmentReview/types.d.ts +6 -0
- package/dist/sections/AssignmentSubmission/AssignmentSubmission.d.ts +1 -1
- package/dist/sections/AssignmentSubmission/types.d.ts +6 -0
- package/dist/sections/CertificateViewer/CertificateViewer.d.ts +1 -1
- package/dist/sections/CertificateViewer/certificate-variants.d.ts +42 -0
- package/dist/sections/CertificateViewer/types.d.ts +6 -0
- package/dist/sections/CourseCatalog/CourseCatalog.d.ts +2 -0
- package/dist/sections/CourseCatalog/types.d.ts +80 -0
- package/dist/sections/CourseOutline/CourseOutline.d.ts +1 -1
- package/dist/sections/CourseOutline/types.d.ts +6 -0
- package/dist/sections/DiscussionThread/DiscussionThread.d.ts +1 -1
- package/dist/sections/DiscussionThread/types.d.ts +6 -0
- package/dist/sections/EnrollmentWizard/EnrollmentWizard.d.ts +2 -0
- package/dist/sections/EnrollmentWizard/types.d.ts +66 -0
- package/dist/sections/ExamSession/ExamSession.d.ts +1 -1
- package/dist/sections/ExamSession/types.d.ts +6 -0
- package/dist/sections/FlashcardStudySession/FlashcardStudySession.d.ts +1 -1
- package/dist/sections/FlashcardStudySession/types.d.ts +6 -0
- package/dist/sections/ForumBoard/ForumBoard.d.ts +1 -1
- package/dist/sections/ForumBoard/types.d.ts +14 -0
- package/dist/sections/GradebookTable/GradebookTable.d.ts +1 -1
- package/dist/sections/GradebookTable/types.d.ts +14 -0
- package/dist/sections/LecturePlayer/LecturePlayer.d.ts +1 -1
- package/dist/sections/LecturePlayer/types.d.ts +8 -0
- package/dist/sections/LessonPage/LessonPage.d.ts +1 -1
- package/dist/sections/LessonPage/types.d.ts +6 -0
- package/dist/sections/PracticeQuiz/PracticeQuiz.d.ts +1 -1
- package/dist/sections/PracticeQuiz/types.d.ts +6 -0
- package/dist/sections/ProgressDashboard/ProgressDashboard.d.ts +1 -1
- package/dist/sections/ProgressDashboard/types.d.ts +6 -0
- package/dist/sections/QuizSession/QuizSession.d.ts +1 -1
- package/dist/sections/QuizSession/types.d.ts +6 -0
- package/dist/sections/RequirementsChecklist/RequirementsChecklist.d.ts +1 -1
- package/dist/sections/RequirementsChecklist/types.d.ts +6 -0
- package/dist/sections/ResourceLibrary/ResourceLibrary.d.ts +1 -1
- package/dist/sections/ResourceLibrary/types.d.ts +15 -1
- package/dist/sections/RubricView/RubricView.d.ts +1 -1
- package/dist/sections/RubricView/types.d.ts +6 -0
- package/dist/sections/ScrollableQuiz/ScrollableQuiz.d.ts +1 -1
- package/dist/sections/ScrollableQuiz/types.d.ts +6 -0
- package/dist/sections/StudentProfile/StudentProfile.d.ts +2 -0
- package/dist/sections/StudentProfile/types.d.ts +98 -0
- package/dist/sections/SurveyForm/SurveyForm.d.ts +1 -1
- package/dist/sections/SurveyForm/types.d.ts +6 -0
- package/dist/sections/_shared/merge-answers.d.ts +9 -0
- package/dist/sections/_shared/section-shell.d.ts +20 -0
- package/dist/sections/_shared/use-assessment-session.d.ts +30 -0
- package/dist/sections/index.d.ts +6 -0
- package/dist/sections.cjs +1 -1
- package/dist/sections.js +268 -307
- package/dist/tabs-BsfVo2Bl.cjs +173 -0
- package/dist/{tabs-Wf3h_Cx3.js → tabs-BuY1iNJE.js} +7532 -6807
- package/dist/ui/badge.d.ts +1 -1
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/progress.d.ts +1 -1
- package/dist/ui/rich-text-editor.d.ts +3 -1
- package/dist/ui/toast.d.ts +43 -0
- package/dist/utils/debounce.d.ts +5 -1
- package/dist/utils/pick-palette-color.d.ts +19 -0
- package/dist/video/types.d.ts +15 -0
- package/dist/video/video-player.d.ts +1 -1
- package/dist/withProGate-BWqcKdPM.js +137 -0
- package/dist/withProGate-DX6XqKLp.cjs +1 -0
- package/package.json +34 -220
- package/src/assessment-toolbar/question-navigator.tsx +10 -5
- package/src/assessment-toolbar/timer-display.tsx +4 -3
- package/src/assessment-toolbar/use-countdown.ts +1 -1
- package/src/common/empty-state.tsx +1 -0
- package/src/common/index.ts +2 -0
- package/src/common/pagination.tsx +135 -0
- package/src/common/search-input.tsx +2 -1
- package/src/common/types.ts +2 -0
- package/src/content/attachment-list.tsx +2 -0
- package/src/content/audio-player.tsx +196 -0
- package/src/content/code-block.tsx +113 -0
- package/src/content/content-block.tsx +64 -0
- package/src/content/embed-block.tsx +78 -0
- package/src/content/file-upload-zone.tsx +10 -0
- package/src/content/index.ts +6 -0
- package/src/content/types.ts +5 -0
- package/src/curriculum/course-card.tsx +199 -0
- package/src/curriculum/curriculum-item.tsx +3 -3
- package/src/curriculum/curriculum-tree.tsx +20 -13
- package/src/curriculum/index.ts +2 -0
- package/src/curriculum/types.ts +2 -2
- package/src/flashcards/flashcard.tsx +28 -8
- package/src/index.ts +3 -0
- package/src/license/HydraContext.tsx +62 -0
- package/src/license/ProBadge.tsx +43 -0
- package/src/license/index.ts +7 -0
- package/src/license/tiers.ts +24 -0
- package/src/license/useHydraLicense.ts +10 -0
- package/src/license/validate.ts +90 -0
- package/src/license/withProGate.tsx +21 -0
- package/src/modules/AssignmentModule/AssignmentModule.tsx +17 -8
- package/src/modules/AssignmentModule/types.ts +5 -1
- package/src/modules/CertificateModule/CertificateModule.tsx +21 -9
- package/src/modules/CertificateModule/types.ts +6 -4
- package/src/modules/CourseCatalogModule/CourseCatalogModule.tsx +126 -0
- package/src/modules/CourseCatalogModule/types.ts +47 -0
- package/src/modules/CoursePlayer/CoursePlayer.tsx +37 -22
- package/src/modules/DiscussionModule/DiscussionModule.tsx +57 -22
- package/src/modules/ExamModule/ExamModule.tsx +64 -198
- package/src/modules/ExamModule/types.ts +5 -14
- package/src/modules/FlashcardLab/FlashcardLab.tsx +10 -5
- package/src/modules/FlashcardLab/types.ts +2 -0
- package/src/modules/GradeCenterModule/GradeCenterModule.tsx +7 -2
- package/src/modules/GradeCenterModule/types.ts +2 -0
- package/src/modules/QuizModule/QuizModule.tsx +49 -169
- package/src/modules/QuizModule/types.ts +5 -15
- package/src/modules/StudentDashboardModule/StudentDashboardModule.tsx +117 -0
- package/src/modules/StudentDashboardModule/types.ts +56 -0
- package/src/modules/StudentProfileModule/StudentProfileModule.tsx +289 -0
- package/src/modules/StudentProfileModule/types.ts +45 -0
- package/src/modules/SurveyModule/SurveyModule.tsx +9 -4
- package/src/modules/SurveyModule/types.ts +2 -0
- package/src/modules/_shared/assessment-intro.tsx +75 -0
- package/src/modules/_shared/assessment-results.tsx +133 -0
- package/src/modules/_shared/types.ts +11 -0
- package/src/modules/_shared/use-timer.ts +49 -0
- package/src/modules/index.ts +9 -0
- package/src/progress/achievement-badge.tsx +3 -3
- package/src/progress/grade-indicator.tsx +9 -1
- package/src/progress/progress-ring.tsx +2 -1
- package/src/progress/stat-card.tsx +8 -1
- package/src/progress/types.ts +2 -0
- package/src/provider/HydraProvider.tsx +15 -6
- package/src/questions/choice.tsx +13 -6
- package/src/questions/confidence-indicator.tsx +107 -0
- package/src/questions/essay.tsx +6 -4
- package/src/questions/fill-in-the-blank.tsx +8 -4
- package/src/questions/hotspot.tsx +4 -4
- package/src/questions/index.ts +2 -0
- package/src/questions/inline-choice.tsx +5 -4
- package/src/questions/matching.tsx +5 -4
- package/src/questions/multiple-choice.tsx +13 -6
- package/src/questions/numeric.tsx +8 -4
- package/src/questions/ordering.tsx +12 -4
- package/src/questions/question-renderer.tsx +3 -2
- package/src/questions/scenario.tsx +4 -4
- package/src/questions/spreadsheet.tsx +5 -4
- package/src/questions/true-false.tsx +13 -6
- package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +64 -8
- package/src/sections/AnnouncementFeed/types.ts +15 -1
- package/src/sections/AssessmentReview/AssessmentReview.tsx +37 -0
- package/src/sections/AssessmentReview/types.ts +6 -0
- package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +37 -1
- package/src/sections/AssignmentSubmission/types.ts +6 -0
- package/src/sections/CertificateViewer/CertificateViewer.tsx +29 -227
- package/src/sections/CertificateViewer/certificate-variants.tsx +170 -0
- package/src/sections/CertificateViewer/types.ts +6 -0
- package/src/sections/CourseCatalog/CourseCatalog.tsx +220 -0
- package/src/sections/CourseCatalog/types.ts +76 -0
- package/src/sections/CourseOutline/CourseOutline.tsx +41 -0
- package/src/sections/CourseOutline/types.ts +6 -0
- package/src/sections/DiscussionThread/DiscussionThread.tsx +42 -1
- package/src/sections/DiscussionThread/types.ts +6 -0
- package/src/sections/EnrollmentWizard/EnrollmentWizard.tsx +343 -0
- package/src/sections/EnrollmentWizard/types.ts +65 -0
- package/src/sections/ExamSession/ExamSession.tsx +100 -94
- package/src/sections/ExamSession/types.ts +6 -0
- package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +53 -36
- package/src/sections/FlashcardStudySession/types.ts +6 -0
- package/src/sections/ForumBoard/ForumBoard.tsx +59 -1
- package/src/sections/ForumBoard/types.ts +14 -0
- package/src/sections/GradebookTable/GradebookTable.tsx +54 -1
- package/src/sections/GradebookTable/types.ts +14 -0
- package/src/sections/LecturePlayer/LecturePlayer.tsx +63 -37
- package/src/sections/LecturePlayer/types.ts +8 -0
- package/src/sections/LessonPage/LessonPage.tsx +36 -5
- package/src/sections/LessonPage/types.ts +6 -0
- package/src/sections/PracticeQuiz/PracticeQuiz.tsx +106 -74
- package/src/sections/PracticeQuiz/types.ts +6 -0
- package/src/sections/ProgressDashboard/ProgressDashboard.tsx +64 -10
- package/src/sections/ProgressDashboard/types.ts +6 -0
- package/src/sections/QuizSession/QuizSession.tsx +71 -82
- package/src/sections/QuizSession/types.ts +6 -0
- package/src/sections/RequirementsChecklist/RequirementsChecklist.tsx +41 -1
- package/src/sections/RequirementsChecklist/types.ts +6 -0
- package/src/sections/ResourceLibrary/ResourceLibrary.tsx +64 -8
- package/src/sections/ResourceLibrary/types.ts +15 -1
- package/src/sections/RubricView/RubricView.tsx +37 -1
- package/src/sections/RubricView/types.ts +6 -0
- package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +36 -15
- package/src/sections/ScrollableQuiz/types.ts +6 -0
- package/src/sections/StudentProfile/StudentProfile.tsx +279 -0
- package/src/sections/StudentProfile/types.ts +99 -0
- package/src/sections/SurveyForm/SurveyForm.tsx +32 -5
- package/src/sections/SurveyForm/types.ts +6 -0
- package/src/sections/_shared/merge-answers.ts +22 -0
- package/src/sections/_shared/section-shell.tsx +64 -0
- package/src/sections/_shared/use-assessment-session.ts +125 -0
- package/src/sections/index.ts +22 -0
- package/src/social/user-avatar.tsx +9 -5
- package/src/styles/globals.css +39 -41
- package/src/ui/badge.tsx +8 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/progress.tsx +4 -0
- package/src/ui/rich-text-editor.tsx +10 -0
- package/src/ui/rich-text-toolbar.tsx +2 -1
- package/src/ui/toast.tsx +170 -0
- package/src/utils/debounce.ts +8 -2
- package/src/utils/pick-palette-color.ts +33 -0
- package/src/video/types.ts +16 -0
- package/src/video/video-player.tsx +13 -1
- package/dist/ForumBoard-CHXU3mjC.js +0 -2207
- package/dist/ForumBoard-d1w5-r6n.cjs +0 -1
- package/dist/tabs-DRM2Iq_J.cjs +0 -172
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
1
2
|
import { Clock } from "lucide-react";
|
|
2
3
|
import { cn } from "../lib/utils";
|
|
3
4
|
import { formatTimer } from "../utils/format-duration";
|
|
@@ -22,11 +23,11 @@ function getTimerClasses(isDanger: boolean, isWarning: boolean) {
|
|
|
22
23
|
};
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
export const TimerDisplay = ({
|
|
26
|
+
export const TimerDisplay = memo(function TimerDisplay({
|
|
26
27
|
timeElapsedSeconds,
|
|
27
28
|
timeLimitSeconds,
|
|
28
29
|
variant = "compact",
|
|
29
|
-
}: TimerDisplayProps)
|
|
30
|
+
}: TimerDisplayProps) {
|
|
30
31
|
const hasTimeLimit = timeLimitSeconds != null && timeLimitSeconds > 0;
|
|
31
32
|
const remainingSeconds = hasTimeLimit
|
|
32
33
|
? Math.max(0, timeLimitSeconds - timeElapsedSeconds)
|
|
@@ -70,4 +71,4 @@ export const TimerDisplay = ({
|
|
|
70
71
|
</span>
|
|
71
72
|
</div>
|
|
72
73
|
);
|
|
73
|
-
};
|
|
74
|
+
});
|
|
@@ -6,6 +6,7 @@ export function EmptyState({ icon, title, description, action, className, style
|
|
|
6
6
|
<div
|
|
7
7
|
className={cn("flex flex-col items-center justify-center text-center px-3 py-6", className)}
|
|
8
8
|
style={style}
|
|
9
|
+
role="status"
|
|
9
10
|
>
|
|
10
11
|
{icon && (
|
|
11
12
|
<div className="mb-2 text-muted-foreground [&>svg]:size-12">
|
package/src/common/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { SearchInput } from "./search-input";
|
|
|
4
4
|
export { StatusBadge } from "./status-badge";
|
|
5
5
|
export { DueDateDisplay } from "./due-date-display";
|
|
6
6
|
export { Stepper } from "./stepper";
|
|
7
|
+
export { Pagination } from "./pagination";
|
|
7
8
|
export type {
|
|
8
9
|
EmptyStateProps,
|
|
9
10
|
ConfirmDialogProps,
|
|
@@ -12,4 +13,5 @@ export type {
|
|
|
12
13
|
DueDateDisplayProps,
|
|
13
14
|
StepperProps,
|
|
14
15
|
StepDefinition,
|
|
16
|
+
PaginationProps,
|
|
15
17
|
} from "./types";
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { memo, useMemo } from "react";
|
|
2
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import { Button } from "../ui/button";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pagination provides page navigation controls with page numbers,
|
|
8
|
+
* previous/next buttons, and ellipsis for large page ranges.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* <Pagination
|
|
12
|
+
* currentPage={3}
|
|
13
|
+
* totalPages={10}
|
|
14
|
+
* onPageChange={(page) => setPage(page)}
|
|
15
|
+
* />
|
|
16
|
+
*/
|
|
17
|
+
export interface PaginationProps {
|
|
18
|
+
/** Current active page (1-indexed) */
|
|
19
|
+
currentPage: number;
|
|
20
|
+
/** Total number of pages */
|
|
21
|
+
totalPages: number;
|
|
22
|
+
/** Called when the user navigates to a page */
|
|
23
|
+
onPageChange: (page: number) => void;
|
|
24
|
+
/** Number of page buttons shown on each side of the current page (default 1) */
|
|
25
|
+
siblingCount?: number;
|
|
26
|
+
/** CSS class name for the root element */
|
|
27
|
+
className?: string;
|
|
28
|
+
/** Inline styles for the root element */
|
|
29
|
+
style?: React.CSSProperties;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getPageRange(
|
|
33
|
+
current: number,
|
|
34
|
+
total: number,
|
|
35
|
+
siblings: number,
|
|
36
|
+
): (number | "ellipsis")[] {
|
|
37
|
+
const totalSlots = siblings * 2 + 5;
|
|
38
|
+
if (total <= totalSlots) {
|
|
39
|
+
return Array.from({ length: total }, (_, i) => i + 1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const leftSibling = Math.max(current - siblings, 1);
|
|
43
|
+
const rightSibling = Math.min(current + siblings, total);
|
|
44
|
+
const showLeftEllipsis = leftSibling > 2;
|
|
45
|
+
const showRightEllipsis = rightSibling < total - 1;
|
|
46
|
+
|
|
47
|
+
if (!showLeftEllipsis && showRightEllipsis) {
|
|
48
|
+
const leftCount = siblings * 2 + 3;
|
|
49
|
+
const leftRange = Array.from({ length: leftCount }, (_, i) => i + 1);
|
|
50
|
+
return [...leftRange, "ellipsis", total];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (showLeftEllipsis && !showRightEllipsis) {
|
|
54
|
+
const rightCount = siblings * 2 + 3;
|
|
55
|
+
const rightRange = Array.from(
|
|
56
|
+
{ length: rightCount },
|
|
57
|
+
(_, i) => total - rightCount + i + 1,
|
|
58
|
+
);
|
|
59
|
+
return [1, "ellipsis", ...rightRange];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const middleRange = Array.from(
|
|
63
|
+
{ length: rightSibling - leftSibling + 1 },
|
|
64
|
+
(_, i) => leftSibling + i,
|
|
65
|
+
);
|
|
66
|
+
return [1, "ellipsis", ...middleRange, "ellipsis", total];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const Pagination = memo(function Pagination({
|
|
70
|
+
currentPage,
|
|
71
|
+
totalPages,
|
|
72
|
+
onPageChange,
|
|
73
|
+
siblingCount = 1,
|
|
74
|
+
className,
|
|
75
|
+
style,
|
|
76
|
+
}: PaginationProps) {
|
|
77
|
+
const pages = useMemo(
|
|
78
|
+
() => getPageRange(currentPage, totalPages, siblingCount),
|
|
79
|
+
[currentPage, totalPages, siblingCount],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (totalPages <= 1) return null;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<nav
|
|
86
|
+
data-slot="pagination"
|
|
87
|
+
aria-label="Pagination"
|
|
88
|
+
className={cn("flex items-center justify-center gap-1", className)}
|
|
89
|
+
style={style}
|
|
90
|
+
>
|
|
91
|
+
<Button
|
|
92
|
+
variant="ghost"
|
|
93
|
+
size="icon-sm"
|
|
94
|
+
onClick={() => onPageChange(currentPage - 1)}
|
|
95
|
+
disabled={currentPage <= 1}
|
|
96
|
+
aria-label="Previous page"
|
|
97
|
+
>
|
|
98
|
+
<ChevronLeft className="size-4" />
|
|
99
|
+
</Button>
|
|
100
|
+
|
|
101
|
+
{pages.map((page, i) =>
|
|
102
|
+
page === "ellipsis" ? (
|
|
103
|
+
<span
|
|
104
|
+
key={`ellipsis-${i}`}
|
|
105
|
+
className="flex items-center justify-center size-8 text-xs text-muted-foreground"
|
|
106
|
+
>
|
|
107
|
+
<span aria-hidden="true">...</span>
|
|
108
|
+
<span className="sr-only">More pages</span>
|
|
109
|
+
</span>
|
|
110
|
+
) : (
|
|
111
|
+
<Button
|
|
112
|
+
key={page}
|
|
113
|
+
variant={page === currentPage ? "default" : "ghost"}
|
|
114
|
+
size="icon-sm"
|
|
115
|
+
onClick={() => onPageChange(page)}
|
|
116
|
+
aria-label={`Page ${page}`}
|
|
117
|
+
aria-current={page === currentPage ? "page" : undefined}
|
|
118
|
+
>
|
|
119
|
+
<span className="text-xs">{page}</span>
|
|
120
|
+
</Button>
|
|
121
|
+
),
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
<Button
|
|
125
|
+
variant="ghost"
|
|
126
|
+
size="icon-sm"
|
|
127
|
+
onClick={() => onPageChange(currentPage + 1)}
|
|
128
|
+
disabled={currentPage >= totalPages}
|
|
129
|
+
aria-label="Next page"
|
|
130
|
+
>
|
|
131
|
+
<ChevronRight className="size-4" />
|
|
132
|
+
</Button>
|
|
133
|
+
</nav>
|
|
134
|
+
);
|
|
135
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useMemo, useRef } from "react";
|
|
1
|
+
import { useState, useMemo, useRef, useEffect } from "react";
|
|
2
2
|
import { Search, X } from "lucide-react";
|
|
3
3
|
import { debounce } from "../utils/debounce";
|
|
4
4
|
import type { SearchInputProps } from "./types";
|
|
@@ -23,6 +23,7 @@ export function SearchInput({
|
|
|
23
23
|
() => debounce((val: string) => onChangeRef.current(val), debounceMs),
|
|
24
24
|
[debounceMs],
|
|
25
25
|
);
|
|
26
|
+
useEffect(() => () => debouncedOnChange.cancel(), [debouncedOnChange]);
|
|
26
27
|
|
|
27
28
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
28
29
|
const next = e.target.value;
|
package/src/common/types.ts
CHANGED
|
@@ -68,6 +68,7 @@ export function AttachmentList({
|
|
|
68
68
|
size="sm"
|
|
69
69
|
className="shrink-0 h-7 w-7 p-0"
|
|
70
70
|
onClick={() => onDownload(file)}
|
|
71
|
+
aria-label={`Download ${file.name}`}
|
|
71
72
|
>
|
|
72
73
|
<Download size={14} />
|
|
73
74
|
</Button>
|
|
@@ -78,6 +79,7 @@ export function AttachmentList({
|
|
|
78
79
|
size="sm"
|
|
79
80
|
className="shrink-0 h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
|
|
80
81
|
onClick={() => onRemove(file)}
|
|
82
|
+
aria-label={`Remove ${file.name}`}
|
|
81
83
|
>
|
|
82
84
|
<X size={14} />
|
|
83
85
|
</Button>
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { memo, useCallback, useRef, useState } from "react";
|
|
2
|
+
import { Play, Pause, Volume2, VolumeX } from "lucide-react";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import { Button } from "../ui/button";
|
|
5
|
+
import { formatTimer } from "../utils/format-duration";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* AudioPlayer provides an HTML5 audio player with custom controls
|
|
9
|
+
* including play/pause, seek, speed adjustment, and mute toggle.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* <AudioPlayer src="/audio/lecture-1.mp3" title="Lecture 1: Introduction" />
|
|
13
|
+
*/
|
|
14
|
+
export interface AudioPlayerProps {
|
|
15
|
+
/** Audio source URL */
|
|
16
|
+
src: string;
|
|
17
|
+
/** Optional title displayed above the player */
|
|
18
|
+
title?: string;
|
|
19
|
+
/** Called when playback ends */
|
|
20
|
+
onEnded?: () => void;
|
|
21
|
+
/** Called on time update with current time and duration */
|
|
22
|
+
onTimeUpdate?: (currentTime: number, duration: number) => void;
|
|
23
|
+
/** CSS class name for the root element */
|
|
24
|
+
className?: string;
|
|
25
|
+
/** Inline styles for the root element */
|
|
26
|
+
style?: React.CSSProperties;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
|
30
|
+
|
|
31
|
+
export const AudioPlayer = memo(function AudioPlayer({
|
|
32
|
+
src,
|
|
33
|
+
title,
|
|
34
|
+
onEnded,
|
|
35
|
+
onTimeUpdate,
|
|
36
|
+
className,
|
|
37
|
+
style,
|
|
38
|
+
}: AudioPlayerProps) {
|
|
39
|
+
const audioRef = useRef<HTMLAudioElement>(null);
|
|
40
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
41
|
+
const [isMuted, setIsMuted] = useState(false);
|
|
42
|
+
const [currentTime, setCurrentTime] = useState(0);
|
|
43
|
+
const [duration, setDuration] = useState(0);
|
|
44
|
+
const [speed, setSpeed] = useState(1);
|
|
45
|
+
const lastReportedTime = useRef(-1);
|
|
46
|
+
const onTimeUpdateRef = useRef(onTimeUpdate);
|
|
47
|
+
onTimeUpdateRef.current = onTimeUpdate;
|
|
48
|
+
|
|
49
|
+
const togglePlay = useCallback(() => {
|
|
50
|
+
const audio = audioRef.current;
|
|
51
|
+
if (!audio) return;
|
|
52
|
+
if (audio.paused) {
|
|
53
|
+
audio.play();
|
|
54
|
+
} else {
|
|
55
|
+
audio.pause();
|
|
56
|
+
}
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const toggleMute = useCallback(() => {
|
|
60
|
+
const audio = audioRef.current;
|
|
61
|
+
if (!audio) return;
|
|
62
|
+
audio.muted = !audio.muted;
|
|
63
|
+
setIsMuted((prev) => !prev);
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const cycleSpeed = useCallback(() => {
|
|
67
|
+
const audio = audioRef.current;
|
|
68
|
+
if (!audio) return;
|
|
69
|
+
setSpeed((prev) => {
|
|
70
|
+
const idx = SPEEDS.indexOf(prev);
|
|
71
|
+
const next = SPEEDS[(idx + 1) % SPEEDS.length];
|
|
72
|
+
audio.playbackRate = next;
|
|
73
|
+
return next;
|
|
74
|
+
});
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
const handleSeek = useCallback(
|
|
78
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
79
|
+
const audio = audioRef.current;
|
|
80
|
+
if (!audio) return;
|
|
81
|
+
const time = Number(e.target.value);
|
|
82
|
+
audio.currentTime = time;
|
|
83
|
+
lastReportedTime.current = Math.floor(time * 4) / 4;
|
|
84
|
+
setCurrentTime(time);
|
|
85
|
+
},
|
|
86
|
+
[],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const handleTimeUpdate = useCallback(() => {
|
|
90
|
+
const audio = audioRef.current;
|
|
91
|
+
if (!audio) return;
|
|
92
|
+
const rounded = Math.floor(audio.currentTime * 4) / 4;
|
|
93
|
+
if (rounded !== lastReportedTime.current) {
|
|
94
|
+
lastReportedTime.current = rounded;
|
|
95
|
+
setCurrentTime(audio.currentTime);
|
|
96
|
+
onTimeUpdateRef.current?.(audio.currentTime, audio.duration);
|
|
97
|
+
}
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
const handleLoadedMetadata = useCallback(() => {
|
|
101
|
+
const audio = audioRef.current;
|
|
102
|
+
if (audio && !isNaN(audio.duration)) setDuration(audio.duration);
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
data-slot="audio-player"
|
|
110
|
+
className={cn("rounded-lg border bg-card p-4 space-y-3", className)}
|
|
111
|
+
style={style}
|
|
112
|
+
>
|
|
113
|
+
{title && (
|
|
114
|
+
<p className="text-sm font-medium text-foreground">{title}</p>
|
|
115
|
+
)}
|
|
116
|
+
<audio
|
|
117
|
+
ref={audioRef}
|
|
118
|
+
src={src}
|
|
119
|
+
onPlay={() => setIsPlaying(true)}
|
|
120
|
+
onPause={() => setIsPlaying(false)}
|
|
121
|
+
onEnded={() => {
|
|
122
|
+
setIsPlaying(false);
|
|
123
|
+
onEnded?.();
|
|
124
|
+
}}
|
|
125
|
+
onTimeUpdate={handleTimeUpdate}
|
|
126
|
+
onLoadedMetadata={handleLoadedMetadata}
|
|
127
|
+
preload="metadata"
|
|
128
|
+
/>
|
|
129
|
+
<div className="flex items-center gap-3">
|
|
130
|
+
<Button
|
|
131
|
+
variant="ghost"
|
|
132
|
+
size="icon-sm"
|
|
133
|
+
onClick={togglePlay}
|
|
134
|
+
aria-label={isPlaying ? "Pause" : "Play"}
|
|
135
|
+
>
|
|
136
|
+
{isPlaying ? (
|
|
137
|
+
<Pause className="size-4" />
|
|
138
|
+
) : (
|
|
139
|
+
<Play className="size-4" />
|
|
140
|
+
)}
|
|
141
|
+
</Button>
|
|
142
|
+
|
|
143
|
+
<span className="text-xs tabular-nums text-muted-foreground w-12 shrink-0">
|
|
144
|
+
{formatTimer(Math.floor(currentTime))}
|
|
145
|
+
</span>
|
|
146
|
+
|
|
147
|
+
<div className="relative flex-1 h-5 flex items-center">
|
|
148
|
+
<div className="absolute inset-y-0 left-0 flex items-center w-full">
|
|
149
|
+
<div className="w-full h-1 rounded-full bg-muted overflow-hidden">
|
|
150
|
+
<div
|
|
151
|
+
className="h-full bg-primary rounded-full transition-[width] duration-100"
|
|
152
|
+
style={{ width: `${progress}%` }}
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
<input
|
|
157
|
+
type="range"
|
|
158
|
+
min={0}
|
|
159
|
+
max={duration || 0}
|
|
160
|
+
step={0.1}
|
|
161
|
+
value={currentTime}
|
|
162
|
+
onChange={handleSeek}
|
|
163
|
+
className="absolute inset-0 w-full opacity-0 cursor-pointer"
|
|
164
|
+
aria-label="Seek"
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<span className="text-xs tabular-nums text-muted-foreground w-12 shrink-0 text-right">
|
|
169
|
+
{formatTimer(Math.floor(duration))}
|
|
170
|
+
</span>
|
|
171
|
+
|
|
172
|
+
<Button
|
|
173
|
+
variant="ghost"
|
|
174
|
+
size="icon-xs"
|
|
175
|
+
onClick={toggleMute}
|
|
176
|
+
aria-label={isMuted ? "Unmute" : "Mute"}
|
|
177
|
+
>
|
|
178
|
+
{isMuted ? (
|
|
179
|
+
<VolumeX className="size-3.5" />
|
|
180
|
+
) : (
|
|
181
|
+
<Volume2 className="size-3.5" />
|
|
182
|
+
)}
|
|
183
|
+
</Button>
|
|
184
|
+
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
onClick={cycleSpeed}
|
|
188
|
+
className="text-xs font-medium text-muted-foreground hover:text-foreground transition-colors tabular-nums w-8 text-center"
|
|
189
|
+
aria-label={`Playback speed ${speed}x`}
|
|
190
|
+
>
|
|
191
|
+
{speed}x
|
|
192
|
+
</button>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Copy, Check, FileCode } from "lucide-react";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import { Button } from "../ui/button";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* CodeBlock renders source code with optional line numbers, a filename header,
|
|
8
|
+
* and a copy-to-clipboard button. Does not bundle a syntax highlighter —
|
|
9
|
+
* consumers can apply their own (Prism, Shiki, etc.) via the `language-*` class on the `<code>` element.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* <CodeBlock
|
|
13
|
+
* code="console.log('hello');"
|
|
14
|
+
* language="javascript"
|
|
15
|
+
* filename="example.js"
|
|
16
|
+
* showLineNumbers
|
|
17
|
+
* />
|
|
18
|
+
*/
|
|
19
|
+
export interface CodeBlockProps {
|
|
20
|
+
/** The code content to display */
|
|
21
|
+
code: string;
|
|
22
|
+
/** Programming language identifier (applied as `language-*` class on `<code>`) */
|
|
23
|
+
language?: string;
|
|
24
|
+
/** Optional filename shown in the header bar */
|
|
25
|
+
filename?: string;
|
|
26
|
+
/** Whether to display line numbers */
|
|
27
|
+
showLineNumbers?: boolean;
|
|
28
|
+
/** Called after code is copied to clipboard */
|
|
29
|
+
onCopy?: () => void;
|
|
30
|
+
/** CSS class name for the root element */
|
|
31
|
+
className?: string;
|
|
32
|
+
/** Inline styles for the root element */
|
|
33
|
+
style?: React.CSSProperties;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const CodeBlock = memo(function CodeBlock({
|
|
37
|
+
code,
|
|
38
|
+
language,
|
|
39
|
+
filename,
|
|
40
|
+
showLineNumbers = false,
|
|
41
|
+
onCopy,
|
|
42
|
+
className,
|
|
43
|
+
style,
|
|
44
|
+
}: CodeBlockProps) {
|
|
45
|
+
const [copied, setCopied] = useState(false);
|
|
46
|
+
const hasHeader = !!(filename || language);
|
|
47
|
+
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
48
|
+
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
|
|
49
|
+
|
|
50
|
+
const handleCopy = useCallback(async () => {
|
|
51
|
+
try {
|
|
52
|
+
await navigator.clipboard.writeText(code);
|
|
53
|
+
setCopied(true);
|
|
54
|
+
onCopy?.();
|
|
55
|
+
clearTimeout(copyTimerRef.current);
|
|
56
|
+
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
|
|
57
|
+
} catch {
|
|
58
|
+
// Clipboard API not available
|
|
59
|
+
}
|
|
60
|
+
}, [code, onCopy]);
|
|
61
|
+
|
|
62
|
+
const lines = code.split("\n");
|
|
63
|
+
|
|
64
|
+
const copyButton = (
|
|
65
|
+
<Button
|
|
66
|
+
variant="ghost"
|
|
67
|
+
size="icon-xs"
|
|
68
|
+
onClick={handleCopy}
|
|
69
|
+
aria-label={copied ? "Copied" : "Copy code"}
|
|
70
|
+
>
|
|
71
|
+
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
|
72
|
+
</Button>
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div
|
|
77
|
+
data-slot="code-block"
|
|
78
|
+
className={cn(
|
|
79
|
+
"relative rounded-lg border bg-card overflow-hidden text-sm",
|
|
80
|
+
className,
|
|
81
|
+
)}
|
|
82
|
+
style={style}
|
|
83
|
+
>
|
|
84
|
+
{hasHeader ? (
|
|
85
|
+
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50">
|
|
86
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
87
|
+
<FileCode className="size-3.5" />
|
|
88
|
+
<span>{filename ?? language}</span>
|
|
89
|
+
</div>
|
|
90
|
+
{copyButton}
|
|
91
|
+
</div>
|
|
92
|
+
) : (
|
|
93
|
+
<div className="absolute top-2 right-2 z-10">{copyButton}</div>
|
|
94
|
+
)}
|
|
95
|
+
<div className="overflow-x-auto">
|
|
96
|
+
<pre className="p-4 m-0">
|
|
97
|
+
<code className={language ? `language-${language}` : undefined}>
|
|
98
|
+
{showLineNumbers
|
|
99
|
+
? lines.map((line, i) => (
|
|
100
|
+
<div key={i} className="flex">
|
|
101
|
+
<span className="select-none text-muted-foreground/50 w-8 shrink-0 text-right pr-4 tabular-nums">
|
|
102
|
+
{i + 1}
|
|
103
|
+
</span>
|
|
104
|
+
<span>{line}</span>
|
|
105
|
+
</div>
|
|
106
|
+
))
|
|
107
|
+
: code}
|
|
108
|
+
</code>
|
|
109
|
+
</pre>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
});
|
|
@@ -5,6 +5,19 @@ import { QuestionRenderer } from "../questions";
|
|
|
5
5
|
import { FlashcardDeck } from "../flashcards";
|
|
6
6
|
import { Alert, AlertDescription } from "../ui/alert";
|
|
7
7
|
import { Separator } from "../ui/separator";
|
|
8
|
+
import {
|
|
9
|
+
Table,
|
|
10
|
+
TableHeader,
|
|
11
|
+
TableBody,
|
|
12
|
+
TableRow,
|
|
13
|
+
TableHead,
|
|
14
|
+
TableCell,
|
|
15
|
+
TableCaption,
|
|
16
|
+
} from "../ui/table";
|
|
17
|
+
import { AudioPlayer } from "./audio-player";
|
|
18
|
+
import { CodeBlock } from "./code-block";
|
|
19
|
+
import { EmbedBlock } from "./embed-block";
|
|
20
|
+
import { AttachmentList } from "./attachment-list";
|
|
8
21
|
import type { ContentBlockProps } from "./types";
|
|
9
22
|
import { cn } from "../lib/utils";
|
|
10
23
|
|
|
@@ -106,6 +119,57 @@ export const ContentBlock = memo(function ContentBlock({
|
|
|
106
119
|
/>
|
|
107
120
|
);
|
|
108
121
|
|
|
122
|
+
case "audio":
|
|
123
|
+
return wrapper(
|
|
124
|
+
<AudioPlayer src={block.src} title={block.title} />
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
case "code":
|
|
128
|
+
return wrapper(
|
|
129
|
+
<CodeBlock
|
|
130
|
+
code={block.code}
|
|
131
|
+
language={block.language}
|
|
132
|
+
filename={block.filename}
|
|
133
|
+
showLineNumbers={block.showLineNumbers}
|
|
134
|
+
/>
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
case "embed":
|
|
138
|
+
return wrapper(
|
|
139
|
+
<EmbedBlock
|
|
140
|
+
src={block.src}
|
|
141
|
+
title={block.title}
|
|
142
|
+
aspectRatio={block.aspectRatio}
|
|
143
|
+
allowFullscreen={block.allowFullscreen}
|
|
144
|
+
/>
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
case "table":
|
|
148
|
+
return wrapper(
|
|
149
|
+
<Table>
|
|
150
|
+
{block.caption && <TableCaption>{block.caption}</TableCaption>}
|
|
151
|
+
<TableHeader>
|
|
152
|
+
<TableRow>
|
|
153
|
+
{block.headers.map((header, i) => (
|
|
154
|
+
<TableHead key={i}>{header}</TableHead>
|
|
155
|
+
))}
|
|
156
|
+
</TableRow>
|
|
157
|
+
</TableHeader>
|
|
158
|
+
<TableBody>
|
|
159
|
+
{block.rows.map((row, i) => (
|
|
160
|
+
<TableRow key={i}>
|
|
161
|
+
{row.map((cell, j) => (
|
|
162
|
+
<TableCell key={j}>{cell}</TableCell>
|
|
163
|
+
))}
|
|
164
|
+
</TableRow>
|
|
165
|
+
))}
|
|
166
|
+
</TableBody>
|
|
167
|
+
</Table>
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
case "file":
|
|
171
|
+
return wrapper(<AttachmentList files={block.files} readOnly />);
|
|
172
|
+
|
|
109
173
|
case "divider":
|
|
110
174
|
return wrapper(<Separator />);
|
|
111
175
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { memo, useState } from "react";
|
|
2
|
+
import { ExternalLink } from "lucide-react";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import { Skeleton } from "../ui/skeleton";
|
|
5
|
+
|
|
6
|
+
export type EmbedAspectRatio = "16/9" | "4/3" | "1/1";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* EmbedBlock renders a responsive iframe wrapper with an optional title bar
|
|
10
|
+
* and loading skeleton. Ideal for embedding YouTube videos, SCORM objects,
|
|
11
|
+
* Google Slides, and other external content.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* <EmbedBlock
|
|
15
|
+
* src="https://www.youtube.com/embed/dQw4w9WgXcQ"
|
|
16
|
+
* title="Introduction Video"
|
|
17
|
+
* aspectRatio="16/9"
|
|
18
|
+
* />
|
|
19
|
+
*/
|
|
20
|
+
export interface EmbedBlockProps {
|
|
21
|
+
/** iframe source URL */
|
|
22
|
+
src: string;
|
|
23
|
+
/** Optional title for the embed */
|
|
24
|
+
title?: string;
|
|
25
|
+
/** Aspect ratio of the embed container */
|
|
26
|
+
aspectRatio?: EmbedAspectRatio;
|
|
27
|
+
/** Whether to allow fullscreen */
|
|
28
|
+
allowFullscreen?: boolean;
|
|
29
|
+
/** CSS class name for the root element */
|
|
30
|
+
className?: string;
|
|
31
|
+
/** Inline styles for the root element */
|
|
32
|
+
style?: React.CSSProperties;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ASPECT_CLASSES: Record<EmbedAspectRatio, string> = {
|
|
36
|
+
"16/9": "aspect-video",
|
|
37
|
+
"4/3": "aspect-[4/3]",
|
|
38
|
+
"1/1": "aspect-square",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const EmbedBlock = memo(function EmbedBlock({
|
|
42
|
+
src,
|
|
43
|
+
title,
|
|
44
|
+
aspectRatio = "16/9",
|
|
45
|
+
allowFullscreen = true,
|
|
46
|
+
className,
|
|
47
|
+
style,
|
|
48
|
+
}: EmbedBlockProps) {
|
|
49
|
+
const [isLoaded, setIsLoaded] = useState(false);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
data-slot="embed-block"
|
|
54
|
+
className={cn("rounded-lg border bg-card overflow-hidden", className)}
|
|
55
|
+
style={style}
|
|
56
|
+
>
|
|
57
|
+
{title && (
|
|
58
|
+
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50">
|
|
59
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
60
|
+
<ExternalLink className="size-3.5" />
|
|
61
|
+
<span>{title}</span>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
<div className={cn("relative w-full", ASPECT_CLASSES[aspectRatio])}>
|
|
66
|
+
{!isLoaded && <Skeleton className="absolute inset-0 rounded-none" />}
|
|
67
|
+
<iframe
|
|
68
|
+
src={src}
|
|
69
|
+
title={title ?? "Embedded content"}
|
|
70
|
+
allowFullScreen={allowFullscreen}
|
|
71
|
+
onLoad={() => setIsLoaded(true)}
|
|
72
|
+
className="absolute inset-0 w-full h-full border-0"
|
|
73
|
+
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
});
|