feat: implement learning module page with AI-generated content and feedback workflow
All checks were successful
On Push to Main / test (push) Successful in 29s
On Push to Main / publish (push) Successful in 58s
On Push to Main / deploy-dev (push) Successful in 1m34s

This commit is contained in:
RaymondVerhoef
2026-05-17 17:53:09 +02:00
parent 5b37c04588
commit 5659ff6726
4 changed files with 6 additions and 78 deletions

View File

@@ -26,7 +26,7 @@ const UploadZone = ({ onUploadComplete }) => {
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
// GitHub sync state // GitHub sync state
const [githubUrl, setGithubUrl] = useState('https://github.com/respellion/employee-handbook/tree/main/docs'); const [githubUrl, setGithubUrl] = useState('https://github.com/respellion/employee-handbook/tree/main/docs/knowledge-base');
const [isFetching, setIsFetching] = useState(false); const [isFetching, setIsFetching] = useState(false);
const [fileList, setFileList] = useState(null); const [fileList, setFileList] = useState(null);
const [syncProgress, setSyncProgress] = useState(null); const [syncProgress, setSyncProgress] = useState(null);

View File

@@ -6,7 +6,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { import {
ChevronLeft, ChevronRight, BookOpen, Presentation, ChevronLeft, ChevronRight, BookOpen, Presentation,
Mic, CheckCircle, Volume2, VolumeX, BarChart2 CheckCircle, BarChart2
} from 'lucide-react'; } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import Card from './Card'; import Card from './Card';
@@ -16,12 +16,11 @@ import Button from './Button';
const MODES = [ const MODES = [
{ key: 'article', icon: BookOpen, label: 'Article' }, { key: 'article', icon: BookOpen, label: 'Article' },
{ key: 'slides', icon: Presentation, label: 'Slides' }, { key: 'slides', icon: Presentation, label: 'Slides' },
{ key: 'podcast', icon: Mic, label: 'Podcast' },
{ key: 'infographic', icon: BarChart2, label: 'Infographic' }, { key: 'infographic', icon: BarChart2, label: 'Infographic' },
]; ];
const LearningContentViewer = ({ content, topic, initialMode = 'article', onGenerate, isLoading }) => { const LearningContentViewer = ({ content, topic, initialMode = 'article', onGenerate, isLoading }) => {
const populatedModes = MODES.filter(m => m.key === 'podcast' ? !!content?.podcastScript : !!content?.[m.key]); const populatedModes = MODES.filter(m => !!content?.[m.key]);
const defaultMode = populatedModes.length > 0 ? populatedModes[0].key : initialMode; const defaultMode = populatedModes.length > 0 ? populatedModes[0].key : initialMode;
const [activeMode, setActiveMode] = useState(defaultMode); const [activeMode, setActiveMode] = useState(defaultMode);
@@ -64,7 +63,7 @@ const LearningContentViewer = ({ content, topic, initialMode = 'article', onGene
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
{(() => { {(() => {
const isPopulated = activeMode === 'podcast' ? !!content?.podcastScript : !!content?.[activeMode]; const isPopulated = !!content?.[activeMode];
if (!isPopulated) { if (!isPopulated) {
return ( return (
<Card className="border border-bg-warm text-center py-16"> <Card className="border border-bg-warm text-center py-16">
@@ -79,7 +78,6 @@ const LearningContentViewer = ({ content, topic, initialMode = 'article', onGene
} }
if (activeMode === 'article') return <ArticleView content={content.article} />; if (activeMode === 'article') return <ArticleView content={content.article} />;
if (activeMode === 'slides') return <SlidesView slides={content.slides} />; if (activeMode === 'slides') return <SlidesView slides={content.slides} />;
if (activeMode === 'podcast') return <PodcastView script={content.podcastScript} topicLabel={topic.label} />;
if (activeMode === 'infographic') return <InfographicView data={content.infographic} topicLabel={topic.label} />; if (activeMode === 'infographic') return <InfographicView data={content.infographic} topicLabel={topic.label} />;
return null; return null;
})()} })()}
@@ -180,66 +178,6 @@ const SlidesView = ({ slides }) => {
); );
}; };
/* ── Podcast Renderer ─────────────────────────────────────── */
const PodcastView = ({ script, topicLabel }) => {
const [isPlaying, setIsPlaying] = useState(false);
const utteranceRef = useRef(null);
const togglePlayback = () => {
if (!('speechSynthesis' in window)) {
alert('Text-to-speech is not supported by your browser.');
return;
}
if (isPlaying) {
window.speechSynthesis.cancel();
setIsPlaying(false);
} else {
const utterance = new SpeechSynthesisUtterance(script);
utterance.lang = 'en-US';
utterance.rate = 0.95;
utterance.onend = () => setIsPlaying(false);
utteranceRef.current = utterance;
window.speechSynthesis.speak(utterance);
setIsPlaying(true);
}
};
useEffect(() => () => window.speechSynthesis?.cancel(), []);
return (
<Card className="border border-bg-warm">
<div className="flex items-center gap-4 mb-6 pb-6 border-b border-bg-warm">
<div className="w-16 h-16 rounded-full bg-teal flex items-center justify-center flex-shrink-0">
<Mic size={28} className="text-white" />
</div>
<div>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Podcast Episode</p>
<h3 className="text-xl font-bold">{topicLabel}</h3>
</div>
<button
onClick={togglePlayback}
className={`ml-auto flex items-center gap-2 px-5 py-2.5 rounded-full font-medium transition-all ${
isPlaying ? 'bg-red-50 text-red-600 border border-red-200' : 'bg-teal text-white hover:bg-teal/90'
}`}
>
{isPlaying ? <><VolumeX size={18} /> Stop</> : <><Volume2 size={18} /> Play</>}
</button>
</div>
{isPlaying && (
<div className="flex items-center gap-2 mb-6 p-3 bg-teal/5 rounded-[var(--r-sm)] border border-teal/20">
<div className="flex gap-1 items-end h-6">
{[0.3, 0.7, 1, 0.6, 0.4, 0.8, 0.5].map((h, i) => (
<div key={i} className="w-1 bg-teal rounded-full animate-pulse" style={{ height: `${h * 24}px`, animationDelay: `${i * 0.1}s` }} />
))}
</div>
<span className="text-sm text-teal font-medium ml-2">Now playing...</span>
</div>
)}
<h4 className="text-sm font-semibold text-fg-muted uppercase tracking-wider mb-3">Script</h4>
<p className="leading-relaxed whitespace-pre-wrap text-fg">{script}</p>
</Card>
);
};
/* ── Infographic Renderer ─────────────────────────────────── */ /* ── Infographic Renderer ─────────────────────────────────── */
const InfographicView = ({ data, topicLabel }) => { const InfographicView = ({ data, topicLabel }) => {

View File

@@ -23,9 +23,7 @@ const CONTENT_SCHEMA_SLIDES = `{
] ]
}`; }`;
const CONTENT_SCHEMA_PODCAST = `{
"podcastScript": "A natural spoken script of approx. 300 words summarizing the topic as a podcast episode."
}`;
const CONTENT_SCHEMA_INFOGRAPHIC = `{ const CONTENT_SCHEMA_INFOGRAPHIC = `{
"infographic": { "infographic": {
@@ -45,7 +43,6 @@ const CONTENT_SCHEMA_INFOGRAPHIC = `{
const CONTENT_SCHEMA_ALL = `{ const CONTENT_SCHEMA_ALL = `{
"article": ${CONTENT_SCHEMA_ARTICLE.replace(/^\{|\}$/g, '').trim()}, "article": ${CONTENT_SCHEMA_ARTICLE.replace(/^\{|\}$/g, '').trim()},
"slides": ${CONTENT_SCHEMA_SLIDES.replace(/^\{|\}$/g, '').trim()}, "slides": ${CONTENT_SCHEMA_SLIDES.replace(/^\{|\}$/g, '').trim()},
"podcastScript": "A natural spoken script of approx. 300 words summarizing the topic as a podcast episode.",
"infographic": ${CONTENT_SCHEMA_INFOGRAPHIC.replace(/^\{|\}$/g, '').trim()} "infographic": ${CONTENT_SCHEMA_INFOGRAPHIC.replace(/^\{|\}$/g, '').trim()}
}`; }`;
@@ -83,10 +80,7 @@ export async function generateLearningContent(topic, force = false, selectedType
if (!force) { if (!force) {
cached = await db.getContent(topic.id); cached = await db.getContent(topic.id);
if (cached) { if (cached) {
if (selectedType === 'podcast' && cached.podcastScript) { if (cached[selectedType]) {
console.log(`[Learn] Cache hit for topic: ${topic.id} (podcast)`);
return cached;
} else if (selectedType !== 'podcast' && cached[selectedType]) {
console.log(`[Learn] Cache hit for topic: ${topic.id} (${selectedType})`); console.log(`[Learn] Cache hit for topic: ${topic.id} (${selectedType})`);
return cached; return cached;
} }
@@ -104,9 +98,6 @@ export async function generateLearningContent(topic, force = false, selectedType
} else if (selectedType === 'slides') { } else if (selectedType === 'slides') {
schema = CONTENT_SCHEMA_SLIDES; schema = CONTENT_SCHEMA_SLIDES;
instructions = 'Provide at least 4 slides.'; instructions = 'Provide at least 4 slides.';
} else if (selectedType === 'podcast') {
schema = CONTENT_SCHEMA_PODCAST;
instructions = 'Provide a natural spoken script.';
} else if (selectedType === 'infographic') { } else if (selectedType === 'infographic') {
schema = CONTENT_SCHEMA_INFOGRAPHIC; schema = CONTENT_SCHEMA_INFOGRAPHIC;
instructions = 'Provide at least 3 stats, and 3-5 steps in the infographic.'; instructions = 'Provide at least 3 stats, and 3-5 steps in the infographic.';

View File

@@ -224,7 +224,6 @@ const Leren = () => {
<div className="flex flex-wrap justify-center gap-4"> <div className="flex flex-wrap justify-center gap-4">
<Button onClick={() => loadContent('article')}>Article</Button> <Button onClick={() => loadContent('article')}>Article</Button>
<Button onClick={() => loadContent('slides')}>Slides</Button> <Button onClick={() => loadContent('slides')}>Slides</Button>
<Button onClick={() => loadContent('podcast')}>Podcast</Button>
<Button onClick={() => loadContent('infographic')}>Infographic</Button> <Button onClick={() => loadContent('infographic')}>Infographic</Button>
</div> </div>
</Card> </Card>