- Add PresentationMode component for fullscreen slide viewing - Support keyboard navigation: arrows/space for slides, escape to exit - Integrate with existing theme rendering and CSS loading system - Update PresentationsList Present button to use fullscreen mode - Add new route /presentations/:presentationId/present - Fix TypeScript errors in presentation components 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
238 lines
7.4 KiB
TypeScript
238 lines
7.4 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import type { Presentation, SlideContent } from '../../types/presentation.ts';
|
|
import type { Theme } from '../../types/theme.ts';
|
|
import { getPresentationById } from '../../utils/presentationStorage.ts';
|
|
import { getTheme } from '../../themes/index.ts';
|
|
import { renderTemplateWithSampleData } from '../../utils/templateRenderer.ts';
|
|
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts';
|
|
import { loggers } from '../../utils/logger.ts';
|
|
import './PresentationMode.css';
|
|
|
|
export const PresentationMode: React.FC = () => {
|
|
const { presentationId } = useParams<{ presentationId: string }>();
|
|
const navigate = useNavigate();
|
|
|
|
const [presentation, setPresentation] = useState<Presentation | null>(null);
|
|
const [theme, setTheme] = useState<Theme | null>(null);
|
|
const [currentSlideIndex, setCurrentSlideIndex] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Keyboard navigation handler
|
|
const handleKeyPress = useCallback((event: KeyboardEvent) => {
|
|
if (!presentation || presentation.slides.length === 0) return;
|
|
|
|
switch (event.key) {
|
|
case 'ArrowRight':
|
|
case 'Space':
|
|
case 'Enter':
|
|
event.preventDefault();
|
|
setCurrentSlideIndex(prev =>
|
|
Math.min(prev + 1, presentation.slides.length - 1)
|
|
);
|
|
break;
|
|
|
|
case 'ArrowLeft':
|
|
event.preventDefault();
|
|
setCurrentSlideIndex(prev => Math.max(prev - 1, 0));
|
|
break;
|
|
|
|
case 'Home':
|
|
event.preventDefault();
|
|
setCurrentSlideIndex(0);
|
|
break;
|
|
|
|
case 'End':
|
|
event.preventDefault();
|
|
setCurrentSlideIndex(presentation.slides.length - 1);
|
|
break;
|
|
|
|
case 'Escape':
|
|
event.preventDefault();
|
|
exitPresentationMode();
|
|
break;
|
|
|
|
default:
|
|
// Handle number keys for direct slide navigation
|
|
const slideNumber = parseInt(event.key);
|
|
if (slideNumber >= 1 && slideNumber <= presentation.slides.length) {
|
|
event.preventDefault();
|
|
setCurrentSlideIndex(slideNumber - 1);
|
|
}
|
|
break;
|
|
}
|
|
}, [presentation, navigate, presentationId]);
|
|
|
|
// Set up keyboard listeners
|
|
useEffect(() => {
|
|
document.addEventListener('keydown', handleKeyPress);
|
|
return () => {
|
|
document.removeEventListener('keydown', handleKeyPress);
|
|
};
|
|
}, [handleKeyPress]);
|
|
|
|
// Load presentation and theme
|
|
useEffect(() => {
|
|
const loadPresentationAndTheme = async () => {
|
|
if (!presentationId) {
|
|
setError('No presentation ID provided');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
|
|
// Load presentation
|
|
const presentationData = await getPresentationById(presentationId);
|
|
if (!presentationData) {
|
|
setError(`Presentation not found: ${presentationId}`);
|
|
return;
|
|
}
|
|
|
|
setPresentation(presentationData);
|
|
|
|
// Load theme
|
|
const themeData = await getTheme(presentationData.metadata.theme);
|
|
if (!themeData) {
|
|
setError(`Theme not found: ${presentationData.metadata.theme}`);
|
|
return;
|
|
}
|
|
|
|
setTheme(themeData);
|
|
|
|
// Load theme CSS
|
|
const cssLink = document.getElementById('presentation-theme-css') as HTMLLinkElement;
|
|
const cssPath = `${themeData.basePath}/${themeData.cssFile}`;
|
|
if (cssLink) {
|
|
cssLink.href = cssPath;
|
|
} else {
|
|
const newCssLink = document.createElement('link');
|
|
newCssLink.id = 'presentation-theme-css';
|
|
newCssLink.rel = 'stylesheet';
|
|
newCssLink.href = cssPath;
|
|
document.head.appendChild(newCssLink);
|
|
}
|
|
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load presentation');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadPresentationAndTheme();
|
|
|
|
// Cleanup theme CSS on unmount
|
|
return () => {
|
|
const cssLink = document.getElementById('presentation-theme-css');
|
|
if (cssLink) {
|
|
cssLink.remove();
|
|
}
|
|
};
|
|
}, [presentationId]);
|
|
|
|
const exitPresentationMode = () => {
|
|
navigate(`/presentations/${presentationId}/view/slides/${currentSlideIndex + 1}`);
|
|
};
|
|
|
|
const renderSlideContent = (slide: SlideContent): string => {
|
|
if (!theme) return '';
|
|
|
|
const layout = theme.layouts.find(l => l.id === slide.layoutId);
|
|
if (!layout) {
|
|
loggers.ui.warn(`Layout ${slide.layoutId} not found in theme ${theme.name}`);
|
|
return '<div class="error">Layout not found</div>';
|
|
}
|
|
|
|
let renderedTemplate = layout.htmlTemplate;
|
|
|
|
// Replace template variables with slide content
|
|
Object.entries(slide.content).forEach(([slotId, content]) => {
|
|
const regex = new RegExp(`\\{\\{${slotId}\\}\\}`, 'g');
|
|
renderedTemplate = renderedTemplate.replace(regex, content);
|
|
});
|
|
|
|
// Handle conditional blocks and clean up remaining variables
|
|
renderedTemplate = renderTemplateWithSampleData(renderedTemplate, layout);
|
|
|
|
return sanitizeSlideTemplate(renderedTemplate);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="presentation-mode loading">
|
|
<div className="loading-spinner">Loading presentation...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="presentation-mode error">
|
|
<div className="error-content">
|
|
<h2>Error Loading Presentation</h2>
|
|
<p>{error}</p>
|
|
<button onClick={exitPresentationMode} className="exit-button">
|
|
Exit Presentation Mode
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!presentation || !theme) {
|
|
return (
|
|
<div className="presentation-mode error">
|
|
<div className="error-content">
|
|
<h2>Presentation Not Found</h2>
|
|
<button onClick={exitPresentationMode} className="exit-button">
|
|
Exit Presentation Mode
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (presentation.slides.length === 0) {
|
|
return (
|
|
<div className="presentation-mode error">
|
|
<div className="error-content">
|
|
<h2>No Slides Available</h2>
|
|
<p>This presentation is empty.</p>
|
|
<button onClick={exitPresentationMode} className="exit-button">
|
|
Exit Presentation Mode
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const currentSlide = presentation.slides[currentSlideIndex];
|
|
const totalSlides = presentation.slides.length;
|
|
const renderedSlideContent = renderSlideContent(currentSlide);
|
|
|
|
return (
|
|
<div className="presentation-mode fullscreen">
|
|
<div className="slide-container">
|
|
<div
|
|
className={`slide-content ${presentation.metadata.aspectRatio ? `aspect-${presentation.metadata.aspectRatio.replace(':', '-')}` : 'aspect-16-9'}`}
|
|
dangerouslySetInnerHTML={{ __html: renderedSlideContent }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Navigation indicator */}
|
|
<div className="navigation-indicator">
|
|
<span className="slide-counter">
|
|
{currentSlideIndex + 1} / {totalSlides}
|
|
</span>
|
|
|
|
<div className="navigation-hints">
|
|
<span>← → Space: Navigate</span>
|
|
<span>Esc: Exit</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}; |