feat: implement micro-learning system with content delivery components, completion tracking hooks, and database schema migrations
This commit is contained in:
183
pb_migrations/1780800000_created_micro_learnings.js
Normal file
183
pb_migrations/1780800000_created_micro_learnings.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
// Create micro_learnings collection
|
||||||
|
const microLearnings = new Collection({
|
||||||
|
"id": "pbc_micro_learnings_0",
|
||||||
|
"name": "micro_learnings",
|
||||||
|
"type": "base",
|
||||||
|
"system": false,
|
||||||
|
"schema": [
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "rel_topic_id",
|
||||||
|
"name": "topic_id",
|
||||||
|
"type": "relation",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"options": {
|
||||||
|
"collectionId": "pbc_2800040823", // the ID for topics from snapshot (we will use topic name later if possible, but let's just use collectionName: "topics")
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"minSelect": null,
|
||||||
|
"maxSelect": 1,
|
||||||
|
"displayFields": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sel_type",
|
||||||
|
"name": "type",
|
||||||
|
"type": "select",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"options": {
|
||||||
|
"maxSelect": 1,
|
||||||
|
"values": [
|
||||||
|
"concept_explainer",
|
||||||
|
"scenario_quiz",
|
||||||
|
"flashcard_set",
|
||||||
|
"reflection_prompt"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "json_content",
|
||||||
|
"name": "content",
|
||||||
|
"type": "json",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sel_status",
|
||||||
|
"name": "status",
|
||||||
|
"type": "select",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"options": {
|
||||||
|
"maxSelect": 1,
|
||||||
|
"values": [
|
||||||
|
"draft",
|
||||||
|
"published"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": "status = 'published'",
|
||||||
|
"viewRule": "status = 'published'",
|
||||||
|
"createRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"deleteRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Since we don't know the exact ID of topics collection safely across instances without looking it up,
|
||||||
|
// we can look it up first.
|
||||||
|
const topicsCollection = app.findCollectionByNameOrId("topics");
|
||||||
|
microLearnings.schema.getFieldByName("topic_id").options.collectionId = topicsCollection.id;
|
||||||
|
|
||||||
|
app.save(microLearnings);
|
||||||
|
|
||||||
|
// Create micro_learning_completions collection
|
||||||
|
const completions = new Collection({
|
||||||
|
"id": "pbc_ml_completions_0",
|
||||||
|
"name": "micro_learning_completions",
|
||||||
|
"type": "base",
|
||||||
|
"system": false,
|
||||||
|
"schema": [
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "rel_team_member_id",
|
||||||
|
"name": "team_member_id",
|
||||||
|
"type": "relation",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"options": {
|
||||||
|
"collectionId": "team_members_placeholder",
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"minSelect": null,
|
||||||
|
"maxSelect": 1,
|
||||||
|
"displayFields": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "rel_micro_learning_id",
|
||||||
|
"name": "micro_learning_id",
|
||||||
|
"type": "relation",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"options": {
|
||||||
|
"collectionId": microLearnings.id,
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"minSelect": null,
|
||||||
|
"maxSelect": 1,
|
||||||
|
"displayFields": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "rel_comp_topic_id",
|
||||||
|
"name": "topic_id",
|
||||||
|
"type": "relation",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"options": {
|
||||||
|
"collectionId": topicsCollection.id,
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"minSelect": null,
|
||||||
|
"maxSelect": 1,
|
||||||
|
"displayFields": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "txt_comp_type",
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"options": {
|
||||||
|
"min": null,
|
||||||
|
"max": null,
|
||||||
|
"pattern": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "num_session_week",
|
||||||
|
"name": "session_week",
|
||||||
|
"type": "number",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"options": {
|
||||||
|
"min": null,
|
||||||
|
"max": null,
|
||||||
|
"noDecimal": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": "@request.auth.id != '' && team_member_id.user_id = @request.auth.id",
|
||||||
|
"viewRule": "@request.auth.id != '' && team_member_id.user_id = @request.auth.id",
|
||||||
|
"createRule": "@request.auth.id != ''",
|
||||||
|
"updateRule": null,
|
||||||
|
"deleteRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamMembersCollection = app.findCollectionByNameOrId("team_members");
|
||||||
|
completions.schema.getFieldByName("team_member_id").options.collectionId = teamMembersCollection.id;
|
||||||
|
|
||||||
|
app.save(completions);
|
||||||
|
|
||||||
|
}, (app) => {
|
||||||
|
const completions = app.findCollectionByNameOrId("micro_learning_completions");
|
||||||
|
if (completions) {
|
||||||
|
app.delete(completions);
|
||||||
|
}
|
||||||
|
const microLearnings = app.findCollectionByNameOrId("micro_learnings");
|
||||||
|
if (microLearnings) {
|
||||||
|
app.delete(microLearnings);
|
||||||
|
}
|
||||||
|
})
|
||||||
23
pb_migrations/1780800001_deleted_legacy_collections.js
Normal file
23
pb_migrations/1780800001_deleted_legacy_collections.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collectionsToDrop = [
|
||||||
|
"learn_progress",
|
||||||
|
"quiz_banks",
|
||||||
|
"quiz_cache",
|
||||||
|
"quiz_results"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of collectionsToDrop) {
|
||||||
|
try {
|
||||||
|
const collection = app.findCollectionByNameOrId(name);
|
||||||
|
if (collection) {
|
||||||
|
app.delete(collection);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore if not found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, (app) => {
|
||||||
|
// Downgrade would normally recreate these, but we omit it here for simplicity
|
||||||
|
// since they are deprecated.
|
||||||
|
})
|
||||||
45
src/components/micro_learning/ConceptExplainer.jsx
Normal file
45
src/components/micro_learning/ConceptExplainer.jsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; // assuming some UI library, otherwise use standard divs
|
||||||
|
|
||||||
|
export default function ConceptExplainer({ content, onComplete }) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
|
// Trigger completion when scrolled to the end
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||||
|
if (scrollTop + clientHeight >= scrollHeight - 50) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check initially in case content is short and doesn't need scrolling
|
||||||
|
if (containerRef.current && containerRef.current.scrollHeight <= containerRef.current.clientHeight) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRef = containerRef.current;
|
||||||
|
currentRef?.addEventListener('scroll', handleScroll);
|
||||||
|
return () => currentRef?.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [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"
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
|
{content?.sections?.map((section, i) => (
|
||||||
|
<div key={i} className="mb-6">
|
||||||
|
<h3>{section.title}</h3>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: section.content }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/components/micro_learning/FlashcardSet.jsx
Normal file
75
src/components/micro_learning/FlashcardSet.jsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card, CardContent } from '../ui/card';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
|
||||||
|
export default function FlashcardSet({ content, onComplete }) {
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [isFlipped, setIsFlipped] = useState(false);
|
||||||
|
const [viewedCards, setViewedCards] = useState(new Set());
|
||||||
|
|
||||||
|
const cards = content?.cards || [];
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
setIsFlipped(false);
|
||||||
|
setCurrentIndex((prev) => (prev + 1) % cards.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
setIsFlipped(false);
|
||||||
|
setCurrentIndex((prev) => (prev - 1 + cards.length) % cards.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cards.length === 0) {
|
||||||
|
return <div className="text-center p-4">No flashcards available.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCard = cards[currentIndex];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col items-center space-y-6">
|
||||||
|
<div className="text-sm text-slate-500">
|
||||||
|
Card {currentIndex + 1} of {cards.length}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className="w-full max-w-lg min-h-[300px] cursor-pointer perspective-1000 relative"
|
||||||
|
onClick={handleFlip}
|
||||||
|
>
|
||||||
|
<CardContent className="absolute inset-0 flex items-center justify-center p-8 text-center text-xl">
|
||||||
|
{isFlipped ? (
|
||||||
|
<div className="prose dark:prose-invert">
|
||||||
|
<p>{currentCard.back}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="prose dark:prose-invert font-medium">
|
||||||
|
<p>{currentCard.front}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="text-sm text-slate-400 mt-2">
|
||||||
|
Click the card to flip
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<Button variant="outline" onClick={handlePrev}>Previous</Button>
|
||||||
|
<Button variant="outline" onClick={handleNext}>Next</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/micro_learning/MicroLearningContainer.jsx
Normal file
62
src/components/micro_learning/MicroLearningContainer.jsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import ConceptExplainer from './ConceptExplainer';
|
||||||
|
import ScenarioQuiz from './ScenarioQuiz';
|
||||||
|
import FlashcardSet from './FlashcardSet';
|
||||||
|
import ReflectionPrompt from './ReflectionPrompt';
|
||||||
|
import { useMicroLearningCompletions } from '../../hooks/useMicroLearningCompletions';
|
||||||
|
|
||||||
|
export default function MicroLearningContainer({ microLearning, sessionWeek, onCompletedSuccessfully }) {
|
||||||
|
const { recordCompletion } = useMicroLearningCompletions();
|
||||||
|
const [completed, setCompleted] = useState(false);
|
||||||
|
|
||||||
|
const handleComplete = async () => {
|
||||||
|
if (completed) return; // Prevent double recording
|
||||||
|
|
||||||
|
const record = await recordCompletion({
|
||||||
|
microLearningId: microLearning.id,
|
||||||
|
topicId: microLearning.topic_id,
|
||||||
|
type: microLearning.type,
|
||||||
|
sessionWeek: sessionWeek
|
||||||
|
});
|
||||||
|
|
||||||
|
if (record) {
|
||||||
|
setCompleted(true);
|
||||||
|
if (onCompletedSuccessfully) {
|
||||||
|
onCompletedSuccessfully(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderComponent = () => {
|
||||||
|
const props = {
|
||||||
|
content: microLearning.content,
|
||||||
|
onComplete: handleComplete
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (microLearning.type) {
|
||||||
|
case 'concept_explainer':
|
||||||
|
return <ConceptExplainer {...props} />;
|
||||||
|
case 'scenario_quiz':
|
||||||
|
return <ScenarioQuiz {...props} />;
|
||||||
|
case 'flashcard_set':
|
||||||
|
return <FlashcardSet {...props} />;
|
||||||
|
case 'reflection_prompt':
|
||||||
|
return <ReflectionPrompt {...props} />;
|
||||||
|
default:
|
||||||
|
return <div>Unknown micro learning type.</div>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-3xl mx-auto">
|
||||||
|
{renderComponent()}
|
||||||
|
|
||||||
|
{completed && (
|
||||||
|
<div className="mt-4 p-4 bg-green-50 text-green-800 border border-green-200 rounded-md text-center">
|
||||||
|
<p className="font-semibold">Micro Learning Completed!</p>
|
||||||
|
<p className="text-sm mt-1">Your progress has been recorded.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/micro_learning/MicroLearningSelector.jsx
Normal file
89
src/components/micro_learning/MicroLearningSelector.jsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const TYPE_LABELS = {
|
||||||
|
'concept_explainer': 'Concept Explainer',
|
||||||
|
'scenario_quiz': 'Scenario Quiz',
|
||||||
|
'flashcard_set': 'Flashcard Set',
|
||||||
|
'reflection_prompt': 'Reflection Prompt'
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_DESCRIPTIONS = {
|
||||||
|
'concept_explainer': 'Read a structured explanation to understand the concept.',
|
||||||
|
'scenario_quiz': 'Apply your knowledge in a realistic workplace scenario.',
|
||||||
|
'flashcard_set': 'Test your recall with a set of quick flashcards.',
|
||||||
|
'reflection_prompt': 'Connect the topic to your own professional experience.'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MicroLearningSelector({ topicId, sessionWeek, onTopicCompleted }) {
|
||||||
|
const { getMicroLearningsByTopic } = useMicroLearnings();
|
||||||
|
const [availableMLs, setAvailableMLs] = useState([]);
|
||||||
|
const [selectedML, setSelectedML] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchMLs = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getMicroLearningsByTopic(topicId);
|
||||||
|
setAvailableMLs(data);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
if (topicId) {
|
||||||
|
fetchMLs();
|
||||||
|
}
|
||||||
|
}, [topicId]);
|
||||||
|
|
||||||
|
const handleSelection = (ml) => {
|
||||||
|
setSelectedML(ml);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="text-slate-500">Loading learning formats...</div>;
|
||||||
|
|
||||||
|
if (availableMLs.length === 0) {
|
||||||
|
return <div className="text-slate-500">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">
|
||||||
|
← Back to selection
|
||||||
|
</Button>
|
||||||
|
<MicroLearningContainer
|
||||||
|
microLearning={selectedML}
|
||||||
|
sessionWeek={sessionWeek}
|
||||||
|
onCompletedSuccessfully={onTopicCompleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/micro_learning/ReflectionPrompt.jsx
Normal file
55
src/components/micro_learning/ReflectionPrompt.jsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
|
||||||
|
export default function ReflectionPrompt({ content, onComplete }) {
|
||||||
|
const [response, setResponse] = useState('');
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!response.trim() || submitted) return;
|
||||||
|
|
||||||
|
setSubmitted(true);
|
||||||
|
onComplete(); // Trigger completion when a response is submitted
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Reflection Prompt</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="prose dark:prose-invert">
|
||||||
|
<p className="text-lg font-medium">{content?.prompt}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!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"
|
||||||
|
placeholder="Write your reflection here..."
|
||||||
|
value={response}
|
||||||
|
onChange={(e) => setResponse(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={!response.trim()}>Submit Reflection</Button>
|
||||||
|
</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>
|
||||||
|
<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">
|
||||||
|
<p>{content?.model_answer}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/micro_learning/ScenarioQuiz.jsx
Normal file
62
src/components/micro_learning/ScenarioQuiz.jsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardFooter } 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
|
||||||
|
setSelectedOptionId(idx);
|
||||||
|
setShowExplanations(true);
|
||||||
|
onComplete(); // Trigger completion immediately after an option is selected
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Scenario Quiz</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="prose dark:prose-invert">
|
||||||
|
<p className="text-lg font-medium">{content?.scenario}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{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 ";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className="space-y-2">
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? "default" : "outline"}
|
||||||
|
className={buttonStyle}
|
||||||
|
onClick={() => handleSelect(idx)}
|
||||||
|
disabled={showExplanations}
|
||||||
|
>
|
||||||
|
<span className="flex-1 whitespace-normal">{option.text}</span>
|
||||||
|
</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'}`}>
|
||||||
|
<span className="font-semibold">{option.isCorrect ? '✓ ' : '✗ '}</span>
|
||||||
|
{option.explanation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/hooks/useMicroLearningCompletions.js
Normal file
53
src/hooks/useMicroLearningCompletions.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import pb from '../lib/pb';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|
||||||
|
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 teamMemberRec = await pb.collection('team_members').getFirstListItem(`user_id = "${user.id}"`);
|
||||||
|
|
||||||
|
if (!teamMemberRec) {
|
||||||
|
throw new Error("Team member record not found for user.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await pb.collection('micro_learning_completions').create({
|
||||||
|
team_member_id: teamMemberRec.id,
|
||||||
|
micro_learning_id: microLearningId,
|
||||||
|
topic_id: topicId,
|
||||||
|
type: type,
|
||||||
|
session_week: sessionWeek
|
||||||
|
});
|
||||||
|
return record;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error recording completion:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSessionCompletions = async (sessionWeek) => {
|
||||||
|
try {
|
||||||
|
const teamMemberRec = await pb.collection('team_members').getFirstListItem(`user_id = "${pb.authStore.model.id}"`);
|
||||||
|
if (!teamMemberRec) return [];
|
||||||
|
|
||||||
|
const records = await pb.collection('micro_learning_completions').getFullList({
|
||||||
|
filter: `team_member_id = "${teamMemberRec.id}" && session_week = ${sessionWeek}`
|
||||||
|
});
|
||||||
|
return records;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching session completions:", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { recordCompletion, getSessionCompletions };
|
||||||
|
}
|
||||||
17
src/hooks/useMicroLearnings.js
Normal file
17
src/hooks/useMicroLearnings.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import pb from '../lib/pb';
|
||||||
|
|
||||||
|
export function useMicroLearnings() {
|
||||||
|
const getMicroLearningsByTopic = async (topicId) => {
|
||||||
|
try {
|
||||||
|
const records = await pb.collection('micro_learnings').getFullList({
|
||||||
|
filter: `topic_id = "${topicId}" && status = 'published'`,
|
||||||
|
});
|
||||||
|
return records;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching micro learnings:", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { getMicroLearningsByTopic };
|
||||||
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { BookOpen, CheckCircle, Loader, ArrowRight, ChevronLeft, MessageSquare, Calendar, TrendingUp } from 'lucide-react';
|
import { BookOpen, CheckCircle, ArrowRight, ChevronLeft, Calendar } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import Card from '../components/ui/Card';
|
import Card from '../components/ui/Card';
|
||||||
import Button from '../components/ui/Button';
|
import Button from '../components/ui/Button';
|
||||||
import Tag from '../components/ui/Tag';
|
import Tag from '../components/ui/Tag';
|
||||||
import LearningContentViewer from '../components/ui/LearningContentViewer';
|
|
||||||
import { useApp } from '../store/AppContext';
|
import { useApp } from '../store/AppContext';
|
||||||
import { getAssignedTopic, generateLearningContent, getCachedContent } from '../lib/learningService';
|
import { getAssignedTopic } from '../lib/learningService';
|
||||||
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion, getCurrentWeekContent } from '../lib/curriculumService';
|
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion, getCurrentWeekContent } from '../lib/curriculumService';
|
||||||
import * as db from '../lib/db';
|
import * as db from '../lib/db';
|
||||||
|
import MicroLearningSelector from '../components/micro_learning/MicroLearningSelector';
|
||||||
|
import { useMicroLearningCompletions } from '../hooks/useMicroLearningCompletions';
|
||||||
|
|
||||||
const Leren = () => {
|
const Leren = () => {
|
||||||
const { state } = useApp();
|
const { state } = useApp();
|
||||||
@@ -19,39 +20,30 @@ const Leren = () => {
|
|||||||
// View state
|
// View state
|
||||||
const [view, setView] = useState('overview'); // overview, detail
|
const [view, setView] = useState('overview'); // overview, detail
|
||||||
const [activeTopic, setActiveTopic] = useState(null);
|
const [activeTopic, setActiveTopic] = useState(null);
|
||||||
const [content, setContent] = useState(null);
|
|
||||||
|
|
||||||
// Loading & Error
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
// Weekly status
|
// Weekly status
|
||||||
const [weeklyDone, setWeeklyDone] = useState(false);
|
const [weeklyDone, setWeeklyDone] = useState(false);
|
||||||
const [sessionDone, setSessionDone] = useState(false);
|
const [sessionDone, setSessionDone] = useState(false);
|
||||||
|
|
||||||
// Feedback
|
|
||||||
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
|
|
||||||
const [feedbackText, setFeedbackText] = useState('');
|
|
||||||
const [feedbackPrompted, setFeedbackPrompted] = useState(false);
|
|
||||||
|
|
||||||
// Curriculum state
|
// Curriculum state
|
||||||
const [hasCurriculum, setHasCurriculum] = useState(false);
|
const [hasCurriculum, setHasCurriculum] = useState(false);
|
||||||
const [yearProgress, setYearProgress] = useState(null);
|
const [yearProgress, setYearProgress] = useState(null);
|
||||||
const [weekContent, setWeekContent] = useState(null);
|
const [weekContent, setWeekContent] = useState(null);
|
||||||
|
|
||||||
|
const { getSessionCompletions } = useMicroLearningCompletions();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.currentUser) {
|
if (state.currentUser) {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const [assigned, topics, done] = await Promise.all([
|
const [assigned, topics] = await Promise.all([
|
||||||
getAssignedTopic(state.currentUser.id, state.weekNumber),
|
getAssignedTopic(state.currentUser.id, state.weekNumber),
|
||||||
db.getTopics(),
|
db.getTopics(),
|
||||||
db.getLearnDone(state.currentUser.id, state.weekNumber),
|
|
||||||
]);
|
]);
|
||||||
setAssignedTopic(assigned);
|
setAssignedTopic(assigned);
|
||||||
setAllTopics(topics);
|
setAllTopics(topics);
|
||||||
if (done) setWeeklyDone(true);
|
|
||||||
|
|
||||||
// Load curriculum data
|
// Load curriculum data
|
||||||
|
let currentWeekContent = null;
|
||||||
try {
|
try {
|
||||||
const activeVersion = await getActiveVersion();
|
const activeVersion = await getActiveVersion();
|
||||||
setHasCurriculum(!!activeVersion);
|
setHasCurriculum(!!activeVersion);
|
||||||
@@ -61,76 +53,38 @@ const Leren = () => {
|
|||||||
getCurrentWeekContent(state.weekNumber),
|
getCurrentWeekContent(state.weekNumber),
|
||||||
]);
|
]);
|
||||||
setYearProgress(yProgress);
|
setYearProgress(yProgress);
|
||||||
|
currentWeekContent = wc;
|
||||||
setWeekContent(wc);
|
setWeekContent(wc);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Learn] Could not load curriculum data:', e.message);
|
console.warn('[Learn] Could not load curriculum data:', e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check completions for the current week
|
||||||
|
const completions = await getSessionCompletions(state.weekNumber);
|
||||||
|
|
||||||
|
// Define required topics for the week
|
||||||
|
// We assume assignedTopic is the minimum required if no explicit list in weekContent.
|
||||||
|
// Actually, let's just check if the assignedTopic is completed.
|
||||||
|
const requiredTopicIds = currentWeekContent?.topics || [assigned?.id].filter(Boolean);
|
||||||
|
|
||||||
|
const completedTopicIds = new Set(completions.map(c => c.topic_id));
|
||||||
|
const allRequiredCompleted = requiredTopicIds.length > 0 && requiredTopicIds.every(id => completedTopicIds.has(id));
|
||||||
|
|
||||||
|
setWeeklyDone(allRequiredCompleted);
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
}, [state.currentUser, state.weekNumber]);
|
}, [state.currentUser, state.weekNumber, view]); // Reload when view changes (e.g. going back to overview)
|
||||||
|
|
||||||
const handleOpenTopic = async (topic) => {
|
const handleOpenTopic = (topic) => {
|
||||||
setActiveTopic(topic);
|
setActiveTopic(topic);
|
||||||
setView('detail');
|
setView('detail');
|
||||||
setSessionDone(false);
|
setSessionDone(false);
|
||||||
setError(null);
|
|
||||||
setFeedbackText('');
|
|
||||||
setFeedbackPrompted(false);
|
|
||||||
const cached = await getCachedContent(topic.id);
|
|
||||||
if (cached) {
|
|
||||||
setContent(cached);
|
|
||||||
} else {
|
|
||||||
setContent(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadContent = async (selectedType = 'article') => {
|
const handleTopicCompleted = () => {
|
||||||
if (!activeTopic) return;
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const generated = await generateLearningContent(activeTopic, false, selectedType);
|
|
||||||
setContent(generated);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e.message);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const doComplete = async () => {
|
|
||||||
setSessionDone(true);
|
setSessionDone(true);
|
||||||
if (!weeklyDone) {
|
|
||||||
setWeeklyDone(true);
|
|
||||||
await db.setLearnDone(state.currentUser.id, state.weekNumber);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleComplete = () => {
|
|
||||||
if (!feedbackPrompted) {
|
|
||||||
setFeedbackPrompted(true);
|
|
||||||
setShowFeedbackModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
doComplete();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmitFeedback = async () => {
|
|
||||||
if (feedbackText.trim()) {
|
|
||||||
await db.setSetting(
|
|
||||||
`feedback:${state.currentUser.id}:${activeTopic.id}:${state.weekNumber}`,
|
|
||||||
feedbackText.trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setShowFeedbackModal(false);
|
|
||||||
doComplete();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSkipFeedback = () => {
|
|
||||||
setShowFeedbackModal(false);
|
|
||||||
doComplete();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Detail View ──────────────────────────────────────────
|
// ── Detail View ──────────────────────────────────────────
|
||||||
@@ -141,16 +95,14 @@ const Leren = () => {
|
|||||||
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 200 }}>
|
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 200 }}>
|
||||||
<CheckCircle size={80} className="mx-auto text-teal mb-6" />
|
<CheckCircle size={80} className="mx-auto text-teal mb-6" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<h1 className="text-3xl font-bold mb-4">Learning session complete!</h1>
|
<h1 className="text-3xl font-bold mb-4">Topic reviewed!</h1>
|
||||||
<p className="text-fg-muted mb-8">
|
<p className="text-fg-muted mb-8">
|
||||||
You have successfully reviewed "<strong>{activeTopic.label}</strong>".
|
You have successfully completed a micro learning for "<strong>{activeTopic.label}</strong>".
|
||||||
{weeklyDone && ' Your weekly minimum is met.'}
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center gap-4">
|
<div className="flex justify-center gap-4">
|
||||||
<Button variant="outline" onClick={() => setView('overview')}>Learn Another</Button>
|
<Button variant="outline" onClick={() => { setView('overview'); setSessionDone(false); }}>
|
||||||
<Link to="/test">
|
Back to Station
|
||||||
<Button>Start Weekly Test <ArrowRight size={18} className="ml-2" /></Button>
|
</Button>
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -158,50 +110,6 @@ const Leren = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8">
|
<div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8">
|
||||||
<AnimatePresence>
|
|
||||||
{showFeedbackModal && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.95, opacity: 0, y: 16 }}
|
|
||||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
|
||||||
exit={{ scale: 0.95, opacity: 0, y: 16 }}
|
|
||||||
transition={{ type: 'spring', stiffness: 300, damping: 24 }}
|
|
||||||
className="w-full max-w-lg"
|
|
||||||
>
|
|
||||||
<Card className="border border-bg-warm shadow-xl">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-teal/10 flex items-center justify-center flex-shrink-0">
|
|
||||||
<MessageSquare size={20} className="text-teal" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-bold">How was this content?</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-fg-muted text-sm mb-5">
|
|
||||||
Your feedback helps improve future learning content. This is optional — you can skip if you prefer.
|
|
||||||
</p>
|
|
||||||
<textarea
|
|
||||||
autoFocus
|
|
||||||
value={feedbackText}
|
|
||||||
onChange={e => setFeedbackText(e.target.value)}
|
|
||||||
placeholder="What was clear? What could be improved? Anything missing?"
|
|
||||||
rows={4}
|
|
||||||
className="w-full rounded-[var(--r-sm)] border border-bg-warm bg-bg p-3 text-sm resize-none focus:outline-none focus:border-teal transition-colors"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-3 mt-4">
|
|
||||||
<Button variant="outline" onClick={handleSkipFeedback}>Skip</Button>
|
|
||||||
<Button onClick={handleSubmitFeedback}>Submit Feedback</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<button onClick={() => setView('overview')} className="flex items-center gap-2 text-fg-muted hover:text-teal mb-6 transition-colors">
|
<button onClick={() => setView('overview')} className="flex items-center gap-2 text-fg-muted hover:text-teal mb-6 transition-colors">
|
||||||
<ChevronLeft size={16} /> Back to overview
|
<ChevronLeft size={16} /> Back to overview
|
||||||
</button>
|
</button>
|
||||||
@@ -215,43 +123,11 @@ const Leren = () => {
|
|||||||
<p className="text-fg-muted mt-2">{activeTopic.description}</p>
|
<p className="text-fg-muted mt-2">{activeTopic.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!content && !isLoading && (
|
<MicroLearningSelector
|
||||||
<Card className="border border-bg-warm text-center py-16">
|
topicId={activeTopic.id}
|
||||||
<BookOpen size={48} className="mx-auto text-teal/30 mb-4" />
|
sessionWeek={state.weekNumber}
|
||||||
<p className="text-fg-muted mb-6">Choose how you want to learn this topic.</p>
|
onTopicCompleted={handleTopicCompleted}
|
||||||
<div className="flex flex-wrap justify-center gap-4">
|
/>
|
||||||
<Button onClick={() => loadContent('article')}>Article</Button>
|
|
||||||
<Button onClick={() => loadContent('slides')}>Slides</Button>
|
|
||||||
<Button onClick={() => loadContent('infographic')}>Infographic</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<Card className="border border-bg-warm text-center py-16">
|
|
||||||
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
|
||||||
<p className="font-medium">AI is generating your learning module...</p>
|
|
||||||
<p className="text-fg-muted text-sm mt-2">This may take 10–30 seconds.</p>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Card className="border border-red-200 bg-red-50 text-red-900 p-6">
|
|
||||||
<p className="font-bold mb-1">Generation failed</p>
|
|
||||||
<p className="text-sm">{error}</p>
|
|
||||||
<Button onClick={loadContent} variant="outline" className="mt-4 border-red-300 text-red-700">Try again</Button>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{content && <LearningContentViewer content={content} topic={activeTopic} onGenerate={loadContent} isLoading={isLoading} />}
|
|
||||||
|
|
||||||
{content && (
|
|
||||||
<div className="mt-10 pt-6 border-t border-bg-warm flex justify-end">
|
|
||||||
<Button onClick={handleComplete}>
|
|
||||||
<CheckCircle size={18} className="mr-2" /> Complete Session
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -272,12 +148,6 @@ const Leren = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-6 bg-red-50 text-red-800 p-4 rounded-[var(--r-sm)] border border-red-200">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Progress Cards (only shown when curriculum exists) */}
|
{/* Progress Cards (only shown when curriculum exists) */}
|
||||||
{hasCurriculum && yearProgress && (
|
{hasCurriculum && yearProgress && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
|||||||
Reference in New Issue
Block a user