fix(presentation): 修复 presentation 模块类型错误和语法问题
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

- 创建 types.ts 定义完整的类型系统
- 重写 DocumentRenderer.tsx 修复语法错误
- 重写 QuizRenderer.tsx 修复语法错误
- 重写 PresentationContainer.tsx 添加类型守卫
- 重写 TypeSwitcher.tsx 修复类型引用
- 更新 index.ts 移除不存在的 ChartRenderer 导出

审计结果:
- 类型检查: 通过
- 单元测试: 222 passed
- 构建: 成功
This commit is contained in:
iven
2026-03-26 17:19:28 +08:00
parent d0c6319fc1
commit b7f3d94950
71 changed files with 15896 additions and 1133 deletions

View File

@@ -0,0 +1,148 @@
/**
* Presentation Container
*
* Main container for smart presentation rendering.
*
* Features:
* - Auto-detects presentation type from data structure
* - Supports manual type switching
* - Manages presentation state
* - Provides export functionality
*/
import React, { useState, useMemo, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import type { PresentationType, PresentationAnalysis } from './types';
import { TypeSwitcher } from './TypeSwitcher';
import { QuizRenderer } from './renderers/QuizRenderer';
const SlideshowRenderer = React.lazy(() => import('./renderers/SlideshowRenderer').then(m => ({ default: m.SlideshowRenderer })));
const DocumentRenderer = React.lazy(() => import('./renderers/DocumentRenderer').then(m => ({ default: m.DocumentRenderer })));
interface PresentationContainerProps {
/** Pipeline output data */
data: unknown;
/** Pipeline ID (reserved for future use) */
pipelineId?: string;
/** Supported presentation types (from pipeline config) */
supportedTypes?: PresentationType[];
/** Default presentation type */
defaultType?: PresentationType;
/** Allow user to switch types */
allowSwitch?: boolean;
/** Called when export is triggered (reserved for future use) */
onExport?: (format: string) => void;
/** Custom className */
className?: string;
}
export function PresentationContainer({
data,
supportedTypes,
defaultType,
allowSwitch = true,
className = '',
}: PresentationContainerProps) {
const [analysis, setAnalysis] = useState<PresentationAnalysis | null>(null);
const [currentType, setCurrentType] = useState<PresentationType | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(true);
useMemo(() => {
const runAnalysis = async () => {
setIsAnalyzing(true);
try {
const result = await invoke<PresentationAnalysis>('analyze_presentation', { data });
setAnalysis(result);
if (defaultType) {
setCurrentType(defaultType);
} else if (result) {
setCurrentType(result.recommendedType);
}
} catch (error) {
console.error('Failed to analyze presentation:', error);
setCurrentType('document');
} finally {
setIsAnalyzing(false);
}
};
runAnalysis();
}, [data, defaultType]);
const handleTypeChange = useCallback((type: PresentationType) => {
setCurrentType(type);
}, []);
const availableTypes = useMemo(() => {
if (supportedTypes && supportedTypes.length > 0) {
return supportedTypes.filter((t): t is PresentationType => t !== 'auto');
}
return (['quiz', 'slideshow', 'document', 'whiteboard'] as PresentationType[]);
}, [supportedTypes]);
const renderContent = () => {
if (isAnalyzing) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
<p className="ml-3 text-gray-500">...</p>
</div>
);
}
switch (currentType) {
case 'quiz':
return <QuizRenderer data={data as Parameters<typeof QuizRenderer>[0]['data']} />;
case 'slideshow':
return (
<React.Suspense fallback={<div className="h-64 animate-pulse bg-gray-100" />}>
<SlideshowRenderer data={data as Parameters<typeof SlideshowRenderer>[0]['data']} />
</React.Suspense>
);
case 'document':
return (
<React.Suspense fallback={<div className="h-64 animate-pulse bg-gray-100" />}>
<DocumentRenderer data={data as Parameters<typeof DocumentRenderer>[0]['data']} />
</React.Suspense>
);
case 'whiteboard':
return (
<div className="flex items-center justify-center h-64 bg-gray-50">
<p className="text-gray-500">...</p>
</div>
);
default:
return (
<div className="flex items-center justify-center h-64 bg-gray-50">
<p className="text-gray-500"></p>
</div>
);
}
};
return (
<div className={`flex flex-col h-full ${className}`}>
{allowSwitch && (
<div className="border-b border-gray-200 bg-gray-50 p-3">
<TypeSwitcher
availableTypes={availableTypes}
currentType={currentType || 'document'}
analysis={analysis || undefined}
onTypeChange={handleTypeChange}
/>
</div>
)}
<div className="flex-1 overflow-auto">
{renderContent()}
</div>
</div>
);
}
export default PresentationContainer;

View File

@@ -0,0 +1,113 @@
/**
* Type Switcher Component
*
* Allows users to switch between presentation types.
*/
import {
BarChart3,
FileText,
Presentation,
CheckCircle,
PenTool,
} from 'lucide-react';
import type { PresentationType, PresentationAnalysis } from './types';
interface TypeSwitcherProps {
/** Available types */
availableTypes: PresentationType[];
/** Current type */
currentType: PresentationType;
/** Analysis result (optional) */
analysis?: PresentationAnalysis;
/** Called when type is changed */
onTypeChange: (type: PresentationType) => void;
/** Disabled types */
disabledTypes?: PresentationType[];
/** Custom className */
className?: string;
}
const typeConfig: Record<PresentationType, { icon: React.ReactNode; label: string; description: string }> = {
chart: {
icon: <BarChart3 className="w-4 h-4" />,
label: '图表',
description: '数据可视化',
},
slideshow: {
icon: <Presentation className="w-4 h-4" />,
label: '幻灯片',
description: '演示文稿风格',
},
quiz: {
icon: <CheckCircle className="w-4 h-4" />,
label: '测验',
description: '互动问答',
},
document: {
icon: <FileText className="w-4 h-4" />,
label: '文档',
description: 'Markdown 文档',
},
whiteboard: {
icon: <PenTool className="w-4 h-4" />,
label: '白板',
description: '交互式画布',
},
auto: {
icon: <CheckCircle className="w-4 h-4" />,
label: '自动',
description: '自动检测类型',
},
};
export function TypeSwitcher({
availableTypes,
currentType,
analysis,
onTypeChange,
disabledTypes = [],
className = '',
}: TypeSwitcherProps) {
return (
<div className={`flex items-center gap-2 ${className}`}>
{availableTypes.map((type) => {
const config = typeConfig[type];
if (!config) return null;
const isActive = currentType === type;
const isDisabled = disabledTypes.includes(type);
const recommendation = analysis?.recommendedType === type;
return (
<button
key={type}
onClick={() => onTypeChange(type)}
disabled={isDisabled}
className={`
flex items-center gap-2 px-3 py-2 rounded-lg transition-all
${isActive
? 'bg-blue-100 text-blue-700 border-2 border-blue-500'
: 'bg-white text-gray-600 border border-gray-200 hover:bg-gray-100'
}
${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}
`}
title={config.description}
>
<span className="text-lg">{config.icon}</span>
<span className="text-sm font-medium">{config.label}</span>
{recommendation && (
<span className="text-xs text-blue-500"></span>
)}
</button>
);
})}
{analysis && (
<div className="ml-4 text-xs text-gray-500">
<p>: {(analysis.confidence * 100).toFixed(0)}%</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
/**
* Presentation Components
*
* Smart presentation layer for Pipeline output rendering.
*
* @example
* ```tsx
* import { PresentationContainer } from '@/components/presentation';
*
* <PresentationContainer
* data={pipelineOutput}
* pipelineId="course-generator"
* supportedTypes={['slideshow', 'quiz', 'document']}
* />
* ```
*/
export { PresentationContainer } from './PresentationContainer';
export { TypeSwitcher } from './TypeSwitcher';
export { QuizRenderer } from './renderers/QuizRenderer';
export { DocumentRenderer } from './renderers/DocumentRenderer';
export { SlideshowRenderer } from './renderers/SlideshowRenderer';
export type {
PresentationType,
PresentationAnalysis,
ChartData,
QuizData,
QuizQuestion,
QuestionType,
SlideshowData,
DocumentData,
WhiteboardData,
} from './types';

View File

@@ -0,0 +1,150 @@
/**
* Document Renderer
*
* Renders content as a scrollable document with Markdown support.
*/
import { useState } from 'react';
import { Download, ExternalLink, Copy } from 'lucide-react';
import type { DocumentData } from '../types';
interface DocumentRendererProps {
/** Document data */
data: DocumentData;
/** Enable markdown rendering */
enableMarkdown?: boolean;
/** Custom className */
className?: string;
}
export function DocumentRenderer({
data,
enableMarkdown = true,
className = '',
}: DocumentRendererProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
const textToCopy = typeof data === 'string' ? data : (data.content || JSON.stringify(data, null, 2));
await navigator.clipboard.writeText(textToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};
const handleDownload = () => {
if (data.downloadUrl) {
const link = document.createElement('a');
link.href = data.downloadUrl;
link.download = data.downloadFilename || 'document.md';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
const renderMarkdown = (content: string): React.ReactNode => {
const lines = content.split('\n');
const elements: React.ReactNode[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('# ')) {
elements.push(
<h1 key={trimmed} className="text-2xl font-bold mb-4">
{trimmed.substring(2)}
</h1>
);
} else if (trimmed.startsWith('## ')) {
elements.push(
<h2 key={trimmed} className="text-xl font-semibold mb-3">
{trimmed.substring(3)}
</h2>
);
} else if (trimmed.startsWith('### ')) {
elements.push(
<h3 key={trimmed} className="text-lg font-medium mb-2">
{trimmed.substring(4)}
</h3>
);
} else if (trimmed.startsWith('- ')) {
elements.push(
<li key={trimmed} className="ml-4 list-disc">
{trimmed.substring(2)}
</li>
);
} else if (trimmed.startsWith('```')) {
elements.push(
<pre key={trimmed} className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm my-2">
<code>{trimmed.substring(3, trimmed.length - 3)}</code>
</pre>
);
} else {
elements.push(
<p key={trimmed} className="mb-2">{trimmed}</p>
);
}
}
return <div className={className}>{elements}</div>;
};
if (!enableMarkdown) {
return (
<div className={`flex flex-col h-full ${className}`}>
<pre className="whitespace-pre-wrap text-sm">{JSON.stringify(data, null, 2)}</pre>
</div>
);
}
return (
<div className={`flex flex-col h-full ${className}`}>
{data.title && (
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h1 className="text-xl font-semibold text-gray-900">{data.title}</h1>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className="p-1 text-gray-400 hover:text-gray-600 flex items-center gap-1"
title="复制"
>
<Copy className="w-4 h-4" />
{copied && <span className="text-xs text-green-500"></span>}
</button>
{data.downloadUrl && (
<button
onClick={handleDownload}
className="p-1 text-gray-400 hover:text-gray-600"
title="下载"
>
<Download className="w-4 h-4" />
</button>
)}
{data.url && (
<button
onClick={() => window.open(data.url, '_blank')}
className="p-1 text-gray-400 hover:text-gray-600"
title="在新窗口打开"
>
<ExternalLink className="w-4 h-4" />
</button>
)}
</div>
</div>
)}
<div className="flex-1 overflow-auto p-6">
{typeof data === 'string'
? renderMarkdown(data)
: renderMarkdown(data.content || JSON.stringify(data))}
</div>
</div>
);
}
export default DocumentRenderer;

View File

@@ -0,0 +1,354 @@
/**
* Quiz Renderer
*
* Renders interactive quizzes with support for:
* - Single choice
* - Multiple choice
* - True/False
* - Fill in blank
* - Short answer
*/
import { useState, useMemo } from 'react';
import {
CheckCircle,
XCircle,
Award,
RotateCcw,
} from 'lucide-react';
import type { QuizData, QuizQuestion } from '../types';
interface QuizRendererProps {
data: QuizData;
onComplete?: (score: number, correct: number, total: number) => void;
onAnswer?: (questionId: string, answer: unknown) => void;
showAnswers?: boolean;
allowRetry?: boolean;
className?: string;
}
interface UserAnswer {
questionId: string;
answer: unknown;
isCorrect: boolean;
}
export function QuizRenderer({
data,
onComplete,
onAnswer,
showAnswers = true,
allowRetry = true,
className = '',
}: QuizRendererProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState<Record<string, UserAnswer>>({});
const [showResults, setShowResults] = useState(showAnswers ?? false);
const [score, setScore] = useState(0);
const [correctCount, setCorrectCount] = useState(0);
const [isCompleted, setIsCompleted] = useState(false);
const checkAnswer = (answer: unknown, question: QuizQuestion): boolean => {
if (question.questionType === 'singleChoice' || question.questionType === 'trueFalse') {
return answer === question.correctAnswer;
}
if (question.questionType === 'multipleChoice') {
const answerArr = answer as string[];
const correctArr = question.correctAnswer as string[];
if (!Array.isArray(answerArr) || !Array.isArray(correctArr)) return false;
return JSON.stringify([...answerArr].sort()) === JSON.stringify([...correctArr].sort());
}
if (question.questionType === 'fillBlank' || question.questionType === 'shortAnswer') {
return String(answer).toLowerCase().trim() === String(question.correctAnswer).toLowerCase().trim();
}
return false;
};
useMemo(() => {
if (!data.questions || data.questions.length === 0) return;
const total = data.questions.length;
const correct = data.questions.filter((q: QuizQuestion) => {
const userAnswer = answers[q.id];
return userAnswer?.isCorrect ?? false;
}).length;
setScore(Math.round((correct / total) * 100));
setCorrectCount(correct);
}, [answers, data.questions]);
const handleSelectAnswer = (questionId: string, answer: unknown) => {
const question = data.questions.find((q: QuizQuestion) => q.id === questionId);
if (!question) return;
const isCorrect = checkAnswer(answer, question);
setAnswers(prev => ({
...prev,
[questionId]: { questionId, answer, isCorrect },
}));
if (onAnswer) {
onAnswer(questionId, answer);
}
};
const handleNext = () => {
if (currentIndex < data.questions.length - 1) {
setCurrentIndex(currentIndex + 1);
}
};
const handlePrev = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1);
}
};
const handleSubmit = () => {
setShowResults(true);
setIsCompleted(true);
if (onComplete) {
onComplete(score, correctCount, data.questions.length);
}
};
const handleRetry = () => {
setAnswers({});
setShowResults(false);
setIsCompleted(false);
setScore(0);
setCorrectCount(0);
setCurrentIndex(0);
};
const question = data.questions[currentIndex];
if (!question) return null;
const progressPercent = ((currentIndex + 1) / data.questions.length) * 100;
const renderQuestionOptions = () => {
const qType = question.questionType;
if (qType === 'singleChoice' || qType === 'trueFalse') {
return (
<div className="space-y-3">
{question.options.map((option) => {
const isSelected = answers[question.id]?.answer === option.id;
const showCorrect = showResults && question.correctAnswer === option.id;
const showIncorrect = showResults && isSelected && !showCorrect;
return (
<button
key={option.id}
onClick={() => !showResults && handleSelectAnswer(question.id, option.id)}
disabled={showResults}
className={`w-full p-4 text-left rounded-lg border-2 transition-all ${
isSelected && !showResults ? 'border-blue-500 bg-blue-50' : ''
} ${showCorrect ? 'border-green-500 bg-green-50' : ''} ${
showIncorrect ? 'border-red-500 bg-red-50' : ''
} ${!isSelected && !showCorrect ? 'border-gray-200' : ''}`}
>
<div className="flex items-center justify-between">
<span className="flex-1">{option.text}</span>
{showCorrect && <CheckCircle className="w-5 h-5 text-green-500" />}
{showIncorrect && <XCircle className="w-5 h-5 text-red-500" />}
</div>
</button>
);
})}
</div>
);
}
if (qType === 'multipleChoice') {
return (
<div className="space-y-3">
{question.options.map((option) => {
const selectedAnswers = (answers[question.id]?.answer as string[]) || [];
const isSelected = selectedAnswers.includes(option.id);
const showCorrect = showResults && (question.correctAnswer as string[]).includes(option.id);
return (
<button
key={option.id}
onClick={() => {
if (showResults) return;
const newAnswers = isSelected
? selectedAnswers.filter(a => a !== option.id)
: [...selectedAnswers, option.id];
handleSelectAnswer(question.id, newAnswers);
}}
disabled={showResults}
className={`w-full p-4 text-left rounded-lg border-2 transition-all ${
isSelected && !showResults ? 'border-blue-500 bg-blue-50' : ''
} ${showCorrect ? 'border-green-500 bg-green-50' : ''} ${
!isSelected && !showCorrect && showResults ? 'border-gray-200 opacity-50' : ''
}`}
>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="w-4 h-4 rounded"
disabled={showResults}
/>
<span className="flex-1">{option.text}</span>
{showCorrect && <CheckCircle className="w-5 h-5 text-green-500" />}
</div>
</button>
);
})}
</div>
);
}
if (qType === 'fillBlank') {
return (
<div className="mt-4">
<input
type="text"
placeholder="请输入答案..."
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
onChange={(e) => handleSelectAnswer(question.id, e.target.value)}
disabled={showResults}
/>
{showResults && (
<p className="text-sm text-gray-500 mt-2">
: {question.correctAnswer}
</p>
)}
</div>
);
}
if (qType === 'shortAnswer') {
return (
<div className="mt-4">
<textarea
placeholder="请输入你的答案..."
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 min-h-32"
onChange={(e) => handleSelectAnswer(question.id, e.target.value)}
disabled={showResults}
/>
{showResults && (
<p className="text-sm text-gray-500 mt-2">
: {question.correctAnswer}
</p>
)}
</div>
);
}
return null;
};
return (
<div className={`flex flex-col h-full ${className}`}>
<div className="bg-white border-b border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
{data.title && (
<h2 className="text-lg font-semibold text-gray-900">{data.title}</h2>
)}
{data.description && (
<p className="text-sm text-gray-500">{data.description}</p>
)}
</div>
<div className="flex items-center gap-2 mt-4">
<div className="flex-1 bg-gray-200 rounded-full h-2">
<div
className="h-2 bg-blue-500 transition-all"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="text-sm text-gray-600">
{currentIndex + 1} / {data.questions.length}
</span>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 flex-1">
<div className="mb-4">
<p className="text-lg font-medium text-gray-900">{question.text}</p>
{question.hint && !showResults && (
<p className="text-sm text-gray-500 mt-2">
💡 {question.hint}
</p>
)}
</div>
{renderQuestionOptions()}
<div className="flex items-center justify-between mt-6">
<button
onClick={handlePrev}
disabled={currentIndex === 0}
className="p-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
>
<RotateCcw className="w-5 h-5" />
</button>
<span className="text-sm text-gray-500">
{currentIndex + 1} / {data.questions.length}
</span>
<button
onClick={handleNext}
disabled={currentIndex === data.questions.length - 1 || showResults}
className="p-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
>
</button>
</div>
{!showResults ? (
<button
onClick={handleSubmit}
className="w-full py-3 bg-blue-500 text-white rounded-lg font-medium hover:bg-blue-600 transition-colors mt-4"
>
</button>
) : (
<div className="space-y-4 mt-4">
<div className="flex items-center justify-center gap-4 p-4 bg-gray-50 rounded-lg">
<div className="text-center">
<div className="text-3xl font-bold text-gray-900">
{score}%
</div>
<div className="text-sm text-gray-500">
{correctCount} / {data.questions.length}
</div>
</div>
</div>
{allowRetry && (
<button
onClick={handleRetry}
className="w-full py-2 text-blue-600 hover:bg-blue-50 rounded-lg font-medium transition-colors"
>
</button>
)}
</div>
)}
</div>
{isCompleted && (
<div className="bg-green-50 p-4 text-center">
<Award className="w-8 h-8 text-green-500 mx-auto mb-2" />
<p className="text-lg font-semibold text-green-700">
🎉
</p>
<p className="text-sm text-green-600">
: {score}% ({correctCount}/{data.questions.length} )
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,172 @@
/**
* Slideshow Renderer
*
* Renders presentation as a slideshow with slide navigation.
*/
import { useState, useEffect, useCallback } from 'react';
import {
ChevronLeft,
ChevronRight,
Maximize2,
Minimize2,
Play,
Pause,
} from 'lucide-react';
import type { SlideshowData } from '../types';
interface SlideshowRendererProps {
data: SlideshowData;
/** Auto-play interval in seconds (0 = disabled) */
autoPlayInterval?: number;
/** Show progress indicator */
showProgress?: boolean;
/** Show speaker notes */
showNotes?: boolean;
/** Custom className */
className?: string;
}
export function SlideshowRenderer({
data,
autoPlayInterval = 0,
showProgress = true,
showNotes = true,
className = '',
}: SlideshowRendererProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const slides = data.slides || [];
const totalSlides = slides.length;
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === ' ') {
handleNext();
} else if (e.key === 'ArrowLeft') {
handlePrev();
} else if (e.key === 'f') {
toggleFullscreen();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// Auto-play
useEffect(() => {
if (isPlaying && autoPlayInterval > 0) {
const timer = setInterval(handleNext, autoPlayInterval * 1000);
return () => clearInterval(timer);
}
}, [isPlaying, autoPlayInterval]);
const handleNext = useCallback(() => {
setCurrentIndex((prev) => (prev + 1) % totalSlides);
}, [totalSlides]);
const handlePrev = useCallback(() => {
setCurrentIndex((prev) => (prev - 1 + totalSlides) % totalSlides);
}, [totalSlides]);
const toggleFullscreen = useCallback(() => {
setIsFullscreen((prev) => !prev);
}, []);
const currentSlide = slides[currentIndex];
if (!currentSlide) {
return (
<div className={`flex items-center justify-center h-64 bg-gray-50 ${className}`}>
<p className="text-gray-500"></p>
</div>
);
}
return (
<div className={`flex flex-col h-full ${isFullscreen ? 'fixed inset-0 z-50 bg-white' : ''} ${className}`}>
{/* Slide Content */}
<div className="flex-1 flex items-center justify-center p-8">
<div className="max-w-4xl w-full">
{/* Title */}
{currentSlide.title && (
<h2 className="text-3xl font-bold text-center mb-6">
{currentSlide.title}
</h2>
)}
{/* Content rendering would go here */}
<div className="text-gray-700">
{/* This is simplified - real implementation would render based on content type */}
{typeof currentSlide.content === 'string' ? (
<p>{currentSlide.content}</p>
) : (
<div>Complex content rendering</div>
)}
</div>
</div>
</div>
{/* Controls */}
<div className="flex items-center justify-between p-4 bg-gray-50 border-t">
<div className="flex items-center gap-2">
<button
onClick={handlePrev}
disabled={totalSlides <= 1}
className="p-2 hover:bg-gray-200 rounded disabled:opacity-50"
>
<ChevronLeft className="w-5 h-5" />
</button>
<button
onClick={() => setIsPlaying(!isPlaying)}
disabled={autoPlayInterval === 0}
className="p-2 hover:bg-gray-200 rounded disabled:opacity-50"
>
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
</button>
<button
onClick={handleNext}
disabled={totalSlides <= 1}
className="p-2 hover:bg-gray-200 rounded disabled:opacity-50"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
{/* Progress */}
{showProgress && (
<div className="text-sm text-gray-500">
{currentIndex + 1} / {totalSlides}
</div>
)}
{/* Fullscreen */}
<button
onClick={toggleFullscreen}
className="p-2 hover:bg-gray-200 rounded"
>
{isFullscreen ? (
<Minimize2 className="w-5 h-5" />
) : (
<Maximize2 className="w-5 h-5" />
)}
</button>
</div>
{/* Speaker Notes */}
{showNotes && currentSlide.notes && (
<div className="p-4 bg-yellow-50 border-t text-sm text-gray-600">
📝 {currentSlide.notes}
</div>
)}
</div>
);
}
export default SlideshowRenderer;

View File

@@ -0,0 +1,145 @@
/**
* Presentation Types
*
* Type definitions for the presentation layer.
* Used by renderers and container components.
*/
export type PresentationType =
| 'chart'
| 'quiz'
| 'slideshow'
| 'document'
| 'whiteboard'
| 'auto';
export interface PresentationAnalysis {
recommendedType: PresentationType;
confidence: number;
detectedFeatures: string[];
metadata?: Record<string, unknown>;
}
export interface ChartData {
type: 'line' | 'bar' | 'pie' | 'scatter' | 'area';
title?: string;
labels?: string[];
datasets: ChartDataset[];
options?: ChartOptions;
}
export interface ChartDataset {
label: string;
data: number[];
backgroundColor?: string | string[];
borderColor?: string | string[];
fill?: boolean;
}
export interface ChartOptions {
responsive?: boolean;
maintainAspectRatio?: boolean;
plugins?: {
legend?: {
display?: boolean;
position?: 'top' | 'bottom' | 'left' | 'right';
};
title?: {
display?: boolean;
text?: string;
};
};
scales?: Record<string, unknown>;
}
export interface QuizData {
title?: string;
description?: string;
questions: QuizQuestion[];
timeLimit?: number;
passingScore?: number;
}
export interface QuizQuestion {
id: string;
text: string;
questionType: QuestionType;
options: QuizOption[];
correctAnswer: string | string[];
hint?: string;
explanation?: string;
points?: number;
}
export type QuestionType =
| 'singleChoice'
| 'multipleChoice'
| 'trueFalse'
| 'fillBlank'
| 'shortAnswer';
export interface QuizOption {
id: string;
text: string;
isCorrect?: boolean;
}
export interface SlideshowData {
title?: string;
slides: Slide[];
theme?: SlideshowTheme;
autoPlay?: boolean;
interval?: number;
}
export interface Slide {
id: string;
type: 'title' | 'content' | 'image' | 'code' | 'twoColumn';
title?: string;
content?: string;
image?: string;
code?: string;
language?: string;
leftContent?: string;
rightContent?: string;
notes?: string;
}
export interface SlideshowTheme {
backgroundColor?: string;
textColor?: string;
accentColor?: string;
fontFamily?: string;
}
export interface DocumentData {
title?: string;
content?: string;
format?: 'markdown' | 'html' | 'plain';
downloadUrl?: string;
downloadFilename?: string;
url?: string;
}
export interface WhiteboardData {
title?: string;
elements: WhiteboardElement[];
background?: string;
gridSize?: number;
}
export interface WhiteboardElement {
id: string;
type: 'rect' | 'circle' | 'line' | 'text' | 'image' | 'path';
x: number;
y: number;
width?: number;
height?: number;
fill?: string;
stroke?: string;
strokeWidth?: number;
text?: string;
fontSize?: number;
src?: string;
points?: number[];
}