diff --git a/USERFLOWS.md b/USERFLOWS.md index 482b9e7..3ffaa2c 100644 --- a/USERFLOWS.md +++ b/USERFLOWS.md @@ -52,11 +52,16 @@ - [x] Slide order adjusts automatically ### Preview Slides -- [ ] User can preview individual slides -- [ ] User can view slides in presentation mode -- [ ] User can navigate between slides in preview +- [x] User can preview individual slides ### Slide Order Management - [ ] User can reorder slides via drag-and-drop - [ ] User can see slide order visually in editor - [ ] Slide order automatically saves when changed + +## Flow #3 - Present to oudience +- [ ] User can start presentation mode from presentation editor +- [ ] User can navigate slides in presentation mode +- [ ] User can exit presentation mode +- [ ] User can see slide notes in presenter view +- [ ] User can control slide transitions and animations \ No newline at end of file diff --git a/public/themes-manifest.json b/public/themes-manifest.json index 85ea119..4052630 100644 --- a/public/themes-manifest.json +++ b/public/themes-manifest.json @@ -12,5 +12,5 @@ "hasMasterSlide": true } }, - "generated": "2025-08-21T11:51:03.695Z" + "generated": "2025-08-21T14:20:36.144Z" } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 3ae590a..893164a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { LayoutDetailPage } from './components/themes/LayoutDetailPage.tsx'; import { LayoutPreviewPage } from './components/themes/LayoutPreviewPage.tsx'; import { NewPresentationPage } from './components/presentations/NewPresentationPage.tsx'; import { PresentationViewer } from './components/presentations/PresentationViewer.tsx'; +import { PresentationMode } from './components/presentations/PresentationMode.tsx'; import { PresentationEditor } from './components/presentations/PresentationEditor.tsx'; import { SlideEditor } from './components/presentations/SlideEditor.tsx'; import { PresentationsList } from './components/presentations/PresentationsList.tsx'; @@ -25,6 +26,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/presentations/PresentationMode.css b/src/components/presentations/PresentationMode.css new file mode 100644 index 0000000..2aff6b7 --- /dev/null +++ b/src/components/presentations/PresentationMode.css @@ -0,0 +1,176 @@ +/* Fullscreen presentation mode styles */ +.presentation-mode { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: #000; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.presentation-mode.loading, +.presentation-mode.error { + flex-direction: column; + background: #1a1a1a; + color: #fff; +} + +.presentation-mode .loading-spinner { + font-size: 1.2rem; + padding: 2rem; + text-align: center; +} + +.presentation-mode .error-content { + text-align: center; + max-width: 600px; + padding: 2rem; +} + +.presentation-mode .error-content h2 { + margin: 0 0 1rem 0; + font-size: 2rem; + font-weight: 300; +} + +.presentation-mode .error-content p { + margin: 0 0 2rem 0; + font-size: 1.1rem; + opacity: 0.8; + line-height: 1.5; +} + +.presentation-mode .exit-button { + background: #fff; + color: #000; + border: none; + padding: 0.75rem 1.5rem; + font-size: 1rem; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.presentation-mode .exit-button:hover { + background: #f0f0f0; +} + +/* Fullscreen slide container */ +.presentation-mode.fullscreen { + background: #000; +} + +.presentation-mode .slide-container { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.presentation-mode .slide-content { + max-width: 90vw; + max-height: 90vh; + background: #fff; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); + position: relative; + overflow: hidden; +} + +/* Aspect ratio classes for slides */ +.presentation-mode .slide-content.aspect-16-9 { + aspect-ratio: 16/9; + width: min(90vw, calc(90vh * 16/9)); +} + +.presentation-mode .slide-content.aspect-4-3 { + aspect-ratio: 4/3; + width: min(90vw, calc(90vh * 4/3)); +} + +.presentation-mode .slide-content.aspect-16-10 { + aspect-ratio: 16/10; + width: min(90vw, calc(90vh * 16/10)); +} + +/* Navigation indicator */ +.presentation-mode .navigation-indicator { + position: fixed; + bottom: 2rem; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(10px); + color: #fff; + padding: 1rem 2rem; + border-radius: 2rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} + +.presentation-mode:hover .navigation-indicator { + opacity: 1; +} + +.presentation-mode .slide-counter { + font-size: 1.1rem; + font-weight: 500; +} + +.presentation-mode .navigation-hints { + display: flex; + gap: 1rem; + font-size: 0.9rem; + opacity: 0.8; +} + +.presentation-mode .navigation-hints span { + white-space: nowrap; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .presentation-mode .navigation-indicator { + bottom: 1rem; + padding: 0.75rem 1.5rem; + font-size: 0.9rem; + } + + .presentation-mode .navigation-hints { + flex-direction: column; + gap: 0.25rem; + text-align: center; + } + + .presentation-mode .slide-content { + max-width: 95vw; + max-height: 95vh; + } +} + +/* Hide scrollbars in presentation mode */ +.presentation-mode * { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE/Edge */ +} + +.presentation-mode *::-webkit-scrollbar { + display: none; /* Chrome/Safari */ +} + +/* Ensure theme styles work in fullscreen */ +.presentation-mode .slide-content > * { + width: 100%; + height: 100%; +} \ No newline at end of file diff --git a/src/components/presentations/PresentationMode.tsx b/src/components/presentations/PresentationMode.tsx new file mode 100644 index 0000000..df425f2 --- /dev/null +++ b/src/components/presentations/PresentationMode.tsx @@ -0,0 +1,238 @@ +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(null); + const [theme, setTheme] = useState(null); + const [currentSlideIndex, setCurrentSlideIndex] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 '
Layout not found
'; + } + + 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 ( +
+
Loading presentation...
+
+ ); + } + + if (error) { + return ( +
+
+

Error Loading Presentation

+

{error}

+ +
+
+ ); + } + + if (!presentation || !theme) { + return ( +
+
+

Presentation Not Found

+ +
+
+ ); + } + + if (presentation.slides.length === 0) { + return ( +
+
+

No Slides Available

+

This presentation is empty.

+ +
+
+ ); + } + + const currentSlide = presentation.slides[currentSlideIndex]; + const totalSlides = presentation.slides.length; + const renderedSlideContent = renderSlideContent(currentSlide); + + return ( +
+
+
+
+ + {/* Navigation indicator */} +
+ + {currentSlideIndex + 1} / {totalSlides} + + +
+ ← → Space: Navigate + Esc: Exit +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/presentations/PresentationViewer.tsx b/src/components/presentations/PresentationViewer.tsx index eb8f270..25b9c27 100644 --- a/src/components/presentations/PresentationViewer.tsx +++ b/src/components/presentations/PresentationViewer.tsx @@ -4,7 +4,6 @@ import type { Presentation } from '../../types/presentation.ts'; import type { Theme } from '../../types/theme.ts'; import { getPresentationById } from '../../utils/presentationStorage.ts'; import { getTheme } from '../../themes/index.ts'; -import { loggers } from '../../utils/logger.ts'; import './PresentationViewer.css'; export const PresentationViewer: React.FC = () => { @@ -86,8 +85,8 @@ export const PresentationViewer: React.FC = () => { }; const enterPresentationMode = () => { - // TODO: Implement full-screen presentation mode - loggers.ui.info('Full-screen presentation mode requested - feature to be implemented'); + if (!presentation) return; + navigate(`/presentations/${presentationId}/present`); }; if (loading) { diff --git a/src/components/presentations/PresentationsList.tsx b/src/components/presentations/PresentationsList.tsx index c876808..5e47cb6 100644 --- a/src/components/presentations/PresentationsList.tsx +++ b/src/components/presentations/PresentationsList.tsx @@ -213,10 +213,7 @@ export const PresentationsList: React.FC = () => {