feat: implement Anthropic API integration with simulation mode and a configurable admin dashboard
This commit is contained in:
@@ -23,4 +23,10 @@ server {
|
||||
access_log off;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
# Proxy Anthropic API to bypass CORS
|
||||
location /api/anthropic/ {
|
||||
proxy_pass https://api.anthropic.com/;
|
||||
proxy_set_header Host api.anthropic.com;
|
||||
proxy_ssl_server_name on;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
|
||||
import Admin from './pages/Admin'
|
||||
import Leren from './pages/Leren'
|
||||
|
||||
// Placeholder components for routing structure
|
||||
const Testen = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Weektest</h1><p className="mt-4">Start your weekly test here.</p></div>
|
||||
const Leren = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Leren</h1><p className="mt-4">Start your weekly learning session.</p></div>
|
||||
const Leaderboard = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Leaderboard</h1><p className="mt-4">See who is on top!</p></div>
|
||||
|
||||
// Protected Route Wrapper
|
||||
|
||||
@@ -2,47 +2,52 @@ import { storage } from './storage';
|
||||
|
||||
/**
|
||||
* Anthropic API Service
|
||||
* Handles communication with the /v1/messages endpoint.
|
||||
* Handles communication with the /v1/messages endpoint via Nginx proxy.
|
||||
*/
|
||||
|
||||
const MODEL = 'claude-3-7-sonnet-20250219'; // using the latest sonnet model for best results. Alternatively, claude-3-5-sonnet-20241022 or the prompt's requested claude-sonnet-4-20250514 (which might be a pseudo-name for the upcoming 3.7 or future version).
|
||||
const DEFAULT_MODEL = 'claude-sonnet-4-20250514';
|
||||
|
||||
export const anthropicApi = {
|
||||
/**
|
||||
* Call the Anthropic API with a system prompt and user message.
|
||||
* Includes a basic retry mechanism.
|
||||
*/
|
||||
async generateContent(systemPrompt, userMessage, maxRetries = 1) {
|
||||
let retries = 0;
|
||||
|
||||
// In a real application, the API key should not be exposed to the client.
|
||||
// For this prototype, we'll assume it's passed or available in the environment,
|
||||
// or proxied via a secure endpoint if deployed.
|
||||
// Prioritize key from local storage (set via Admin Settings)
|
||||
const apiKey = storage.get('admin:anthropic_key') || import.meta.env.VITE_ANTHROPIC_API_KEY || 'no-key-provided';
|
||||
// Check if simulation mode is on
|
||||
const useSimulation = storage.get('admin:use_simulation') === true;
|
||||
if (useSimulation) {
|
||||
console.log('[API] Simulatie mode actief. Mock data wordt geretourneerd.');
|
||||
return await simulateResponse();
|
||||
}
|
||||
|
||||
const apiKey = storage.get('admin:anthropic_key') || import.meta.env.VITE_ANTHROPIC_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error('Geen Anthropic API key gevonden. Ga naar Admin -> Settings.');
|
||||
}
|
||||
|
||||
// Model is configurable from Admin > Settings, defaults to the original spec model
|
||||
const model = storage.get('admin:model') || DEFAULT_MODEL;
|
||||
console.log(`[API] Aanroep met model: ${model}`);
|
||||
|
||||
let retries = 0;
|
||||
while (retries <= maxRetries) {
|
||||
try {
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
const response = await fetch('/api/anthropic/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-dangerous-direct-browser-access': 'true' // Required for client-side fetch
|
||||
'anthropic-dangerous-direct-browser-access': 'true'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
model: model,
|
||||
max_tokens: 4000,
|
||||
system: systemPrompt,
|
||||
messages: [
|
||||
{ role: 'user', content: userMessage }
|
||||
]
|
||||
messages: [{ role: 'user', content: userMessage }]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
console.error('[API] Error response:', errData);
|
||||
throw new Error(`API Error: ${response.status} ${response.statusText} - ${JSON.stringify(errData)}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@@ -50,12 +55,24 @@ export const anthropicApi = {
|
||||
} catch (error) {
|
||||
console.error('API call failed:', error);
|
||||
retries++;
|
||||
if (retries > maxRetries) {
|
||||
throw new Error('Failed to generate content after retries.');
|
||||
}
|
||||
// Small delay before retry
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
if (retries > maxRetries) throw error;
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function simulateResponse() {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
return JSON.stringify({
|
||||
topics: [
|
||||
{ id: "radicale-transparantie", label: "Radicale Transparantie", type: "concept", description: "De kernwaarde van Respellion waarbij alle informatie publiek toegankelijk is." },
|
||||
{ id: "kennisbeheer", label: "Kennisbeheer", type: "process", description: "Het proces van het vastleggen en ontsluiten van organisatiekennis." },
|
||||
{ id: "wekelijkse-sessie", label: "Wekelijkse Leersessie", type: "process", description: "Elke week leren medewerkers via AI-gegenereerde vragen en quizzen." }
|
||||
],
|
||||
relations: [
|
||||
{ source: "kennisbeheer", target: "radicale-transparantie", type: "depends_on" },
|
||||
{ source: "wekelijkse-sessie", target: "kennisbeheer", type: "part_of" }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
91
src/lib/learningService.js
Normal file
91
src/lib/learningService.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { anthropicApi } from './api';
|
||||
import { storage } from './storage';
|
||||
|
||||
const CONTENT_GENERATION_SYSTEM = `Je bent een expert leerinhoud-schrijver voor Respellion, een intern IT-bedrijf.
|
||||
Je schrijft leermateriaal voor medewerkers op basis van kennisonderwerpen.
|
||||
Schrijf altijd in het Nederlands, helder en professioneel.
|
||||
Geef ALTIJD geldige JSON terug, zonder markdown code-blokken.`;
|
||||
|
||||
/**
|
||||
* Get the assigned topic for a user for a given week using round-robin.
|
||||
* hash(userId + weekNumber) % topicCount
|
||||
*/
|
||||
export function getAssignedTopic(userId, weekNumber) {
|
||||
const topics = storage.get('kb:topics', []);
|
||||
if (!topics || topics.length === 0) return null;
|
||||
|
||||
// Simple deterministic hash
|
||||
const str = `${userId}:${weekNumber}`;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
hash |= 0; // Convert to 32-bit integer
|
||||
}
|
||||
const index = Math.abs(hash) % topics.length;
|
||||
return topics[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete learning module for a topic.
|
||||
* Returns an object with { article, slides, podcastScript, infographic }.
|
||||
* Caches results in storage.
|
||||
*/
|
||||
export async function generateLearningContent(topic) {
|
||||
const cacheKey = `kb:content:${topic.id}`;
|
||||
const cached = storage.get(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`[Learn] Cache hit voor topic: ${topic.id}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const prompt = `Genereer een compleet leermodule voor het volgende onderwerp:
|
||||
|
||||
Label: ${topic.label}
|
||||
Type: ${topic.type}
|
||||
Beschrijving: ${topic.description}
|
||||
|
||||
Geef ALLEEN een JSON object terug met de volgende structuur:
|
||||
{
|
||||
"article": {
|
||||
"title": "Artikel titel",
|
||||
"intro": "Korte intro van 1-2 zinnen",
|
||||
"sections": [
|
||||
{ "heading": "Sectietitel", "body": "Sectietekst van minimaal 3 zinnen." }
|
||||
],
|
||||
"keyTakeaways": ["Lespunt 1", "Lespunt 2", "Lespunt 3"]
|
||||
},
|
||||
"slides": [
|
||||
{ "title": "Diatitel", "bullets": ["Punt 1", "Punt 2", "Punt 3"], "speakerNote": "Toelichting voor de spreker." }
|
||||
],
|
||||
"podcastScript": "Een vloeiend gesproken script van ca. 300 woorden dat de inhoud samenvat als een podcast.",
|
||||
"infographic": {
|
||||
"headline": "Een korte, krachtige zin die het onderwerp samenvat (max 8 woorden)",
|
||||
"tagline": "Een subkop van max 15 woorden",
|
||||
"stats": [
|
||||
{ "value": "Getal of %", "label": "Korte omschrijving", "icon": "📊" }
|
||||
],
|
||||
"steps": [
|
||||
{ "number": 1, "title": "Staptitel", "description": "Korte beschrijving van 1 zin.", "icon": "🔑" }
|
||||
],
|
||||
"quote": "Een inspirerende of kernachtige quote over het onderwerp.",
|
||||
"colorTheme": "teal"
|
||||
}
|
||||
}
|
||||
|
||||
Zorg voor minimaal 3 secties in het artikel, 4 slides, 3 statistieken en 3-5 stappen in de infographic.`;
|
||||
|
||||
|
||||
const responseText = await anthropicApi.generateContent(CONTENT_GENERATION_SYSTEM, prompt);
|
||||
|
||||
let content;
|
||||
try {
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||
content = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
|
||||
} catch (e) {
|
||||
throw new Error('AI kon geen geldige leerinhoud genereren.');
|
||||
}
|
||||
|
||||
// Cache the content
|
||||
storage.set(cacheKey, content);
|
||||
return content;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save } from 'lucide-react';
|
||||
import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info } from 'lucide-react';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Tag from '../../components/ui/Tag';
|
||||
import Button from '../../components/ui/Button';
|
||||
@@ -12,6 +12,8 @@ const Admin = () => {
|
||||
const [activeTab, setActiveTab] = useState('bronnen');
|
||||
const [sources, setSources] = useState([]);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [model, setModel] = useState('');
|
||||
const [useSimulation, setUseSimulation] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState(null);
|
||||
|
||||
const loadSources = () => {
|
||||
@@ -24,12 +26,16 @@ const Admin = () => {
|
||||
}
|
||||
if (activeTab === 'instellingen') {
|
||||
setApiKey(storage.get('admin:anthropic_key', ''));
|
||||
setModel(storage.get('admin:model', 'claude-sonnet-4-20250514'));
|
||||
setUseSimulation(storage.get('admin:use_simulation', false));
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const saveSettings = (e) => {
|
||||
e.preventDefault();
|
||||
storage.set('admin:anthropic_key', apiKey);
|
||||
storage.set('admin:anthropic_key', apiKey.trim());
|
||||
storage.set('admin:model', model.trim());
|
||||
storage.set('admin:use_simulation', useSimulation);
|
||||
setSaveStatus('Opgeslagen!');
|
||||
setTimeout(() => setSaveStatus(null), 3000);
|
||||
};
|
||||
@@ -153,12 +159,45 @@ const Admin = () => {
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<Input
|
||||
label="Model ID"
|
||||
placeholder="claude-sonnet-4-20250514"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-fg-muted mt-1">Laat leeg voor de standaard. Controleer je Anthropic Console voor beschikbare modellen.</p>
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted mt-2">
|
||||
Deze sleutel wordt lokaal in je browser opgeslagen (`localStorage`) en wordt gebruikt voor alle AI-interacties.
|
||||
Deze sleutel wordt lokaal in je browser opgeslagen (`localStorage`).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-bg-warm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Simulatie Mode</h3>
|
||||
<p className="text-sm text-fg-muted">Gebruik gesimuleerde AI-antwoorden als de API niet werkt.</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={useSimulation}
|
||||
onChange={(e) => setUseSimulation(e.target.checked)}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bg-warm peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-teal"></div>
|
||||
</label>
|
||||
</div>
|
||||
{useSimulation && (
|
||||
<div className="p-3 bg-teal/5 border border-teal/20 rounded-[var(--r-sm)] flex gap-3 text-sm text-teal-800">
|
||||
<Info size={18} className="flex-shrink-0" />
|
||||
<p>Wanneer ingeschakeld, zal het platform doen alsof de AI teksten verwerkt zonder een echte API-aanroep te doen. Ideaal voor UI testen.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<Button type="submit">
|
||||
<Save size={18} className="mr-2" /> Instellingen Opslaan
|
||||
</Button>
|
||||
|
||||
391
src/pages/Leren.jsx
Normal file
391
src/pages/Leren.jsx
Normal file
@@ -0,0 +1,391 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { ChevronLeft, ChevronRight, BookOpen, Presentation, Mic, CheckCircle, Loader, ArrowRight, Volume2, VolumeX, BarChart2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import Card from '../components/ui/Card';
|
||||
import Button from '../components/ui/Button';
|
||||
import Tag from '../components/ui/Tag';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import { getAssignedTopic, generateLearningContent } from '../lib/learningService';
|
||||
import { storage } from '../lib/storage';
|
||||
|
||||
const Leren = () => {
|
||||
const { state } = useApp();
|
||||
const [topic, setTopic] = useState(null);
|
||||
const [content, setContent] = useState(null);
|
||||
const [activeMode, setActiveMode] = useState('artikel'); // 'artikel' | 'slides' | 'podcast'
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.currentUser) {
|
||||
const assigned = getAssignedTopic(state.currentUser.id, state.weekNumber);
|
||||
setTopic(assigned);
|
||||
}
|
||||
}, [state.currentUser, state.weekNumber]);
|
||||
|
||||
const loadContent = async () => {
|
||||
if (!topic) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const generated = await generateLearningContent(topic);
|
||||
setContent(generated);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
setCompleted(true);
|
||||
storage.set(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, true);
|
||||
};
|
||||
|
||||
if (!topic) {
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
||||
<BookOpen size={64} className="mx-auto text-teal/30 mb-6" />
|
||||
<h1 className="text-3xl text-teal font-bold mb-4">Leerstation</h1>
|
||||
<p className="text-fg-muted">Er zijn nog geen kennisonderwerpen beschikbaar. Vraag een admin om bronmateriaal te uploaden.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (completed) {
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
||||
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 200 }}>
|
||||
<CheckCircle size={80} className="mx-auto text-teal mb-6" />
|
||||
</motion.div>
|
||||
<h1 className="text-3xl font-bold mb-4">Leersessie voltooid!</h1>
|
||||
<p className="text-fg-muted mb-8">Je hebt "<strong>{topic.label}</strong>" succesvol doorgenomen. Ga nu de weektest maken!</p>
|
||||
<Button onClick={() => window.location.hash = '/testen'}>
|
||||
Weektest Starten <ArrowRight size={18} className="ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Tag variant="accent">Week {state.weekNumber}</Tag>
|
||||
<Tag variant="dark" className="font-mono text-xs">{topic.type}</Tag>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-teal">{topic.label}</h1>
|
||||
<p className="text-fg-muted mt-2">{topic.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Mode Selector */}
|
||||
{content && (
|
||||
<div className="flex gap-2 mb-8 flex-wrap">
|
||||
{[
|
||||
{ key: 'artikel', icon: BookOpen, label: 'Artikel' },
|
||||
{ key: 'slides', icon: Presentation, label: 'Slides' },
|
||||
{ key: 'podcast', icon: Mic, label: 'Podcast' },
|
||||
{ key: 'infographic', icon: BarChart2, label: 'Infographic' },
|
||||
].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 ${
|
||||
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} />
|
||||
<span className="font-medium text-sm">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Area */}
|
||||
{!content && !isLoading && (
|
||||
<Card className="border border-bg-warm text-center py-16">
|
||||
<BookOpen size={48} className="mx-auto text-teal/30 mb-4" />
|
||||
<p className="text-fg-muted mb-6">Klik op de knop om je gepersonaliseerde leerinhoud te genereren met AI.</p>
|
||||
<Button onClick={loadContent} disabled={isLoading}>
|
||||
Leerinhoud Genereren
|
||||
</Button>
|
||||
</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 genereert je gepersonaliseerde leerinhoud...</p>
|
||||
<p className="text-fg-muted text-sm mt-2">Dit kan 10-30 seconden duren.</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card className="border border-red-200 bg-red-50 text-red-900 p-6">
|
||||
<p className="font-bold mb-1">Fout bij genereren</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
<Button onClick={loadContent} variant="outline" className="mt-4 border-red-300 text-red-700">
|
||||
Opnieuw proberen
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{content && (
|
||||
<motion.div key={activeMode} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}>
|
||||
{activeMode === 'artikel' && <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>
|
||||
|
||||
{content && !completed && (
|
||||
<div className="mt-10 pt-6 border-t border-bg-warm flex justify-end">
|
||||
<Button onClick={handleComplete}>
|
||||
<CheckCircle size={18} className="mr-2" /> Leersessie afronden
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Article Renderer ─────────────────────────────────────── */
|
||||
const ArticleView = ({ content }) => (
|
||||
<div className="space-y-8">
|
||||
<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} /> Kernpunten</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-[300px] 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={`w-2 h-2 rounded-full transition-all ${i === current ? 'bg-teal w-4' : 'bg-bg-warm'}`} />
|
||||
))}
|
||||
</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 wordt niet ondersteund door je browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
window.speechSynthesis.cancel();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
const utterance = new SpeechSynthesisUtterance(script);
|
||||
utterance.lang = 'nl-NL';
|
||||
utterance.rate = 0.95;
|
||||
utterance.onend = () => setIsPlaying(false);
|
||||
utteranceRef.current = utterance;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
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 Aflevering</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} /> Afspelen</>}
|
||||
</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">Wordt afgespeeld...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prose max-w-none">
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Infographic Renderer ─────────────────────────────────── */
|
||||
const InfographicView = ({ data, topicLabel }) => {
|
||||
if (!data) {
|
||||
return (
|
||||
<Card className="border border-bg-warm p-8 text-center text-fg-muted">
|
||||
Geen infographic data beschikbaar voor dit onderwerp.
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Hero Header */}
|
||||
<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>
|
||||
|
||||
{/* Stats Row */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Process Steps */}
|
||||
{data.steps?.length > 0 && (
|
||||
<Card className="border border-bg-warm">
|
||||
<h3 className="text-lg font-bold mb-6 text-teal">Stappen & Processen</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>
|
||||
)}
|
||||
|
||||
{/* Quote */}
|
||||
{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 Leren;
|
||||
@@ -5,4 +5,13 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api/anthropic': {
|
||||
target: 'https://api.anthropic.com',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api\/anthropic/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user