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 { 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 }) {
const containerRef = useRef(null);
@@ -25,12 +25,12 @@ export default function ConceptExplainer({ content, onComplete }) {
}, [onComplete]);
return (
<Card className="w-full h-[60vh] flex flex-col">
<CardHeader>
<CardTitle>Concept Explainer</CardTitle>
</CardHeader>
<CardContent
className="flex-1 overflow-y-auto prose dark:prose-invert"
<Card className="w-full h-[60vh] flex flex-col p-0">
<div className="p-6 border-b border-bg-warm">
<h2 className="text-xl font-bold">Concept Explainer</h2>
</div>
<div
className="flex-1 overflow-y-auto p-6 prose dark:prose-invert"
ref={containerRef}
>
{content?.sections?.map((section, i) => (
@@ -39,7 +39,7 @@ export default function ConceptExplainer({ content, onComplete }) {
<div dangerouslySetInnerHTML={{ __html: section.content }} />
</div>
))}
</CardContent>
</div>
</Card>
);
}

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Card, CardContent } from '../ui/card';
import { Button } from '../ui/button';
import Card from '../ui/Card';
import Button from '../ui/Button';
export default function FlashcardSet({ content, onComplete }) {
const [currentIndex, setCurrentIndex] = useState(0);
@@ -12,12 +12,10 @@ export default function FlashcardSet({ content, onComplete }) {
const handleFlip = () => {
setIsFlipped(!isFlipped);
// Once flipped, mark this card as viewed
const newViewed = new Set(viewedCards);
newViewed.add(currentIndex);
setViewedCards(newViewed);
// If all cards are viewed, trigger completion
if (newViewed.size === cards.length && cards.length > 0) {
onComplete();
}
@@ -41,25 +39,25 @@ export default function FlashcardSet({ content, onComplete }) {
return (
<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}
</div>
<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}
>
<CardContent className="absolute inset-0 flex items-center justify-center p-8 text-center text-xl">
<div className="text-center text-xl">
{isFlipped ? (
<div className="prose dark:prose-invert">
<p>{currentCard.back}</p>
</div>
) : (
<div className="prose dark:prose-invert font-medium">
<div className="prose dark:prose-invert font-bold text-teal">
<p>{currentCard.front}</p>
</div>
)}
</CardContent>
</div>
</Card>
<div className="text-sm text-slate-400 mt-2">

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { useMicroLearnings } from '../../hooks/useMicroLearnings';
import MicroLearningContainer from './MicroLearningContainer';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import Button from '../ui/Button';
import Card from '../ui/Card';
const TYPE_LABELS = {
'concept_explainer': 'Concept Explainer',
@@ -40,19 +40,21 @@ export default function MicroLearningSelector({ topicId, sessionWeek, onTopicCom
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) {
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) {
return (
<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
</Button>
</button>
<MicroLearningContainer
microLearning={selectedML}
sessionWeek={sessionWeek}
@@ -62,28 +64,23 @@ export default function MicroLearningSelector({ topicId, sessionWeek, onTopicCom
);
}
// Otherwise, show the selection menu
return (
<Card className="w-full">
<CardHeader>
<CardTitle>Choose a Learning Format</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{availableMLs.map((ml) => (
<Card
key={ml.id}
className="cursor-pointer hover:border-blue-500 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors"
onClick={() => handleSelection(ml)}
>
<CardContent className="p-6">
<h3 className="font-bold text-lg mb-2">{TYPE_LABELS[ml.type] || ml.type}</h3>
<p className="text-sm text-slate-500">{TYPE_DESCRIPTIONS[ml.type]}</p>
</CardContent>
</Card>
))}
</div>
</CardContent>
<div className="mb-6 pb-4 border-b border-bg-warm">
<h2 className="text-xl font-bold">Choose a Learning Format</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{availableMLs.map((ml) => (
<div
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"
onClick={() => handleSelection(ml)}
>
<h3 className="font-bold text-lg mb-2 text-teal">{TYPE_LABELS[ml.type] || ml.type}</h3>
<p className="text-sm text-fg-muted">{TYPE_DESCRIPTIONS[ml.type]}</p>
</div>
))}
</div>
</Card>
);
}

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import Card from '../ui/Card';
import Button from '../ui/Button';
export default function ReflectionPrompt({ content, onComplete }) {
const [response, setResponse] = useState('');
@@ -11,15 +11,15 @@ export default function ReflectionPrompt({ content, onComplete }) {
if (!response.trim() || submitted) return;
setSubmitted(true);
onComplete(); // Trigger completion when a response is submitted
onComplete();
};
return (
<Card className="w-full">
<CardHeader>
<CardTitle>Reflection Prompt</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="mb-6 pb-4 border-b border-bg-warm">
<h2 className="text-xl font-bold">Reflection Prompt</h2>
</div>
<div className="space-y-6">
<div className="prose dark:prose-invert">
<p className="text-lg font-medium">{content?.prompt}</p>
</div>
@@ -27,7 +27,7 @@ export default function ReflectionPrompt({ content, onComplete }) {
{!submitted ? (
<form onSubmit={handleSubmit} className="space-y-4">
<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..."
value={response}
onChange={(e) => setResponse(e.target.value)}
@@ -36,20 +36,20 @@ export default function ReflectionPrompt({ content, onComplete }) {
</form>
) : (
<div className="space-y-6">
<div className="p-4 bg-slate-50 dark:bg-slate-800/50 rounded-md">
<h4 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-2">Your Reflection</h4>
<div className="p-4 bg-bg-warm rounded-[var(--r-sm)]">
<h4 className="text-sm font-semibold text-fg-muted uppercase tracking-wider mb-2">Your Reflection</h4>
<p className="whitespace-pre-wrap">{response}</p>
</div>
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-md">
<h4 className="text-sm font-semibold text-blue-800 dark:text-blue-300 uppercase tracking-wider mb-2">Model Answer</h4>
<div className="prose prose-blue dark:prose-invert">
<div className="p-4 bg-teal/5 border border-teal/20 rounded-[var(--r-sm)]">
<h4 className="text-sm font-semibold text-teal uppercase tracking-wider mb-2">Model Answer</h4>
<div className="prose dark:prose-invert">
<p>{content?.model_answer}</p>
</div>
</div>
</div>
)}
</CardContent>
</div>
</Card>
);
}

View File

@@ -1,24 +1,24 @@
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '../ui/card';
import { Button } from '../ui/button';
import Card from '../ui/Card';
import Button from '../ui/Button';
export default function ScenarioQuiz({ content, onComplete }) {
const [selectedOptionId, setSelectedOptionId] = useState(null);
const [showExplanations, setShowExplanations] = useState(false);
const handleSelect = (idx) => {
if (showExplanations) return; // Prevent changing after selection
if (showExplanations) return;
setSelectedOptionId(idx);
setShowExplanations(true);
onComplete(); // Trigger completion immediately after an option is selected
onComplete();
};
return (
<Card className="w-full">
<CardHeader>
<CardTitle>Scenario Quiz</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="mb-6 pb-4 border-b border-bg-warm">
<h2 className="text-xl font-bold">Scenario Quiz</h2>
</div>
<div className="space-y-6">
<div className="prose dark:prose-invert">
<p className="text-lg font-medium">{content?.scenario}</p>
</div>
@@ -27,18 +27,19 @@ export default function ScenarioQuiz({ content, onComplete }) {
{content?.options?.map((option, idx) => {
const isSelected = selectedOptionId === idx;
// Basic styling for the option buttons
let buttonStyle = "w-full justify-start text-left h-auto p-4 ";
if (showExplanations) {
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 buttonStyle += "opacity-70 ";
} else {
if (isSelected) buttonStyle += "bg-teal/10 ";
}
return (
<div key={idx} className="space-y-2">
<Button
variant={isSelected ? "default" : "outline"}
variant={isSelected ? "primary" : "outline"}
className={buttonStyle}
onClick={() => handleSelect(idx)}
disabled={showExplanations}
@@ -47,7 +48,7 @@ export default function ScenarioQuiz({ content, onComplete }) {
</Button>
{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>
{option.explanation}
</div>
@@ -56,7 +57,7 @@ export default function ScenarioQuiz({ content, onComplete }) {
);
})}
</div>
</CardContent>
</div>
</Card>
);
}

View File

@@ -1,19 +1,11 @@
import pb from '../lib/pb';
import { useAuthStore } from '../store/authStore';
import { pb } from '../lib/pb';
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 }) => {
try {
// Find the team_member record for the current user
let teamMemberId = user?.id;
// 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 user = pb.authStore.model;
if (!user) throw new Error("No authenticated user");
const teamMemberRec = await pb.collection('team_members').getFirstListItem(`user_id = "${user.id}"`);
if (!teamMemberRec) {

View File

@@ -1,4 +1,4 @@
import pb from '../lib/pb';
import { pb } from '../lib/pb';
export function useMicroLearnings() {
const getMicroLearningsByTopic = async (topicId) => {