feat: implement core micro-learning components and hooks for diverse educational formats
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; // assuming some UI library, otherwise use standard divs
|
import Card from '../ui/Card';
|
||||||
|
|
||||||
export default function ConceptExplainer({ content, onComplete }) {
|
export default function ConceptExplainer({ content, onComplete }) {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
@@ -25,12 +25,12 @@ export default function ConceptExplainer({ content, onComplete }) {
|
|||||||
}, [onComplete]);
|
}, [onComplete]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full h-[60vh] flex flex-col">
|
<Card className="w-full h-[60vh] flex flex-col p-0">
|
||||||
<CardHeader>
|
<div className="p-6 border-b border-bg-warm">
|
||||||
<CardTitle>Concept Explainer</CardTitle>
|
<h2 className="text-xl font-bold">Concept Explainer</h2>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent
|
<div
|
||||||
className="flex-1 overflow-y-auto prose dark:prose-invert"
|
className="flex-1 overflow-y-auto p-6 prose dark:prose-invert"
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
>
|
>
|
||||||
{content?.sections?.map((section, i) => (
|
{content?.sections?.map((section, i) => (
|
||||||
@@ -39,7 +39,7 @@ export default function ConceptExplainer({ content, onComplete }) {
|
|||||||
<div dangerouslySetInnerHTML={{ __html: section.content }} />
|
<div dangerouslySetInnerHTML={{ __html: section.content }} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Card, CardContent } from '../ui/card';
|
import Card from '../ui/Card';
|
||||||
import { Button } from '../ui/button';
|
import Button from '../ui/Button';
|
||||||
|
|
||||||
export default function FlashcardSet({ content, onComplete }) {
|
export default function FlashcardSet({ content, onComplete }) {
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
@@ -12,12 +12,10 @@ export default function FlashcardSet({ content, onComplete }) {
|
|||||||
const handleFlip = () => {
|
const handleFlip = () => {
|
||||||
setIsFlipped(!isFlipped);
|
setIsFlipped(!isFlipped);
|
||||||
|
|
||||||
// Once flipped, mark this card as viewed
|
|
||||||
const newViewed = new Set(viewedCards);
|
const newViewed = new Set(viewedCards);
|
||||||
newViewed.add(currentIndex);
|
newViewed.add(currentIndex);
|
||||||
setViewedCards(newViewed);
|
setViewedCards(newViewed);
|
||||||
|
|
||||||
// If all cards are viewed, trigger completion
|
|
||||||
if (newViewed.size === cards.length && cards.length > 0) {
|
if (newViewed.size === cards.length && cards.length > 0) {
|
||||||
onComplete();
|
onComplete();
|
||||||
}
|
}
|
||||||
@@ -41,25 +39,25 @@ export default function FlashcardSet({ content, onComplete }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col items-center space-y-6">
|
<div className="w-full flex flex-col items-center space-y-6">
|
||||||
<div className="text-sm text-slate-500">
|
<div className="text-sm text-slate-500 font-medium">
|
||||||
Card {currentIndex + 1} of {cards.length}
|
Card {currentIndex + 1} of {cards.length}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
className="w-full max-w-lg min-h-[300px] cursor-pointer perspective-1000 relative"
|
className="w-full max-w-lg min-h-[300px] cursor-pointer relative flex items-center justify-center p-8 hover:shadow-pill transition-all"
|
||||||
onClick={handleFlip}
|
onClick={handleFlip}
|
||||||
>
|
>
|
||||||
<CardContent className="absolute inset-0 flex items-center justify-center p-8 text-center text-xl">
|
<div className="text-center text-xl">
|
||||||
{isFlipped ? (
|
{isFlipped ? (
|
||||||
<div className="prose dark:prose-invert">
|
<div className="prose dark:prose-invert">
|
||||||
<p>{currentCard.back}</p>
|
<p>{currentCard.back}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="prose dark:prose-invert font-medium">
|
<div className="prose dark:prose-invert font-bold text-teal">
|
||||||
<p>{currentCard.front}</p>
|
<p>{currentCard.front}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="text-sm text-slate-400 mt-2">
|
<div className="text-sm text-slate-400 mt-2">
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useMicroLearnings } from '../../hooks/useMicroLearnings';
|
import { useMicroLearnings } from '../../hooks/useMicroLearnings';
|
||||||
import MicroLearningContainer from './MicroLearningContainer';
|
import MicroLearningContainer from './MicroLearningContainer';
|
||||||
import { Button } from '../ui/button';
|
import Button from '../ui/Button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
import Card from '../ui/Card';
|
||||||
|
|
||||||
const TYPE_LABELS = {
|
const TYPE_LABELS = {
|
||||||
'concept_explainer': 'Concept Explainer',
|
'concept_explainer': 'Concept Explainer',
|
||||||
@@ -40,19 +40,21 @@ export default function MicroLearningSelector({ topicId, sessionWeek, onTopicCom
|
|||||||
setSelectedML(ml);
|
setSelectedML(ml);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <div className="text-slate-500">Loading learning formats...</div>;
|
if (loading) return <div className="text-slate-500 text-center py-8">Loading learning formats...</div>;
|
||||||
|
|
||||||
if (availableMLs.length === 0) {
|
if (availableMLs.length === 0) {
|
||||||
return <div className="text-slate-500">No micro learnings available for this topic yet.</div>;
|
return <div className="text-slate-500 text-center py-8">No micro learnings available for this topic yet.</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If one is selected, render it
|
|
||||||
if (selectedML) {
|
if (selectedML) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Button variant="ghost" onClick={() => setSelectedML(null)} className="mb-4">
|
<button
|
||||||
|
onClick={() => setSelectedML(null)}
|
||||||
|
className="text-fg-muted hover:text-teal mb-4 text-sm font-medium"
|
||||||
|
>
|
||||||
← Back to selection
|
← Back to selection
|
||||||
</Button>
|
</button>
|
||||||
<MicroLearningContainer
|
<MicroLearningContainer
|
||||||
microLearning={selectedML}
|
microLearning={selectedML}
|
||||||
sessionWeek={sessionWeek}
|
sessionWeek={sessionWeek}
|
||||||
@@ -62,28 +64,23 @@ export default function MicroLearningSelector({ topicId, sessionWeek, onTopicCom
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, show the selection menu
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<div className="mb-6 pb-4 border-b border-bg-warm">
|
||||||
<CardTitle>Choose a Learning Format</CardTitle>
|
<h2 className="text-xl font-bold">Choose a Learning Format</h2>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{availableMLs.map((ml) => (
|
||||||
{availableMLs.map((ml) => (
|
<div
|
||||||
<Card
|
key={ml.id}
|
||||||
key={ml.id}
|
className="cursor-pointer border-2 border-bg-warm rounded-[var(--r-md)] p-6 hover:border-teal hover:bg-teal/5 transition-all"
|
||||||
className="cursor-pointer hover:border-blue-500 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors"
|
onClick={() => handleSelection(ml)}
|
||||||
onClick={() => handleSelection(ml)}
|
>
|
||||||
>
|
<h3 className="font-bold text-lg mb-2 text-teal">{TYPE_LABELS[ml.type] || ml.type}</h3>
|
||||||
<CardContent className="p-6">
|
<p className="text-sm text-fg-muted">{TYPE_DESCRIPTIONS[ml.type]}</p>
|
||||||
<h3 className="font-bold text-lg mb-2">{TYPE_LABELS[ml.type] || ml.type}</h3>
|
</div>
|
||||||
<p className="text-sm text-slate-500">{TYPE_DESCRIPTIONS[ml.type]}</p>
|
))}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
import Card from '../ui/Card';
|
||||||
import { Button } from '../ui/button';
|
import Button from '../ui/Button';
|
||||||
|
|
||||||
export default function ReflectionPrompt({ content, onComplete }) {
|
export default function ReflectionPrompt({ content, onComplete }) {
|
||||||
const [response, setResponse] = useState('');
|
const [response, setResponse] = useState('');
|
||||||
@@ -11,15 +11,15 @@ export default function ReflectionPrompt({ content, onComplete }) {
|
|||||||
if (!response.trim() || submitted) return;
|
if (!response.trim() || submitted) return;
|
||||||
|
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
onComplete(); // Trigger completion when a response is submitted
|
onComplete();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<div className="mb-6 pb-4 border-b border-bg-warm">
|
||||||
<CardTitle>Reflection Prompt</CardTitle>
|
<h2 className="text-xl font-bold">Reflection Prompt</h2>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="prose dark:prose-invert">
|
<div className="prose dark:prose-invert">
|
||||||
<p className="text-lg font-medium">{content?.prompt}</p>
|
<p className="text-lg font-medium">{content?.prompt}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,7 +27,7 @@ export default function ReflectionPrompt({ content, onComplete }) {
|
|||||||
{!submitted ? (
|
{!submitted ? (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full min-h-[150px] p-4 border rounded-md resize-y focus:ring-2 focus:ring-blue-500 focus:outline-none dark:bg-slate-800 dark:border-slate-700"
|
className="w-full min-h-[150px] p-4 border rounded-[var(--r-sm)] resize-y focus:ring-2 focus:ring-teal focus:outline-none bg-bg"
|
||||||
placeholder="Write your reflection here..."
|
placeholder="Write your reflection here..."
|
||||||
value={response}
|
value={response}
|
||||||
onChange={(e) => setResponse(e.target.value)}
|
onChange={(e) => setResponse(e.target.value)}
|
||||||
@@ -36,20 +36,20 @@ export default function ReflectionPrompt({ content, onComplete }) {
|
|||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="p-4 bg-slate-50 dark:bg-slate-800/50 rounded-md">
|
<div className="p-4 bg-bg-warm rounded-[var(--r-sm)]">
|
||||||
<h4 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-2">Your Reflection</h4>
|
<h4 className="text-sm font-semibold text-fg-muted uppercase tracking-wider mb-2">Your Reflection</h4>
|
||||||
<p className="whitespace-pre-wrap">{response}</p>
|
<p className="whitespace-pre-wrap">{response}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-md">
|
<div className="p-4 bg-teal/5 border border-teal/20 rounded-[var(--r-sm)]">
|
||||||
<h4 className="text-sm font-semibold text-blue-800 dark:text-blue-300 uppercase tracking-wider mb-2">Model Answer</h4>
|
<h4 className="text-sm font-semibold text-teal uppercase tracking-wider mb-2">Model Answer</h4>
|
||||||
<div className="prose prose-blue dark:prose-invert">
|
<div className="prose dark:prose-invert">
|
||||||
<p>{content?.model_answer}</p>
|
<p>{content?.model_answer}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '../ui/card';
|
import Card from '../ui/Card';
|
||||||
import { Button } from '../ui/button';
|
import Button from '../ui/Button';
|
||||||
|
|
||||||
export default function ScenarioQuiz({ content, onComplete }) {
|
export default function ScenarioQuiz({ content, onComplete }) {
|
||||||
const [selectedOptionId, setSelectedOptionId] = useState(null);
|
const [selectedOptionId, setSelectedOptionId] = useState(null);
|
||||||
const [showExplanations, setShowExplanations] = useState(false);
|
const [showExplanations, setShowExplanations] = useState(false);
|
||||||
|
|
||||||
const handleSelect = (idx) => {
|
const handleSelect = (idx) => {
|
||||||
if (showExplanations) return; // Prevent changing after selection
|
if (showExplanations) return;
|
||||||
setSelectedOptionId(idx);
|
setSelectedOptionId(idx);
|
||||||
setShowExplanations(true);
|
setShowExplanations(true);
|
||||||
onComplete(); // Trigger completion immediately after an option is selected
|
onComplete();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<div className="mb-6 pb-4 border-b border-bg-warm">
|
||||||
<CardTitle>Scenario Quiz</CardTitle>
|
<h2 className="text-xl font-bold">Scenario Quiz</h2>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="prose dark:prose-invert">
|
<div className="prose dark:prose-invert">
|
||||||
<p className="text-lg font-medium">{content?.scenario}</p>
|
<p className="text-lg font-medium">{content?.scenario}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,18 +27,19 @@ export default function ScenarioQuiz({ content, onComplete }) {
|
|||||||
{content?.options?.map((option, idx) => {
|
{content?.options?.map((option, idx) => {
|
||||||
const isSelected = selectedOptionId === idx;
|
const isSelected = selectedOptionId === idx;
|
||||||
|
|
||||||
// Basic styling for the option buttons
|
|
||||||
let buttonStyle = "w-full justify-start text-left h-auto p-4 ";
|
let buttonStyle = "w-full justify-start text-left h-auto p-4 ";
|
||||||
if (showExplanations) {
|
if (showExplanations) {
|
||||||
if (option.isCorrect) buttonStyle += "bg-green-100 dark:bg-green-900 border-green-500 ";
|
if (option.isCorrect) buttonStyle += "bg-green-100 dark:bg-green-900 border-green-500 ";
|
||||||
else if (isSelected && !option.isCorrect) buttonStyle += "bg-red-100 dark:bg-red-900 border-red-500 ";
|
else if (isSelected && !option.isCorrect) buttonStyle += "bg-red-100 dark:bg-red-900 border-red-500 ";
|
||||||
else buttonStyle += "opacity-70 ";
|
else buttonStyle += "opacity-70 ";
|
||||||
|
} else {
|
||||||
|
if (isSelected) buttonStyle += "bg-teal/10 ";
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="space-y-2">
|
<div key={idx} className="space-y-2">
|
||||||
<Button
|
<Button
|
||||||
variant={isSelected ? "default" : "outline"}
|
variant={isSelected ? "primary" : "outline"}
|
||||||
className={buttonStyle}
|
className={buttonStyle}
|
||||||
onClick={() => handleSelect(idx)}
|
onClick={() => handleSelect(idx)}
|
||||||
disabled={showExplanations}
|
disabled={showExplanations}
|
||||||
@@ -47,7 +48,7 @@ export default function ScenarioQuiz({ content, onComplete }) {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{showExplanations && (
|
{showExplanations && (
|
||||||
<div className={`p-3 rounded-md text-sm ${option.isCorrect ? 'bg-green-50 dark:bg-green-950 text-green-800 dark:text-green-200' : 'bg-slate-50 dark:bg-slate-800 text-slate-700 dark:text-slate-300'}`}>
|
<div className={`p-3 rounded-[var(--r-md)] text-sm ${option.isCorrect ? 'bg-green-50 text-green-800' : 'bg-slate-50 text-slate-700'}`}>
|
||||||
<span className="font-semibold">{option.isCorrect ? '✓ ' : '✗ '}</span>
|
<span className="font-semibold">{option.isCorrect ? '✓ ' : '✗ '}</span>
|
||||||
{option.explanation}
|
{option.explanation}
|
||||||
</div>
|
</div>
|
||||||
@@ -56,7 +57,7 @@ export default function ScenarioQuiz({ content, onComplete }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
import pb from '../lib/pb';
|
import { pb } from '../lib/pb';
|
||||||
import { useAuthStore } from '../store/authStore';
|
|
||||||
|
|
||||||
export function useMicroLearningCompletions() {
|
export function useMicroLearningCompletions() {
|
||||||
const { user } = useAuthStore?.() || { user: pb.authStore.model };
|
|
||||||
|
|
||||||
// Note: user in PB context for team_members usually matches user_id or team_member_id.
|
|
||||||
// We need the team_member record. If the frontend is currently storing it in authStore, we can get it.
|
|
||||||
|
|
||||||
const recordCompletion = async ({ microLearningId, topicId, type, sessionWeek }) => {
|
const recordCompletion = async ({ microLearningId, topicId, type, sessionWeek }) => {
|
||||||
try {
|
try {
|
||||||
// Find the team_member record for the current user
|
const user = pb.authStore.model;
|
||||||
let teamMemberId = user?.id;
|
if (!user) throw new Error("No authenticated user");
|
||||||
// If user is just the auth user, we might need to query the team_members collection
|
|
||||||
// but in many setups, the auth user is the team_member or there's a 1-to-1.
|
|
||||||
// Let's ensure we fetch team_member_id properly if needed.
|
|
||||||
const teamMemberRec = await pb.collection('team_members').getFirstListItem(`user_id = "${user.id}"`);
|
const teamMemberRec = await pb.collection('team_members').getFirstListItem(`user_id = "${user.id}"`);
|
||||||
|
|
||||||
if (!teamMemberRec) {
|
if (!teamMemberRec) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import pb from '../lib/pb';
|
import { pb } from '../lib/pb';
|
||||||
|
|
||||||
export function useMicroLearnings() {
|
export function useMicroLearnings() {
|
||||||
const getMicroLearningsByTopic = async (topicId) => {
|
const getMicroLearningsByTopic = async (topicId) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user