feat: implement core micro-learning components and hooks for diverse educational formats

This commit is contained in:
RaymondVerhoef
2026-05-24 23:45:26 +02:00
parent 55bcb689e7
commit cd151aace4
7 changed files with 71 additions and 83 deletions

View File

@@ -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>
); );
} }

View File

@@ -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">

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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) {

View File

@@ -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) => {