feat: implement core knowledge graph UI components, extraction pipeline, and initial platform navigation pages
This commit is contained in:
282
src/components/ui/LearningContentViewer.jsx
Normal file
282
src/components/ui/LearningContentViewer.jsx
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* LearningContentViewer
|
||||
* Shared component used by both the student Learn page and the Admin Content Manager.
|
||||
* Renders all four learning modes: Article, Slides, Podcast, Infographic.
|
||||
*/
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
ChevronLeft, ChevronRight, BookOpen, Presentation,
|
||||
Mic, CheckCircle, Volume2, VolumeX, BarChart2
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import Card from './Card';
|
||||
import Tag from './Tag';
|
||||
import Button from './Button';
|
||||
|
||||
const MODES = [
|
||||
{ key: 'article', icon: BookOpen, label: 'Article' },
|
||||
{ key: 'slides', icon: Presentation, label: 'Slides' },
|
||||
{ key: 'podcast', icon: Mic, label: 'Podcast' },
|
||||
{ key: 'infographic', icon: BarChart2, label: 'Infographic' },
|
||||
];
|
||||
|
||||
const LearningContentViewer = ({ content, topic, initialMode = 'article' }) => {
|
||||
const [activeMode, setActiveMode] = useState(initialMode);
|
||||
|
||||
if (!content || !topic) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Mode Selector */}
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
{MODES.map(({ key, icon: Icon, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveMode(key)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-[var(--r-sm)] border transition-all text-sm font-medium ${
|
||||
activeMode === key
|
||||
? 'bg-teal text-white border-teal shadow-sm'
|
||||
: 'border-bg-warm text-fg-muted hover:text-fg hover:border-teal/50 bg-paper'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeMode}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{activeMode === 'article' && <ArticleView content={content.article} />}
|
||||
{activeMode === 'slides' && <SlidesView slides={content.slides} />}
|
||||
{activeMode === 'podcast' && <PodcastView script={content.podcastScript} topicLabel={topic.label} />}
|
||||
{activeMode === 'infographic' && <InfographicView data={content.infographic} topicLabel={topic.label} />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Article Renderer ─────────────────────────────────────── */
|
||||
const ArticleView = ({ content }) => (
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-bg-warm">
|
||||
<h2 className="text-2xl font-bold mb-3">{content.title}</h2>
|
||||
<p className="text-fg-muted text-lg leading-relaxed">{content.intro}</p>
|
||||
</Card>
|
||||
{content.sections?.map((section, i) => (
|
||||
<Card key={i} className="border border-bg-warm">
|
||||
<h3 className="text-xl font-semibold mb-3 text-teal">{section.heading}</h3>
|
||||
<p className="leading-relaxed">{section.body}</p>
|
||||
</Card>
|
||||
))}
|
||||
{content.keyTakeaways?.length > 0 && (
|
||||
<Card className="border border-teal/30 bg-teal/5">
|
||||
<h3 className="font-bold text-teal mb-4 flex items-center gap-2">
|
||||
<CheckCircle size={18} /> Key Takeaways
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{content.keyTakeaways.map((point, i) => (
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<span className="font-mono text-teal mt-0.5">→</span>
|
||||
<span>{point}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ── Slides Renderer ──────────────────────────────────────── */
|
||||
const SlidesView = ({ slides }) => {
|
||||
const [current, setCurrent] = useState(0);
|
||||
const total = slides?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={current}
|
||||
initial={{ opacity: 0, x: 40 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -40 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<Card className="border border-bg-warm min-h-[280px] flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className="font-mono text-xs text-fg-muted">{current + 1} / {total}</span>
|
||||
<Tag variant="accent">Slide</Tag>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-6">{slides[current]?.title}</h2>
|
||||
<ul className="space-y-3">
|
||||
{slides[current]?.bullets?.map((bullet, i) => (
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<span className="font-mono text-purple-500 font-bold mt-0.5">▸</span>
|
||||
<span className="text-lg">{bullet}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{slides[current]?.speakerNote && (
|
||||
<p className="text-sm text-fg-muted border-t border-bg-warm pt-4 mt-6 italic">
|
||||
💬 {slides[current].speakerNote}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Button variant="outline" onClick={() => setCurrent(c => Math.max(0, c - 1))} disabled={current === 0}>
|
||||
<ChevronLeft size={18} />
|
||||
</Button>
|
||||
<div className="flex gap-1.5">
|
||||
{slides?.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrent(i)}
|
||||
className={`h-2 rounded-full transition-all ${i === current ? 'bg-teal w-4' : 'bg-bg-warm w-2'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setCurrent(c => Math.min(total - 1, c + 1))} disabled={current === total - 1}>
|
||||
<ChevronRight size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── 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 ─────────────────────────────────── */
|
||||
const InfographicView = ({ data, topicLabel }) => {
|
||||
if (!data) {
|
||||
return (
|
||||
<Card className="border border-bg-warm p-8 text-center text-fg-muted">
|
||||
No infographic data available for this topic.
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-[var(--r-org)] overflow-hidden bg-gradient-to-br from-teal to-teal/70 text-white p-8 md:p-12 relative">
|
||||
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 80%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
|
||||
<div className="relative z-10">
|
||||
<p className="text-white/60 text-sm uppercase tracking-widest font-mono mb-3">{topicLabel}</p>
|
||||
<h2 className="text-3xl md:text-4xl font-bold leading-tight mb-4">{data.headline}</h2>
|
||||
<p className="text-white/80 text-lg max-w-xl">{data.tagline}</p>
|
||||
</div>
|
||||
</div>
|
||||
{data.stats?.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{data.stats.map((stat, i) => (
|
||||
<motion.div key={i} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.1 }}>
|
||||
<Card className="border border-bg-warm text-center py-6">
|
||||
<div className="text-4xl mb-2">{stat.icon}</div>
|
||||
<div className="text-3xl font-bold text-teal mb-1">{stat.value}</div>
|
||||
<div className="text-sm text-fg-muted">{stat.label}</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{data.steps?.length > 0 && (
|
||||
<Card className="border border-bg-warm">
|
||||
<h3 className="text-lg font-bold mb-6 text-teal">Steps & Processes</h3>
|
||||
<div className="space-y-4">
|
||||
{data.steps.map((step, i) => (
|
||||
<motion.div key={i} initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: i * 0.08 }} className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-teal/10 border border-teal/30 flex items-center justify-center">
|
||||
<span className="text-lg">{step.icon}</span>
|
||||
</div>
|
||||
<div className="flex-1 pb-4 border-b border-bg-warm last:border-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-xs text-teal font-bold">0{step.number}</span>
|
||||
<h4 className="font-semibold">{step.title}</h4>
|
||||
</div>
|
||||
<p className="text-sm text-fg-muted">{step.description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{data.quote && (
|
||||
<div className="border-l-4 border-teal pl-6 py-2">
|
||||
<blockquote className="text-xl italic text-fg-muted leading-relaxed">"{data.quote}"</blockquote>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LearningContentViewer;
|
||||
Reference in New Issue
Block a user