Compare commits

...

6 Commits

Author SHA1 Message Date
4ce9f225a6 Implement slide deletion and duplication functionality
- Add complete slide deletion with smart confirmation messages
- Implement slide duplication that copies layout, content, and notes
- Handle edge cases for navigation after deletion/duplication
- Add proper slide order management and renumbering
- Include comprehensive error handling and user feedback
- Support deleting last slide, only slide, and middle slides
- Navigate intelligently after operations (to duplicated slide, adjusted position after deletion)
- Add improved confirmation dialogs with context-aware messaging
- Integrate with existing presentation storage and state management
- Replace placeholder TODO implementations with full functionality

Features completed:
 User can delete slides from presentation
 User gets confirmation before slide deletion
 Slide order adjusts automatically
 User can duplicate existing slides copying layout and content
 Smart navigation maintains user context after operations
 Robust error handling with proper user feedback

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-20 17:32:06 -05:00
79060f0945 Update USERFLOWS.md to reflect completed features and design changes
- Mark aspect ratio selection as completed
- Mark live preview and slide saving as completed
- Mark slide editing features as completed
- Update slide editing flow to reflect layout cannot be changed once saved
- Add completed item for obvious exit/cancel editing option
- Document that changes auto-save to presentation

All core slide editing and presentation management features are now implemented
and working according to the user flow specifications.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-20 17:26:18 -05:00
7b262f398c Simplify slide editor by removing layout change feature and add obvious edit buttons
- Remove "Change Layout" buttons from slide editor header and content actions
- Simplify user flow: layout selection only happens once when creating new slides
- Existing slides go directly to content editing, skipping layout selection
- Add prominent edit buttons to slide thumbnails and main slide view
- Style edit buttons with blue theme to make them obvious primary actions
- Add "Cancel editing" link styled as underlined text link for clear visual distinction
- Improve responsive design for mobile with proper button spacing
- Lock in layout choice after selection to prevent confusion and content loss

Benefits:
- Cleaner, more focused editing interface
- Prevents accidental layout changes that could break content formatting
- Clear visual hierarchy with obvious edit and cancel options
- Faster editing workflow for existing slides

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-20 17:25:33 -05:00
2a905d50e0 Enhance default theme with proper content centering and 2-content-blocks layout
- Update default theme CSS for proper content centering and aspect ratio integration
- Add text-align: center to base slide styling for consistent centering
- Implement specific alignment for title, subtitle, and text slots
- Add aspect ratio specific adjustments (different padding and max-width per ratio)
- Create new 2-content-blocks layout with side-by-side equal spacing
- Use CSS Grid for 50/50 split with proper gap and visual separation
- Add responsive design for mobile devices (stacks vertically)
- Include subtle styling to distinguish content blocks
- Improve typography scaling for two-column layout

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-20 17:15:03 -05:00
92672f77e3 feat: implement absolute positioning for slide editor with viewport-aware layout
## Major Improvements:

### Absolute Positioning System
- Implemented fixed positioning for slide editor to break out of DOM flow
- Editor uses full viewport (top: 80px; left: 0; right: 0; bottom: 0)
- Fixed header at top with z-index layering for proper overlap
- Background overlay to separate from underlying content

### Viewport-Optimized Layout
- True 50/50 split using full available viewport width
- Height calculations based on actual viewport (100vh) not container constraints
- Responsive top positioning (80px desktop, 60px tablet, 50px mobile)
- No scrolling issues - content always fits within viewport boundaries

### Enhanced Live Preview
- Applied presentation aspect ratio classes to preview container
- Dynamic aspect ratio detection (16:9, 4:3, 16:10)
- Viewport-aware sizing with proper aspect ratio preservation
- Added aspect ratio indicator in preview meta information

### Improved User Experience
- Compact header design with reduced padding and font sizes
- Internal scrolling for content fields while keeping actions visible
- Better space utilization with reduced margins and gaps
- Consistent behavior across all screen sizes and orientations

### Technical Enhancements
- Z-index management for proper layering (header: 20, content: 10)
- Flexbox structure for optimal space distribution
- Overflow handling for different content types
- Mobile-responsive design with appropriate scaling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-20 16:54:27 -05:00
9b0b16969f feat: implement complete presentation management system with aspect ratio support
## Major Features Added:

### Presentation Management
- Complete CRUD operations for presentations (create, read, update, delete)
- IndexedDB storage for offline presentation data
- Comprehensive presentation list view with metadata
- Navigation integration with header menu

### Slide Management
- Full slide editor with layout selection and content editing
- Live preview with theme styling applied
- Speaker notes functionality
- Enhanced layout previews with realistic sample content
- Themed layout selection with proper CSS inheritance

### Aspect Ratio System
- Support for 3 common presentation formats: 16:9, 4:3, 16:10
- Global CSS system baked into theme engine
- Visual aspect ratio selection in presentation creation
- Responsive scaling for different viewing contexts
- Print-optimized styling with proper dimensions

### User Experience Improvements
- Enhanced sample content generation for realistic previews
- Improved navigation with presentation management
- Better form styling and user interaction
- Comprehensive error handling and loading states
- Mobile-responsive design throughout

### Technical Infrastructure
- Complete TypeScript type system for presentations
- Modular component architecture
- CSS aspect ratio classes for theme consistency
- Enhanced template rendering with live updates
- Robust storage utilities with proper error handling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-20 16:34:00 -05:00
25 changed files with 5521 additions and 31 deletions

62
USERFLOWS.md Normal file
View File

@ -0,0 +1,62 @@
# User Flows I want the solution to support
## Flow #1 - Presentation Management
**Adding, removing, and editing existing presentations**
### Create New Presentation
- [x] User navigates to create new presentation
- [x] User enters presentation details (title, description)
- [x] User selects aspect ratio (16:9, 4:3, 16:10) for presentation
- [x] User selects a theme from available options
- [x] User creates presentation and is taken to editor
### View All Presentations
- [x] User can view list of all saved presentations
- [x] User can see presentation metadata (name, description, theme, slide count)
- [x] User can access presentations from navigation
### Edit Existing Presentation
- [x] User can open existing presentation for editing
- [x] User can navigate between slides in editor
- [x] User can access presentation settings and metadata
### Delete Presentation
- [x] User can delete presentation from list view
- [x] User gets confirmation dialog before deletion
- [x] Presentation is removed from storage
## Flow #2 - Slide Management
**Adding, removing, and editing individual slides within presentations**
### Add New Slide
- [x] User clicks "Add Slide" from presentation editor
- [x] User can select layout for new slide (with themed previews)
- [x] User can add content to slide slots (text, images)
- [x] User can add presentation notes to slide
- [x] User can see miniature preview of slide live while editing
- [x] User can save slide (auto-saves presentation)
- [x] User can duplicate an existing slide copying it's layout and content.
### Edit Existing Slide
- [x] User can click on existing slide to edit
- [x] User can modify slide content in all slots
- [x] User cannot change slide layout once saved
- [x] User can exit slide editing mode without saving changes in an obvious way
- [x] User can edit presentation notes
- [x] Changes auto-save to presentation
- [ ] User can edit slide content without preview if desired by clicking inside content slot areas
### Remove Slide
- [ ] User can delete slides from presentation
- [ ] User gets confirmation before slide deletion
- [ ] 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
### 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

View File

@ -4,6 +4,7 @@
"id": "default",
"cssFile": "style.css",
"layouts": [
"2-content-blocks",
"content-slide",
"image-slide",
"title-slide"
@ -11,5 +12,5 @@
"hasMasterSlide": true
}
},
"generated": "2025-08-20T18:50:35.588Z"
"generated": "2025-08-20T22:06:06.798Z"
}

View File

@ -0,0 +1,13 @@
<div class="slide layout-2-content-blocks">
<h1 class="slot title-slot" data-slot="title" data-placeholder="Slide Title" data-required>
{{title}}
</h1>
<div class="content-blocks-container">
<div class="slot content-slot" data-slot="content1" data-placeholder="First content block" data-required>
{{content1}}
</div>
<div class="slot content-slot" data-slot="content2" data-placeholder="Second content block" data-required>
{{content2}}
</div>
</div>
</div>

View File

@ -17,15 +17,15 @@
--theme-font-body: 'Inter', system-ui, sans-serif;
--theme-font-code: 'JetBrains Mono', 'Consolas', monospace;
--slide-width: 100vw;
--slide-height: 100vh;
--slide-padding: 2rem;
--slide-padding: 5%;
--content-max-width: 90%;
}
/* Base slide container */
/* Base slide styling - works with global .slide-container classes */
.slide-container .slide-content,
.slide {
width: var(--slide-width);
height: var(--slide-height);
width: 100%;
height: 100%;
background: var(--theme-background);
color: var(--theme-text);
font-family: var(--theme-font-body);
@ -35,6 +35,10 @@
flex-direction: column;
position: relative;
overflow: hidden;
justify-content: center;
align-items: center;
/* Ensure content is properly centered within container */
text-align: center;
}
/* Master slide elements */
@ -59,6 +63,11 @@
border: 2px dashed transparent;
min-height: 2rem;
transition: border-color 0.2s ease;
width: 100%;
max-width: var(--content-max-width);
margin: 0 auto;
/* Ensure slot content inherits proper centering */
text-align: inherit;
}
.slot:hover,
@ -86,16 +95,23 @@
font-family: var(--theme-font-heading);
font-weight: bold;
line-height: 1.2;
text-align: center;
}
.slot[data-type="subtitle"] {
font-family: var(--theme-font-heading);
line-height: 1.4;
opacity: 0.8;
text-align: center;
}
.content-slot {
color: var(--theme-text-secondary);
background-color: var(--theme-background);
}
.slot[data-type="text"] {
line-height: 1.6;
/* Text content can be left-aligned for readability */
text-align: left;
}
/* Image slots */
@ -117,62 +133,176 @@
/* Layout-specific styles */
/* Title slide layout */
.layout-title-slide {
.layout-title-slide,
.slide-container .layout-title-slide {
justify-content: center;
align-items: center;
text-align: center;
}
.layout-title-slide .slot[data-slot="title"] {
font-size: clamp(2rem, 5vw, 4rem);
font-size: clamp(2rem, 8vw, 4rem);
margin-bottom: 2rem;
width: 80%;
width: 100%;
max-width: 80%;
color: var(--theme-primary);
text-align: center;
}
.layout-title-slide .slot[data-slot="subtitle"] {
font-size: clamp(1rem, 2.5vw, 2rem);
font-size: clamp(1rem, 4vw, 2rem);
color: var(--theme-text-secondary);
width: 100%;
max-width: 80%;
text-align: center;
}
/* Content slide layout */
.layout-content-slide,
.slide-container .layout-content-slide {
justify-content: flex-start;
align-items: stretch;
/* Reset text alignment for content slides */
text-align: initial;
}
/* Content slide layout */
.layout-content-slide .slot[data-slot="title"] {
font-size: clamp(1.5rem, 3vw, 2.5rem);
font-size: clamp(1.5rem, 6vw, 2.5rem);
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--theme-primary);
text-align: center;
width: 100%;
}
.layout-content-slide .slot[data-slot="subtitle"] {
font-size: clamp(1rem, 2vw, .5rem);
text-align: right;
width: 50%;
}
.layout-content-slide .slot[data-slot="content"] {
font-size: clamp(1rem, 1.5vw, 1.25rem);
font-size: clamp(1rem, 2.5vw, 1.25rem);
flex: 1;
text-align: left;
width: 100%;
line-height: 1.6;
/* Ensure content doesn't get too wide */
max-width: 100%;
margin: 0 auto;
}
/* Two content blocks layout */
.layout-2-content-blocks,
.slide-container .layout-2-content-blocks {
justify-content: flex-start;
align-items: stretch;
/* Reset text alignment for two-column slides */
text-align: initial;
}
.layout-2-content-blocks .slot[data-slot="title"] {
font-size: clamp(1.5rem, 5vw, 2.5rem);
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--theme-primary);
text-align: center;
width: 100%;
}
.content-blocks-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
flex: 1;
align-items: stretch;
width: 100%;
}
.layout-2-content-blocks .slot[data-slot="content1"],
.layout-2-content-blocks .slot[data-slot="content2"] {
font-size: clamp(0.9rem, 2.2vw, 1.1rem);
text-align: left;
width: 100%;
line-height: 1.6;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Image slide layout */
.layout-image-slide,
.slide-container .layout-image-slide {
justify-content: flex-start;
align-items: stretch;
}
.layout-image-slide .slot[data-slot="title"] {
font-size: clamp(1.5rem, 3vw, 2.5rem);
font-size: clamp(1.5rem, 6vw, 2.5rem);
margin-bottom: 2rem;
text-align: center;
width: 100%;
}
.layout-image-slide .slot[data-slot="image"] {
flex: 1;
margin-top: 1rem;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
/* Aspect ratio specific adjustments */
.slide-container.aspect-16-9 .slide-content,
.slide-container.aspect-16-9 .slide {
--slide-padding: 4%;
--content-max-width: 85%;
}
.slide-container.aspect-4-3 .slide-content,
.slide-container.aspect-4-3 .slide {
--slide-padding: 6%;
--content-max-width: 80%;
}
.slide-container.aspect-16-10 .slide-content,
.slide-container.aspect-16-10 .slide {
--slide-padding: 5%;
--content-max-width: 85%;
}
/* Responsive adjustments */
@media (max-width: 768px) {
:root {
--slide-padding: 1rem;
--slide-padding: 3%;
--content-max-width: 95%;
}
.layout-title-slide .slot[data-slot="title"] {
margin-bottom: 1rem;
font-size: clamp(1.5rem, 6vw, 3rem);
}
.layout-content-slide .slot[data-slot="title"] {
margin-bottom: 1rem;
font-size: clamp(1.2rem, 5vw, 2rem);
}
.layout-image-slide .slot[data-slot="title"] {
font-size: clamp(1.2rem, 5vw, 2rem);
}
/* Two content blocks responsive adjustments */
.content-blocks-container {
grid-template-columns: 1fr;
gap: 1rem;
}
.layout-2-content-blocks .slot[data-slot="content1"],
.layout-2-content-blocks .slot[data-slot="content2"] {
font-size: clamp(0.8rem, 2vw, 1rem);
padding: 0.75rem;
}
}
@ -191,4 +321,4 @@
.slot.empty::before {
display: none;
}
}/* Test change Wed Aug 20 13:55:27 CDT 2025 */
}

View File

@ -1,3 +1,6 @@
/* Import aspect ratio system for theme engine */
@import './styles/aspectRatios.css';
/* App Layout */
.app-root {
width: 100%;
@ -9,14 +12,75 @@
}
.app-header {
padding: 2rem;
text-align: center;
border-bottom: 1px solid #e5e7eb;
background: #ffffff;
width: 100%;
box-sizing: border-box;
}
.app-nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
border-bottom: 1px solid #f1f5f9;
}
.app-logo {
font-size: 1.25rem;
font-weight: 700;
color: #1e293b;
text-decoration: none;
transition: color 0.2s ease;
}
.app-logo:hover {
color: #3b82f6;
}
.nav-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.nav-button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
font-size: 0.875rem;
text-decoration: none;
transition: all 0.2s ease;
border: none;
cursor: pointer;
}
.nav-button.primary {
background: #3b82f6;
color: white;
}
.nav-button.primary:hover {
background: #2563eb;
}
.nav-link {
color: #64748b;
text-decoration: none;
font-weight: 500;
font-size: 0.875rem;
transition: color 0.2s ease;
}
.nav-link:hover {
color: #334155;
}
.page-title-section {
padding: 2rem;
text-align: center;
}
.app-header h1 {
margin: 0;
color: #1f2937;
@ -80,7 +144,18 @@
gap: 0.75rem;
}
.app-header {
.app-nav {
padding: 1rem;
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.nav-actions {
justify-content: center;
}
.page-title-section {
padding: 1.5rem 1rem;
}

View File

@ -1,5 +1,6 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import { ThemeBrowser, ThemeDetailPage, LayoutDetailPage, LayoutPreviewPage } from './components/themes'
import { NewPresentationPage, PresentationViewer, PresentationEditor, SlideEditor, PresentationsList } from './components/presentations'
import { AppHeader } from './components/AppHeader'
import { Welcome } from './components/Welcome'
import './App.css'
@ -13,6 +14,11 @@ function App() {
<main className="app-main">
<Routes>
<Route path="/" element={<Welcome />} />
<Route path="/presentations" element={<PresentationsList />} />
<Route path="/presentations/new" element={<NewPresentationPage />} />
<Route path="/presentations/:presentationId/edit/slides/:slideNumber" element={<PresentationEditor />} />
<Route path="/presentations/:presentationId/view/slides/:slideNumber" element={<PresentationViewer />} />
<Route path="/presentations/:presentationId/slide/:slideId/edit" element={<SlideEditor />} />
<Route path="/themes" element={<ThemeBrowser />} />
<Route path="/themes/:themeId" element={<ThemeDetailPage />} />
<Route path="/themes/:themeId/layouts/:layoutId" element={<LayoutDetailPage />} />

View File

@ -8,6 +8,34 @@ export const AppHeader: React.FC = () => {
if (location.pathname === '/') {
return 'Welcome to Slideshare';
}
if (location.pathname === '/presentations') {
return 'My Presentations';
}
if (location.pathname === '/presentations/new') {
return 'Create New Presentation';
}
if (location.pathname.includes('/slide/') && location.pathname.includes('/edit')) {
const segments = location.pathname.split('/');
const slideId = segments[4];
if (slideId === 'new') {
return 'Add New Slide';
} else {
return 'Edit Slide';
}
}
if (location.pathname.includes('/presentations/') && location.pathname.includes('/slides/')) {
const segments = location.pathname.split('/');
if (segments.length === 6 && segments[1] === 'presentations') {
const mode = segments[3]; // 'edit' or 'view'
const slideNumber = segments[5];
if (mode === 'edit') {
return `Editing Slide ${slideNumber}`;
} else if (mode === 'view') {
return `Viewing Slide ${slideNumber}`;
}
return `Presentation Slide ${slideNumber}`;
}
}
if (location.pathname === '/themes') {
return 'Theme Browser';
}
@ -27,6 +55,33 @@ export const AppHeader: React.FC = () => {
if (location.pathname === '/') {
return 'Create beautiful presentations with customizable themes';
}
if (location.pathname === '/presentations') {
return 'View and manage all your presentations';
}
if (location.pathname === '/presentations/new') {
return 'Select a theme and enter details for your new presentation';
}
if (location.pathname.includes('/slide/') && location.pathname.includes('/edit')) {
const segments = location.pathname.split('/');
const slideId = segments[4];
if (slideId === 'new') {
return 'Choose a layout and add content for your new slide';
} else {
return 'Edit slide content and layout';
}
}
if (location.pathname.includes('/presentations/') && location.pathname.includes('/slides/')) {
const segments = location.pathname.split('/');
if (segments.length === 6 && segments[1] === 'presentations') {
const mode = segments[3];
if (mode === 'edit') {
return 'Edit slide content, add notes, and manage your presentation';
} else if (mode === 'view') {
return 'View your presentation slides in read-only mode';
}
}
return 'View and manage your presentation slides';
}
if (location.pathname === '/themes') {
return 'Browse and select themes for your presentations';
}
@ -46,11 +101,19 @@ export const AppHeader: React.FC = () => {
<header className="app-header">
<nav className="app-nav">
<Link to="/" className="app-logo">
Home
</Link>
<Link to="/themes" className="app-logo">
Slideshare
</Link>
<div className="nav-actions">
<Link to="/presentations" className="nav-link">
My Presentations
</Link>
<Link to="/presentations/new" className="nav-button primary">
Create Presentation
</Link>
<Link to="/themes" className="nav-link">
Themes
</Link>
</div>
</nav>
<div className="page-title-section">
<h1 className="page-title">{getPageTitle()}</h1>

View File

@ -10,7 +10,10 @@ export const Welcome: React.FC = () => {
Create beautiful presentations with customizable themes and layouts
</p>
<div className="hero-actions">
<Link to="/themes" className="primary-button">
<Link to="/presentations/new" className="primary-button">
Create Presentation
</Link>
<Link to="/themes" className="secondary-button">
Browse Themes
</Link>
</div>
@ -114,8 +117,8 @@ export const Welcome: React.FC = () => {
<p className="cta-description">
Start building your presentation with our theme collection
</p>
<Link to="/themes" className="primary-button">
Explore Themes
<Link to="/presentations/new" className="primary-button">
Create Your First Presentation
</Link>
</div>
</section>

View File

@ -0,0 +1,382 @@
.new-presentation-page {
min-height: 100vh;
background: #f8fafc;
}
.page-header {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 1.5rem 2rem;
display: flex;
align-items: center;
gap: 1.5rem;
}
.back-button {
background: none;
border: none;
color: #64748b;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
transition: all 0.2s ease;
}
.back-button:hover {
background: #f1f5f9;
color: #334155;
}
.header-content h1 {
margin: 0;
font-size: 1.875rem;
font-weight: 700;
color: #1e293b;
}
.header-content p {
margin: 0.25rem 0 0 0;
color: #64748b;
font-size: 0.875rem;
}
.page-content {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.creation-form {
display: flex;
flex-direction: column;
gap: 3rem;
}
/* Presentation Details Section */
.presentation-details {
background: white;
border-radius: 0.75rem;
padding: 2rem;
border: 1px solid #e2e8f0;
}
.presentation-details h2 {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: #1e293b;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
font-size: 0.875rem;
}
.form-input,
.form-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
background: white;
color: #374151;
box-sizing: border-box;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-textarea {
resize: vertical;
min-height: 80px;
font-family: inherit;
}
/* Theme Selection Section */
.theme-selection {
background: white;
border-radius: 0.75rem;
padding: 2rem;
border: 1px solid #e2e8f0;
}
.theme-selection h2 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: #1e293b;
}
.section-description {
margin: 0 0 2rem 0;
color: #64748b;
font-size: 0.875rem;
}
.no-themes {
text-align: center;
padding: 3rem;
color: #64748b;
}
/* Creation Actions Section */
.creation-actions {
background: white;
border-radius: 0.75rem;
padding: 2rem;
border: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 2rem;
flex-wrap: wrap;
}
.selected-theme-info {
flex: 1;
min-width: 0;
}
.theme-preview-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
font-weight: 600;
color: #1e293b;
}
.theme-preview-info p {
margin: 0 0 0.75rem 0;
color: #64748b;
font-size: 0.875rem;
}
.theme-stats {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #6b7280;
}
.theme-stats span {
background: #f1f5f9;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.creation-error {
margin-top: 1rem;
padding: 0.75rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.375rem;
color: #dc2626;
}
.creation-error p {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
}
/* Aspect Ratio Selection */
.aspect-ratio-selection {
margin-bottom: 2rem;
}
.aspect-ratio-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.aspect-ratio-card {
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
padding: 1.5rem;
cursor: pointer;
transition: all 0.2s ease;
background: white;
}
.aspect-ratio-card:hover {
border-color: #cbd5e1;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.aspect-ratio-card.selected {
border-color: #3b82f6;
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.2);
background: #eff6ff;
}
.aspect-ratio-preview {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
margin-bottom: 1rem;
background: #f8fafc;
border-radius: 0.5rem;
border: 1px dashed #cbd5e1;
}
.preview-box {
background: #3b82f6;
border-radius: 0.25rem;
}
.preview-box.aspect-16-9 {
width: 64px;
height: 36px;
}
.preview-box.aspect-4-3 {
width: 60px;
height: 45px;
}
.preview-box.aspect-16-10 {
width: 64px;
height: 40px;
}
.aspect-ratio-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
font-weight: 600;
color: #1e293b;
}
.ratio-description {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
color: #64748b;
line-height: 1.4;
}
.ratio-dimensions {
font-size: 0.75rem;
color: #6b7280;
background: #f1f5f9;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
display: inline-block;
}
.action-buttons {
display: flex;
gap: 1rem;
flex-shrink: 0;
}
.button {
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.button.primary {
background: #3b82f6;
color: white;
}
.button.primary:hover:not(:disabled) {
background: #2563eb;
}
.button.primary:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.button.secondary {
background: #f8fafc;
color: #64748b;
border: 1px solid #e2e8f0;
}
.button.secondary:hover {
background: #f1f5f9;
color: #475569;
}
/* Loading and Error States */
.loading-content,
.error-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 50vh;
text-align: center;
gap: 1rem;
}
.loading-spinner {
color: #64748b;
font-size: 1.125rem;
}
.error-content h2 {
color: #dc2626;
margin: 0;
}
.error-content p {
color: #64748b;
margin: 0.5rem 0 1.5rem 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.page-header {
padding: 1rem;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.page-content {
padding: 1rem;
}
.creation-actions {
flex-direction: column;
align-items: stretch;
}
.action-buttons {
justify-content: stretch;
}
.button {
flex: 1;
}
}

View File

@ -0,0 +1,237 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import type { Theme } from '../../types/theme';
import type { AspectRatio } from '../../types/presentation';
import { ASPECT_RATIOS } from '../../types/presentation';
import { getThemes } from '../../themes';
import { createPresentation } from '../../utils/presentationStorage';
import { ThemeSelector } from './ThemeSelector';
import './NewPresentationPage.css';
export const NewPresentationPage: React.FC = () => {
const navigate = useNavigate();
const [themes, setThemes] = useState<Theme[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedTheme, setSelectedTheme] = useState<Theme | null>(null);
const [selectedAspectRatio, setSelectedAspectRatio] = useState<AspectRatio>('16:9');
const [presentationTitle, setPresentationTitle] = useState('');
const [presentationDescription, setPresentationDescription] = useState('');
const [creating, setCreating] = useState(false);
useEffect(() => {
const loadThemes = async () => {
try {
setLoading(true);
const discoveredThemes = await getThemes();
setThemes(discoveredThemes);
// Auto-select first theme if available
if (discoveredThemes.length > 0) {
setSelectedTheme(discoveredThemes[0]);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load themes');
} finally {
setLoading(false);
}
};
loadThemes();
}, []);
const handleCreatePresentation = async () => {
if (!selectedTheme) {
alert('Please select a theme for your presentation');
return;
}
if (!presentationTitle.trim()) {
alert('Please enter a title for your presentation');
return;
}
try {
setCreating(true);
setError(null);
const presentation = await createPresentation({
name: presentationTitle.trim(),
description: presentationDescription.trim(),
theme: selectedTheme.id,
aspectRatio: selectedAspectRatio
});
console.log('Presentation created successfully:', presentation);
// Navigate to the new presentation editor (slide 1)
navigate(`/presentations/${presentation.metadata.id}/edit/slides/1`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create presentation');
console.error('Error creating presentation:', err);
} finally {
setCreating(false);
}
};
if (loading) {
return (
<div className="new-presentation-page">
<div className="loading-content">
<div className="loading-spinner">Loading themes...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="new-presentation-page">
<div className="error-content">
<h2>Error Loading Themes</h2>
<p>{error}</p>
<button onClick={() => navigate('/themes')} className="button secondary">
Back to Themes
</button>
</div>
</div>
);
}
return (
<div className="new-presentation-page">
<header className="page-header">
<button
onClick={() => navigate('/themes')}
className="back-button"
type="button"
>
Back to Themes
</button>
<div className="header-content">
<h1>Create New Presentation</h1>
<p>Choose a theme and enter details for your new presentation</p>
</div>
</header>
<main className="page-content">
<div className="creation-form">
<section className="presentation-details">
<h2>Presentation Details</h2>
<div className="form-group">
<label htmlFor="title">Title *</label>
<input
id="title"
type="text"
value={presentationTitle}
onChange={(e) => setPresentationTitle(e.target.value)}
placeholder="Enter presentation title"
className="form-input"
required
/>
</div>
<div className="form-group">
<label htmlFor="description">Description</label>
<textarea
id="description"
value={presentationDescription}
onChange={(e) => setPresentationDescription(e.target.value)}
placeholder="Optional description of your presentation"
className="form-textarea"
rows={3}
/>
</div>
</section>
<section className="aspect-ratio-selection">
<h2>Choose Aspect Ratio</h2>
<p className="section-description">
Select the aspect ratio that best fits your display setup
</p>
<div className="aspect-ratio-grid">
{ASPECT_RATIOS.map((ratio) => (
<div
key={ratio.id}
className={`aspect-ratio-card ${selectedAspectRatio === ratio.id ? 'selected' : ''}`}
onClick={() => setSelectedAspectRatio(ratio.id)}
>
<div className="aspect-ratio-preview">
<div className={`preview-box ${ratio.cssClass}`}></div>
</div>
<div className="aspect-ratio-info">
<h3>{ratio.name}</h3>
<p className="ratio-description">{ratio.description}</p>
<div className="ratio-dimensions">
{ratio.width} × {ratio.height}
</div>
</div>
</div>
))}
</div>
</section>
<section className="theme-selection">
<h2>Choose a Theme</h2>
<p className="section-description">
Select a theme that matches the style and tone of your presentation
</p>
{themes.length > 0 ? (
<ThemeSelector
themes={themes}
selectedTheme={selectedTheme}
onThemeSelect={setSelectedTheme}
/>
) : (
<div className="no-themes">
<p>No themes available. Please check your theme configuration.</p>
</div>
)}
</section>
<section className="creation-actions">
<div className="selected-theme-info">
{selectedTheme && (
<div className="theme-preview-info">
<h3>Selected Theme: {selectedTheme.name}</h3>
<p>{selectedTheme.description}</p>
<div className="theme-stats">
<span>{selectedTheme.layouts.length} layouts available</span>
{selectedTheme.author && <span>by {selectedTheme.author}</span>}
</div>
</div>
)}
{error && (
<div className="creation-error">
<p>Failed to create presentation: {error}</p>
</div>
)}
</div>
<div className="action-buttons">
<button
onClick={() => navigate('/themes')}
className="button secondary"
type="button"
disabled={creating}
>
Cancel
</button>
<button
onClick={handleCreatePresentation}
className="button primary"
type="button"
disabled={!selectedTheme || !presentationTitle.trim() || creating}
>
{creating ? 'Creating...' : 'Create Presentation'}
</button>
</div>
</section>
</div>
</main>
</div>
);
};

View File

@ -0,0 +1,754 @@
.presentation-editor {
min-height: 100vh;
background: #f8fafc;
display: flex;
flex-direction: column;
}
/* Header */
.editor-header {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 2rem;
flex-wrap: wrap;
}
.presentation-info {
display: flex;
align-items: center;
gap: 1.5rem;
flex: 1;
min-width: 0;
}
.back-link {
color: #64748b;
text-decoration: none;
font-weight: 500;
font-size: 0.875rem;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
transition: all 0.2s ease;
flex-shrink: 0;
}
.back-link:hover {
background: #f1f5f9;
color: #334155;
}
.presentation-title {
flex: 1;
min-width: 0;
}
.presentation-title h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presentation-description {
margin: 0.25rem 0 0 0;
color: #64748b;
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presentation-meta {
display: flex;
gap: 1rem;
align-items: center;
flex-shrink: 0;
flex-wrap: wrap;
}
.theme-badge {
background: #dbeafe;
color: #1e40af;
padding: 0.25rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
}
.slide-counter {
color: #6b7280;
font-size: 0.875rem;
font-weight: 500;
}
.saving-indicator {
color: #f59e0b;
font-size: 0.875rem;
font-weight: 500;
}
.editor-actions {
display: flex;
gap: 0.75rem;
flex-shrink: 0;
}
.action-button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
font-size: 0.875rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.action-button.primary {
background: #3b82f6;
color: white;
}
.action-button.primary:hover:not(:disabled) {
background: #2563eb;
}
.action-button.secondary {
background: #f8fafc;
color: #64748b;
border: 1px solid #e2e8f0;
}
.action-button.secondary:hover:not(:disabled) {
background: #f1f5f9;
color: #475569;
}
.action-button.large {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Main Content */
.editor-content {
flex: 1;
display: flex;
flex-direction: column;
}
/* Empty State */
.empty-presentation {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.empty-content {
text-align: center;
max-width: 800px;
}
.empty-content h2 {
margin: 0 0 0.5rem 0;
color: #374151;
font-size: 1.5rem;
}
.empty-content p {
margin: 0 0 2rem 0;
color: #6b7280;
font-size: 1rem;
}
/* Theme Preview in Empty State */
.theme-preview {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
padding: 2rem;
margin: 2rem 0;
text-align: left;
max-height: 500px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.theme-preview h3 {
margin: 0 0 0.5rem 0;
color: #1e293b;
font-size: 1.25rem;
font-weight: 600;
}
.theme-description {
margin: 0 0 1.5rem 0;
color: #64748b;
font-size: 0.875rem;
}
.available-layouts {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.available-layouts h4 {
margin: 0 0 1rem 0;
color: #374151;
font-size: 1rem;
font-weight: 600;
flex-shrink: 0;
}
.layouts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
overflow-y: auto;
max-height: 300px;
padding-right: 0.5rem;
}
/* Custom scrollbar for layouts grid */
.layouts-grid::-webkit-scrollbar {
width: 6px;
}
.layouts-grid::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
.layouts-grid::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.layouts-grid::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.layout-preview-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
padding: 1rem;
transition: all 0.2s ease;
}
.layout-preview-card:hover {
border-color: #cbd5e1;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.layout-name {
font-weight: 600;
color: #1e293b;
font-size: 0.875rem;
margin-bottom: 0.25rem;
}
.layout-description {
color: #64748b;
font-size: 0.75rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.slot-count {
color: #059669;
font-size: 0.75rem;
font-weight: 500;
}
.more-layouts {
display: flex;
align-items: center;
justify-content: center;
background: #f1f5f9;
border: 1px dashed #cbd5e1;
border-radius: 0.5rem;
padding: 1rem;
color: #64748b;
font-size: 0.875rem;
font-weight: 500;
}
/* Editor Layout */
.editor-layout {
display: flex;
flex: 1;
min-height: 0;
}
/* Slide Sidebar */
.slide-sidebar {
width: 280px;
background: white;
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #1e293b;
}
.add-slide-button {
width: 32px;
height: 32px;
border: 1px solid #e2e8f0;
background: white;
border-radius: 0.375rem;
cursor: pointer;
font-size: 1.25rem;
color: #3b82f6;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.add-slide-button:hover:not(:disabled) {
background: #f8fafc;
border-color: #3b82f6;
}
.add-slide-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.slides-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.slide-thumbnail {
background: white;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.slide-thumbnail:hover {
border-color: #cbd5e1;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.slide-thumbnail.active {
border-color: #3b82f6;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);
}
.thumbnail-number {
position: absolute;
top: 0.5rem;
left: 0.5rem;
background: #374151;
color: white;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 500;
z-index: 10;
}
.slide-thumbnail.active .thumbnail-number {
background: #3b82f6;
}
.thumbnail-preview {
padding: 2rem 1rem 1rem;
min-height: 80px;
background: #f8fafc;
border-radius: 0.25rem;
margin: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.thumbnail-content {
text-align: center;
}
.layout-name {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: #374151;
margin-bottom: 0.25rem;
}
.content-count {
display: block;
font-size: 0.625rem;
color: #6b7280;
}
.thumbnail-actions {
display: flex;
gap: 0.25rem;
padding: 0.5rem;
justify-content: center;
border-top: 1px solid #f1f5f9;
}
.thumbnail-action {
width: 28px;
height: 28px;
border: 1px solid #e2e8f0;
background: white;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
color: #6b7280;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.thumbnail-action:hover:not(:disabled) {
background: #f8fafc;
border-color: #cbd5e1;
}
.thumbnail-action.edit {
background: #eff6ff;
border-color: #3b82f6;
color: #3b82f6;
}
.thumbnail-action.edit:hover:not(:disabled) {
background: #dbeafe;
border-color: #2563eb;
color: #2563eb;
}
.thumbnail-action.delete:hover:not(:disabled) {
background: #fef2f2;
border-color: #fecaca;
color: #dc2626;
}
.thumbnail-action:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Slide Editor Area */
.slide-editor-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.slide-editor {
flex: 1;
display: flex;
flex-direction: column;
padding: 1rem;
}
.slide-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
gap: 1rem;
flex-wrap: wrap;
}
.slide-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1e293b;
}
.slide-controls {
display: flex;
gap: 0.5rem;
}
.control-button {
padding: 0.375rem 0.75rem;
border: 1px solid #e2e8f0;
background: white;
color: #374151;
border-radius: 0.375rem;
cursor: pointer;
font-weight: 500;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.control-button:hover:not(:disabled) {
background: #f8fafc;
border-color: #cbd5e1;
}
.control-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.control-button.edit-slide-button {
background: #3b82f6;
color: white;
border-color: #3b82f6;
font-weight: 600;
}
.control-button.edit-slide-button:hover:not(:disabled) {
background: #2563eb;
border-color: #2563eb;
}
.slide-content-editor {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
.content-preview {
flex: 1;
background: white;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
padding: 2rem;
min-height: 400px;
}
.editor-placeholder {
text-align: center;
color: #6b7280;
}
.editor-placeholder h4 {
margin: 0 0 1rem 0;
color: #374151;
}
.editor-placeholder p {
margin: 0.5rem 0;
}
.content-slots {
margin: 1.5rem 0;
text-align: left;
}
.content-slot {
margin-bottom: 1rem;
padding: 0.75rem;
background: #f8fafc;
border-radius: 0.375rem;
}
.content-slot label {
display: block;
font-weight: 500;
color: #374151;
margin-bottom: 0.25rem;
font-size: 0.875rem;
}
.slot-content {
color: #6b7280;
font-size: 0.875rem;
}
.placeholder-note {
font-style: italic;
color: #9ca3af;
margin-top: 1.5rem;
}
.slide-notes-editor {
background: white;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
padding: 1rem;
}
.slide-notes-editor h4 {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
font-weight: 600;
color: #374151;
}
.notes-textarea {
width: 100%;
min-height: 80px;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
color: #374151;
background: white;
box-sizing: border-box;
resize: vertical;
font-family: inherit;
}
.notes-textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.slide-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
color: #dc2626;
}
/* Loading and Error States */
.loading-content,
.error-content,
.not-found-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 50vh;
text-align: center;
gap: 1rem;
}
.loading-spinner {
color: #64748b;
font-size: 1.125rem;
}
.error-content h2,
.not-found-content h2 {
color: #dc2626;
margin: 0;
}
.error-content p,
.not-found-content p {
color: #64748b;
margin: 0.5rem 0 1.5rem 0;
}
/* Responsive Design */
@media (max-width: 1024px) {
.slide-sidebar {
width: 240px;
}
.editor-header {
padding: 1rem;
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.presentation-info {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.presentation-meta {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.editor-actions {
justify-content: stretch;
}
.action-button {
flex: 1;
}
}
@media (max-width: 768px) {
.editor-layout {
flex-direction: column;
}
.slide-sidebar {
width: 100%;
max-height: 200px;
border-right: none;
border-bottom: 1px solid #e2e8f0;
}
.slides-list {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding: 0.5rem;
}
.slide-thumbnail {
min-width: 120px;
margin-bottom: 0;
}
.slide-header {
flex-direction: column;
align-items: stretch;
}
.slide-controls {
justify-content: stretch;
}
.control-button {
flex: 1;
}
}

View File

@ -0,0 +1,513 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import type { Presentation } from '../../types/presentation';
import type { Theme } from '../../types/theme';
import { getPresentationById, updatePresentation } from '../../utils/presentationStorage';
import { getTheme } from '../../themes';
import './PresentationEditor.css';
export const PresentationEditor: React.FC = () => {
const { presentationId, slideNumber } = useParams<{
presentationId: string;
slideNumber: string;
}>();
const navigate = useNavigate();
const [presentation, setPresentation] = useState<Presentation | null>(null);
const [theme, setTheme] = useState<Theme | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const currentSlideIndex = slideNumber ? parseInt(slideNumber, 10) - 1 : 0;
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);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load presentation');
} finally {
setLoading(false);
}
};
loadPresentationAndTheme();
}, [presentationId]);
const goToSlide = (slideIndex: number) => {
if (!presentation) return;
const slideNum = slideIndex + 1;
navigate(`/presentations/${presentationId}/edit/slides/${slideNum}`);
};
const goToPreviousSlide = () => {
if (currentSlideIndex > 0) {
goToSlide(currentSlideIndex - 1);
}
};
const goToNextSlide = () => {
if (presentation && currentSlideIndex < presentation.slides.length - 1) {
goToSlide(currentSlideIndex + 1);
}
};
const addNewSlide = () => {
if (!presentation) return;
// Navigate to slide editor for new slide
navigate(`/presentations/${presentationId}/slide/new/edit`);
};
const duplicateSlide = async (slideIndex: number) => {
if (!presentation) return;
const slideToDuplicate = presentation.slides[slideIndex];
if (!slideToDuplicate) return;
try {
setSaving(true);
setError(null);
// Create a duplicate slide with new ID
const duplicatedSlide = {
...slideToDuplicate,
id: `slide_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
order: slideIndex + 1 // Insert right after the original
};
// Create updated presentation with the duplicated slide
const updatedPresentation = { ...presentation };
const newSlides = [...presentation.slides];
// Insert the duplicated slide after the original
newSlides.splice(slideIndex + 1, 0, duplicatedSlide);
// Update slide order for all slides after the insertion point
newSlides.forEach((slide, index) => {
slide.order = index;
});
updatedPresentation.slides = newSlides;
// Save the updated presentation
await updatePresentation(updatedPresentation);
// Update local state
setPresentation(updatedPresentation);
// Navigate to the duplicated slide
const newSlideNumber = slideIndex + 2; // +2 because we inserted after and slide numbers are 1-based
navigate(`/presentations/${presentationId}/edit/slides/${newSlideNumber}`);
} catch (err) {
console.error('Error duplicating slide:', err);
setError(err instanceof Error ? err.message : 'Failed to duplicate slide');
} finally {
setSaving(false);
}
};
const deleteSlide = async (slideIndex: number) => {
if (!presentation) return;
const slideToDelete = presentation.slides[slideIndex];
if (!slideToDelete) return;
const slideNumber = slideIndex + 1;
const totalSlides = presentation.slides.length;
let confirmMessage = `Are you sure you want to delete slide ${slideNumber}?`;
if (totalSlides === 1) {
confirmMessage = `Are you sure you want to delete the only slide in this presentation? The presentation will be empty after deletion.`;
} else {
confirmMessage += ` This will remove the slide and renumber all subsequent slides. This action cannot be undone.`;
}
if (!confirm(confirmMessage)) {
return;
}
try {
setSaving(true);
setError(null);
// Create updated presentation with the slide removed
const updatedPresentation = { ...presentation };
updatedPresentation.slides = presentation.slides.filter((_, index) => index !== slideIndex);
// Update slide order for remaining slides
updatedPresentation.slides.forEach((slide, index) => {
slide.order = index;
});
// Save the updated presentation
await updatePresentation(updatedPresentation);
// Update local state
setPresentation(updatedPresentation);
// Handle navigation after deletion
const totalSlides = updatedPresentation.slides.length;
if (totalSlides === 0) {
// No slides left, stay on editor main view
navigate(`/presentations/${presentationId}/edit`);
} else {
// Navigate to appropriate slide
let newSlideIndex = slideIndex;
if (slideIndex >= totalSlides) {
// If we deleted the last slide, go to the new last slide
newSlideIndex = totalSlides - 1;
}
// Navigate to the adjusted slide number
const slideNumber = newSlideIndex + 1;
navigate(`/presentations/${presentationId}/edit/slides/${slideNumber}`);
}
} catch (err) {
console.error('Error deleting slide:', err);
setError(err instanceof Error ? err.message : 'Failed to delete slide');
} finally {
setSaving(false);
}
};
const savePresentation = async () => {
if (!presentation) return;
try {
setSaving(true);
// TODO: Implement presentation saving
console.log('Save presentation functionality to be implemented');
alert('Auto-save will be implemented. Changes are saved automatically.');
} catch (err) {
console.error('Error saving presentation:', err);
alert('Failed to save presentation');
} finally {
setSaving(false);
}
};
const previewPresentation = () => {
if (!presentation) return;
const slideNum = Math.max(1, currentSlideIndex + 1);
navigate(`/presentations/${presentationId}/view/slides/${slideNum}`);
};
if (loading) {
return (
<div className="presentation-editor">
<div className="loading-content">
<div className="loading-spinner">Loading presentation editor...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="presentation-editor">
<div className="error-content">
<h2>Error Loading Presentation</h2>
<p>{error}</p>
<Link to="/themes" className="back-link"> Back to Themes</Link>
</div>
</div>
);
}
if (!presentation || !theme) {
return (
<div className="presentation-editor">
<div className="not-found-content">
<h2>Presentation Not Found</h2>
<p>The requested presentation could not be found.</p>
<Link to="/themes" className="back-link"> Back to Themes</Link>
</div>
</div>
);
}
const currentSlide = presentation.slides[currentSlideIndex];
const totalSlides = presentation.slides.length;
return (
<div className="presentation-editor">
<header className="editor-header">
<div className="presentation-info">
<Link to="/themes" className="back-link"> Back</Link>
<div className="presentation-title">
<h1>{presentation.metadata.name}</h1>
{presentation.metadata.description && (
<p className="presentation-description">{presentation.metadata.description}</p>
)}
</div>
<div className="presentation-meta">
<span className="theme-badge">Theme: {theme.name}</span>
<span className="slide-counter">
{totalSlides === 0 ? 'No slides' : `Editing slide ${currentSlideIndex + 1} of ${totalSlides}`}
</span>
{saving && <span className="saving-indicator">Saving...</span>}
</div>
</div>
<div className="editor-actions">
<button
type="button"
className="action-button secondary"
onClick={savePresentation}
disabled={saving}
>
{saving ? 'Saving...' : 'Save'}
</button>
<button
type="button"
className="action-button secondary"
onClick={previewPresentation}
>
Preview
</button>
<button
type="button"
className="action-button primary"
onClick={addNewSlide}
>
Add Slide
</button>
</div>
</header>
<main className="editor-content">
{totalSlides === 0 ? (
<div className="empty-presentation">
<div className="empty-content">
<h2>Start creating your presentation</h2>
<p>Add your first slide to begin editing your presentation</p>
{theme && (
<div className="theme-preview">
<h3>Using Theme: {theme.name}</h3>
<p className="theme-description">{theme.description}</p>
<div className="available-layouts">
<h4>Available Layouts ({theme.layouts.length})</h4>
<div className="layouts-grid">
{theme.layouts.slice(0, 6).map((layout) => (
<div key={layout.id} className="layout-preview-card">
<div className="layout-name">{layout.name}</div>
<div className="layout-description">{layout.description}</div>
<div className="slot-count">{layout.slots.length} slots</div>
</div>
))}
{theme.layouts.length > 6 && (
<div className="more-layouts">
+{theme.layouts.length - 6} more layouts
</div>
)}
</div>
</div>
</div>
)}
<button
type="button"
className="action-button primary large"
onClick={addNewSlide}
>
Add First Slide
</button>
</div>
</div>
) : (
<div className="editor-layout">
<aside className="slide-sidebar">
<div className="sidebar-header">
<h3>Slides</h3>
<button
type="button"
className="add-slide-button"
onClick={addNewSlide}
title="Add new slide"
>
+
</button>
</div>
<div className="slides-list">
{presentation.slides.map((slide, index) => (
<div
key={slide.id}
className={`slide-thumbnail ${index === currentSlideIndex ? 'active' : ''}`}
onClick={() => goToSlide(index)}
onDoubleClick={() => navigate(`/presentations/${presentationId}/slide/${slide.id}/edit`)}
>
<div className="thumbnail-number">{index + 1}</div>
<div className="thumbnail-preview">
<div className="thumbnail-content">
<span className="layout-name">{slide.layoutId}</span>
<span className="content-count">
{Object.keys(slide.content).length} items
</span>
</div>
</div>
<div className="thumbnail-actions">
<button
type="button"
className="thumbnail-action edit"
onClick={(e) => {
e.stopPropagation();
navigate(`/presentations/${presentationId}/slide/${slide.id}/edit`);
}}
title="Edit slide content"
disabled={saving}
>
</button>
<button
type="button"
className="thumbnail-action"
onClick={(e) => {
e.stopPropagation();
duplicateSlide(index);
}}
title="Duplicate slide"
disabled={saving}
>
</button>
<button
type="button"
className="thumbnail-action delete"
onClick={(e) => {
e.stopPropagation();
deleteSlide(index);
}}
title="Delete slide"
disabled={saving}
>
</button>
</div>
</div>
))}
</div>
</aside>
<div className="slide-editor-area">
{currentSlide ? (
<div className="slide-editor">
<div className="slide-header">
<h3>Slide {currentSlideIndex + 1} - {currentSlide.layoutId}</h3>
<div className="slide-controls">
<button
type="button"
className="control-button edit-slide-button"
onClick={() => navigate(`/presentations/${presentationId}/slide/${currentSlide.id}/edit`)}
disabled={saving}
>
Edit Content
</button>
<button
type="button"
className="control-button"
onClick={goToPreviousSlide}
disabled={currentSlideIndex === 0}
>
Previous
</button>
<button
type="button"
className="control-button"
onClick={goToNextSlide}
disabled={currentSlideIndex === totalSlides - 1}
>
Next
</button>
</div>
</div>
<div className="slide-content-editor">
<div className="content-preview">
{/* TODO: Render actual slide content with editing capabilities */}
<div className="editor-placeholder">
<h4>Slide Content Editor</h4>
<p>Layout: {currentSlide.layoutId}</p>
<p>Content slots: {Object.keys(currentSlide.content).length}</p>
<div className="content-slots">
{Object.entries(currentSlide.content).map(([slotId, content]) => (
<div key={slotId} className="content-slot">
<label>{slotId}:</label>
<div className="slot-content">{content || '(empty)'}</div>
</div>
))}
</div>
<p className="placeholder-note">
Interactive slide editor will be implemented next
</p>
</div>
</div>
{currentSlide.notes && (
<div className="slide-notes-editor">
<h4>Speaker Notes</h4>
<textarea
value={currentSlide.notes}
onChange={(e) => {
// TODO: Update slide notes
console.log('Update notes:', e.target.value);
}}
placeholder="Add speaker notes for this slide..."
className="notes-textarea"
/>
</div>
)}
</div>
</div>
) : (
<div className="slide-error">
<p>Invalid slide number</p>
<button
type="button"
className="action-button secondary"
onClick={() => goToSlide(0)}
>
Go to First Slide
</button>
</div>
)}
</div>
</div>
)}
</main>
</div>
);
};

View File

@ -0,0 +1,390 @@
.presentation-viewer {
min-height: 100vh;
background: #f8fafc;
display: flex;
flex-direction: column;
}
/* Header */
.viewer-header {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 2rem;
flex-wrap: wrap;
}
.presentation-info {
display: flex;
align-items: center;
gap: 1.5rem;
flex: 1;
min-width: 0;
}
.back-link {
color: #64748b;
text-decoration: none;
font-weight: 500;
font-size: 0.875rem;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
transition: all 0.2s ease;
flex-shrink: 0;
}
.back-link:hover {
background: #f1f5f9;
color: #334155;
}
.presentation-title {
flex: 1;
min-width: 0;
}
.presentation-title h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presentation-description {
margin: 0.25rem 0 0 0;
color: #64748b;
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presentation-meta {
display: flex;
gap: 1rem;
align-items: center;
flex-shrink: 0;
}
.theme-badge {
background: #dbeafe;
color: #1e40af;
padding: 0.25rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
}
.slide-counter {
color: #6b7280;
font-size: 0.875rem;
font-weight: 500;
}
.viewer-actions {
display: flex;
gap: 0.75rem;
flex-shrink: 0;
}
.action-button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
font-size: 0.875rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.action-button.primary {
background: #3b82f6;
color: white;
}
.action-button.primary:hover {
background: #2563eb;
}
.action-button.secondary {
background: #f8fafc;
color: #64748b;
border: 1px solid #e2e8f0;
}
.action-button.secondary:hover {
background: #f1f5f9;
color: #475569;
}
.action-button.large {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
/* Main Content */
.viewer-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 2rem;
gap: 2rem;
}
/* Empty State */
.empty-presentation {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.empty-content {
text-align: center;
max-width: 400px;
}
.empty-content h2 {
margin: 0 0 0.5rem 0;
color: #374151;
font-size: 1.5rem;
}
.empty-content p {
margin: 0 0 2rem 0;
color: #6b7280;
font-size: 1rem;
}
/* Slide Area */
.slide-area {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.slide-container {
background: white;
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
padding: 2rem;
max-width: 800px;
width: 100%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.slide-content h3 {
margin: 0 0 1rem 0;
color: #1e293b;
font-size: 1.25rem;
}
.slide-content p {
margin: 0 0 1rem 0;
color: #64748b;
font-size: 0.875rem;
}
.slide-preview {
background: #f8fafc;
border: 2px dashed #cbd5e1;
border-radius: 0.5rem;
padding: 3rem;
text-align: center;
margin: 1.5rem 0;
}
.slide-placeholder {
color: #6b7280;
}
.slide-placeholder p {
margin: 0.5rem 0;
}
.slide-notes {
margin-top: 1.5rem;
padding: 1rem;
background: #f8fafc;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
}
.slide-notes h4 {
margin: 0 0 0.5rem 0;
color: #374151;
font-size: 0.875rem;
font-weight: 600;
}
.slide-notes p {
margin: 0;
color: #6b7280;
font-size: 0.875rem;
line-height: 1.4;
}
.slide-error {
text-align: center;
padding: 2rem;
color: #dc2626;
}
/* Slide Navigation */
.slide-navigation {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 1rem;
background: white;
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
}
.nav-button {
padding: 0.5rem 1rem;
border: 1px solid #e2e8f0;
background: white;
color: #374151;
border-radius: 0.375rem;
cursor: pointer;
font-weight: 500;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.nav-button:hover:not(:disabled) {
background: #f8fafc;
border-color: #cbd5e1;
}
.nav-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.slide-thumbnails {
display: flex;
gap: 0.5rem;
max-width: 300px;
overflow-x: auto;
padding: 0.25rem;
}
.thumbnail {
min-width: 40px;
height: 40px;
border: 2px solid #e2e8f0;
background: white;
border-radius: 0.375rem;
cursor: pointer;
font-weight: 500;
font-size: 0.875rem;
color: #6b7280;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.thumbnail:hover {
border-color: #cbd5e1;
background: #f8fafc;
}
.thumbnail.active {
border-color: #3b82f6;
background: #dbeafe;
color: #1e40af;
}
/* Loading and Error States */
.loading-content,
.error-content,
.not-found-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 50vh;
text-align: center;
gap: 1rem;
}
.loading-spinner {
color: #64748b;
font-size: 1.125rem;
}
.error-content h2,
.not-found-content h2 {
color: #dc2626;
margin: 0;
}
.error-content p,
.not-found-content p {
color: #64748b;
margin: 0.5rem 0 1.5rem 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.viewer-header {
padding: 1rem;
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.presentation-info {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.presentation-meta {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.viewer-actions {
justify-content: stretch;
}
.action-button {
flex: 1;
}
.viewer-content {
padding: 1rem;
}
.slide-container {
padding: 1rem;
}
.slide-navigation {
flex-direction: column;
gap: 1rem;
}
.slide-thumbnails {
justify-content: center;
max-width: none;
}
}

View File

@ -0,0 +1,249 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import type { Presentation } from '../../types/presentation';
import type { Theme } from '../../types/theme';
import { getPresentationById } from '../../utils/presentationStorage';
import { getTheme } from '../../themes';
import './PresentationViewer.css';
export const PresentationViewer: React.FC = () => {
const { presentationId, slideNumber } = useParams<{
presentationId: string;
slideNumber: string;
}>();
const navigate = useNavigate();
const [presentation, setPresentation] = useState<Presentation | null>(null);
const [theme, setTheme] = useState<Theme | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const currentSlideIndex = slideNumber ? parseInt(slideNumber, 10) - 1 : 0;
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);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load presentation');
} finally {
setLoading(false);
}
};
loadPresentationAndTheme();
}, [presentationId]);
const goToSlide = (slideIndex: number) => {
if (!presentation) return;
const slideNum = slideIndex + 1;
navigate(`/presentations/${presentationId}/view/slides/${slideNum}`);
};
const goToPreviousSlide = () => {
if (currentSlideIndex > 0) {
goToSlide(currentSlideIndex - 1);
}
};
const goToNextSlide = () => {
if (presentation && currentSlideIndex < presentation.slides.length - 1) {
goToSlide(currentSlideIndex + 1);
}
};
const editPresentation = () => {
if (!presentation) return;
const slideNum = Math.max(1, currentSlideIndex + 1);
navigate(`/presentations/${presentationId}/edit/slides/${slideNum}`);
};
const enterPresentationMode = () => {
// TODO: Implement full-screen presentation mode
console.log('Full-screen presentation mode to be implemented');
};
if (loading) {
return (
<div className="presentation-viewer">
<div className="loading-content">
<div className="loading-spinner">Loading presentation...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="presentation-viewer">
<div className="error-content">
<h2>Error Loading Presentation</h2>
<p>{error}</p>
<Link to="/themes" className="back-link"> Back to Themes</Link>
</div>
</div>
);
}
if (!presentation || !theme) {
return (
<div className="presentation-viewer">
<div className="not-found-content">
<h2>Presentation Not Found</h2>
<p>The requested presentation could not be found.</p>
<Link to="/themes" className="back-link"> Back to Themes</Link>
</div>
</div>
);
}
const currentSlide = presentation.slides[currentSlideIndex];
const totalSlides = presentation.slides.length;
return (
<div className="presentation-viewer">
<header className="viewer-header">
<div className="presentation-info">
<Link to="/themes" className="back-link"> Back</Link>
<div className="presentation-title">
<h1>{presentation.metadata.name}</h1>
{presentation.metadata.description && (
<p className="presentation-description">{presentation.metadata.description}</p>
)}
</div>
<div className="presentation-meta">
<span className="theme-badge">Theme: {theme.name}</span>
<span className="slide-counter">
{totalSlides === 0 ? 'No slides' : `Slide ${currentSlideIndex + 1} of ${totalSlides}`}
</span>
</div>
</div>
<div className="viewer-actions">
<button
type="button"
className="action-button secondary"
onClick={editPresentation}
>
Edit
</button>
<button
type="button"
className="action-button primary"
onClick={enterPresentationMode}
>
Present
</button>
</div>
</header>
<main className="viewer-content">
{totalSlides === 0 ? (
<div className="empty-presentation">
<div className="empty-content">
<h2>This presentation is empty</h2>
<p>Switch to edit mode to add slides</p>
<button
type="button"
className="action-button primary large"
onClick={editPresentation}
>
Edit Presentation
</button>
</div>
</div>
) : (
<>
<div className="slide-area">
<div className="slide-container">
{currentSlide ? (
<div className="slide-content">
<h3>Slide {currentSlideIndex + 1}</h3>
<p>Layout: {currentSlide.layoutId}</p>
<div className="slide-preview">
{/* TODO: Render actual slide content based on layout */}
<div className="slide-placeholder">
<p>Slide content will be rendered here</p>
<p>Layout: {currentSlide.layoutId}</p>
<p>Content slots: {Object.keys(currentSlide.content).length}</p>
</div>
</div>
{currentSlide.notes && (
<div className="slide-notes">
<h4>Notes:</h4>
<p>{currentSlide.notes}</p>
</div>
)}
</div>
) : (
<div className="slide-error">
<p>Invalid slide number</p>
</div>
)}
</div>
</div>
<div className="slide-navigation">
<button
type="button"
className="nav-button"
onClick={goToPreviousSlide}
disabled={currentSlideIndex === 0}
>
Previous
</button>
<div className="slide-thumbnails">
{presentation.slides.map((slide, index) => (
<button
key={slide.id}
type="button"
className={`thumbnail ${index === currentSlideIndex ? 'active' : ''}`}
onClick={() => goToSlide(index)}
>
{index + 1}
</button>
))}
</div>
<button
type="button"
className="nav-button"
onClick={goToNextSlide}
disabled={currentSlideIndex === totalSlides - 1}
>
Next
</button>
</div>
</>
)}
</main>
</div>
);
};

View File

@ -0,0 +1,430 @@
.presentations-list {
min-height: 100vh;
background: #f8fafc;
display: flex;
flex-direction: column;
}
/* Header */
.list-header {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 2rem;
flex-wrap: wrap;
}
.header-content {
flex: 1;
min-width: 0;
}
.header-content h1 {
margin: 0;
font-size: 2rem;
font-weight: 700;
color: #1e293b;
}
.header-content p {
margin: 0.5rem 0 0 0;
color: #64748b;
font-size: 1rem;
}
.header-actions {
display: flex;
gap: 1rem;
flex-shrink: 0;
}
.action-button {
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.action-button.primary {
background: #3b82f6;
color: white;
}
.action-button.primary:hover {
background: #2563eb;
}
.action-button.secondary {
background: #f8fafc;
color: #64748b;
border: 1px solid #e2e8f0;
}
.action-button.secondary:hover {
background: #f1f5f9;
color: #475569;
}
.action-button.large {
padding: 1rem 2rem;
font-size: 1rem;
}
/* Main Content */
.list-content {
flex: 1;
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
/* Empty State */
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
text-align: center;
}
.empty-content h2 {
margin: 0 0 0.5rem 0;
color: #374151;
font-size: 1.5rem;
}
.empty-content p {
margin: 0 0 2rem 0;
color: #6b7280;
font-size: 1rem;
}
/* Presentations Grid */
.presentations-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.presentation-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
overflow: hidden;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
}
.presentation-card:hover {
border-color: #cbd5e1;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
/* Card Header */
.card-header {
padding: 1.5rem 1.5rem 1rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.presentation-info {
flex: 1;
min-width: 0;
}
.presentation-name {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presentation-description {
margin: 0;
color: #64748b;
font-size: 0.875rem;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.presentation-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.action-icon {
width: 32px;
height: 32px;
border: 1px solid #e2e8f0;
background: white;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.action-icon:hover:not(:disabled) {
background: #f8fafc;
border-color: #cbd5e1;
}
.action-icon.delete:hover:not(:disabled) {
background: #fef2f2;
border-color: #fecaca;
}
.action-icon:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Card Content */
.card-content {
padding: 0 1.5rem;
flex: 1;
}
.presentation-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stat-label {
font-size: 0.75rem;
font-weight: 500;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
.theme-name {
background: #dbeafe;
color: #1e40af;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
display: inline-block;
}
.slides-count {
color: #059669;
font-weight: 600;
}
.presentation-meta {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem 0;
border-top: 1px solid #f1f5f9;
}
.meta-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.meta-label {
font-size: 0.75rem;
color: #6b7280;
}
.meta-value {
font-size: 0.75rem;
color: #374151;
font-weight: 500;
}
/* Card Footer */
.card-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #f1f5f9;
display: flex;
gap: 0.5rem;
}
.card-action {
flex: 1;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-weight: 500;
font-size: 0.875rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
.card-action.primary {
background: #3b82f6;
color: white;
}
.card-action.primary:hover {
background: #2563eb;
}
.card-action.secondary {
background: #f8fafc;
color: #64748b;
border: 1px solid #e2e8f0;
}
.card-action.secondary:hover {
background: #f1f5f9;
color: #475569;
}
/* List Footer */
.list-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 2rem 0;
border-top: 1px solid #e2e8f0;
gap: 1rem;
flex-wrap: wrap;
}
.summary-stats p {
margin: 0;
color: #6b7280;
font-size: 0.875rem;
}
.footer-actions {
display: flex;
gap: 1rem;
}
/* Loading and Error States */
.loading-content,
.error-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 50vh;
text-align: center;
gap: 1rem;
}
.loading-spinner {
color: #64748b;
font-size: 1.125rem;
}
.error-content h2 {
color: #dc2626;
margin: 0;
}
.error-content p {
color: #64748b;
margin: 0.5rem 0 1.5rem 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.list-header {
padding: 1rem;
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.header-actions {
justify-content: stretch;
}
.action-button {
flex: 1;
}
.list-content {
padding: 1rem;
}
.presentations-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.card-header {
padding: 1rem 1rem 0.5rem;
}
.presentation-actions {
flex-direction: column;
}
.presentation-stats {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.list-footer {
flex-direction: column;
align-items: stretch;
text-align: center;
}
.footer-actions {
justify-content: stretch;
}
}
@media (max-width: 480px) {
.presentations-grid {
grid-template-columns: 1fr;
}
.card-footer {
flex-direction: column;
}
.card-action {
padding: 0.75rem;
}
}

View File

@ -0,0 +1,256 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { Presentation } from '../../types/presentation';
import { getAllPresentations, deletePresentation } from '../../utils/presentationStorage';
import './PresentationsList.css';
export const PresentationsList: React.FC = () => {
const navigate = useNavigate();
const [presentations, setPresentations] = useState<Presentation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
useEffect(() => {
loadPresentations();
}, []);
const loadPresentations = async () => {
try {
setLoading(true);
setError(null);
const allPresentations = await getAllPresentations();
setPresentations(allPresentations);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load presentations');
} finally {
setLoading(false);
}
};
const handleDeletePresentation = async (id: string, name: string) => {
if (!confirm(`Are you sure you want to delete "${name}"? This action cannot be undone.`)) {
return;
}
try {
setDeleting(id);
await deletePresentation(id);
setPresentations(prev => prev.filter(p => p.metadata.id !== id));
} catch (err) {
console.error('Error deleting presentation:', err);
alert('Failed to delete presentation. Please try again.');
} finally {
setDeleting(null);
}
};
const handleEditPresentation = (id: string, slideCount: number) => {
const slideNumber = slideCount > 0 ? 1 : 1; // Always go to slide 1, or empty state
navigate(`/presentations/${id}/edit/slides/${slideNumber}`);
};
const handleViewPresentation = (id: string, slideCount: number) => {
const slideNumber = slideCount > 0 ? 1 : 1; // Always go to slide 1, or empty state
navigate(`/presentations/${id}/view/slides/${slideNumber}`);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
if (loading) {
return (
<div className="presentations-list">
<div className="loading-content">
<div className="loading-spinner">Loading presentations...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="presentations-list">
<div className="error-content">
<h2>Error Loading Presentations</h2>
<p>{error}</p>
<button
onClick={loadPresentations}
className="action-button secondary"
>
Try Again
</button>
</div>
</div>
);
}
return (
<div className="presentations-list">
<header className="list-header">
<div className="header-content">
<h1>My Presentations</h1>
<p>Manage and organize your presentation library</p>
</div>
<div className="header-actions">
<Link
to="/presentations/new"
className="action-button primary"
>
Create New Presentation
</Link>
</div>
</header>
<main className="list-content">
{presentations.length === 0 ? (
<div className="empty-state">
<div className="empty-content">
<h2>No presentations yet</h2>
<p>Create your first presentation to get started</p>
<Link
to="/presentations/new"
className="action-button primary large"
>
Create Your First Presentation
</Link>
</div>
</div>
) : (
<div className="presentations-grid">
{presentations.map((presentation) => (
<div key={presentation.metadata.id} className="presentation-card">
<div className="card-header">
<div className="presentation-info">
<h3 className="presentation-name">{presentation.metadata.name}</h3>
{presentation.metadata.description && (
<p className="presentation-description">
{presentation.metadata.description}
</p>
)}
</div>
<div className="presentation-actions">
<button
type="button"
className="action-icon"
onClick={() => handleEditPresentation(presentation.metadata.id, presentation.slides.length)}
title="Edit presentation"
>
</button>
<button
type="button"
className="action-icon"
onClick={() => handleViewPresentation(presentation.metadata.id, presentation.slides.length)}
title="View presentation"
>
👁
</button>
<button
type="button"
className="action-icon delete"
onClick={() => handleDeletePresentation(presentation.metadata.id, presentation.metadata.name)}
disabled={deleting === presentation.metadata.id}
title="Delete presentation"
>
{deleting === presentation.metadata.id ? '⏳' : '🗑️'}
</button>
</div>
</div>
<div className="card-content">
<div className="presentation-stats">
<div className="stat-item">
<span className="stat-label">Theme</span>
<span className="stat-value theme-name">{presentation.metadata.theme}</span>
</div>
<div className="stat-item">
<span className="stat-label">Slides</span>
<span className="stat-value slides-count">
{presentation.slides.length} slide{presentation.slides.length !== 1 ? 's' : ''}
</span>
</div>
</div>
<div className="presentation-meta">
<div className="meta-item">
<span className="meta-label">Created</span>
<span className="meta-value">{formatDate(presentation.metadata.createdAt)}</span>
</div>
{presentation.metadata.updatedAt !== presentation.metadata.createdAt && (
<div className="meta-item">
<span className="meta-label">Updated</span>
<span className="meta-value">{formatDate(presentation.metadata.updatedAt)}</span>
</div>
)}
</div>
</div>
<div className="card-footer">
<button
type="button"
className="card-action primary"
onClick={() => handleEditPresentation(presentation.metadata.id, presentation.slides.length)}
>
Edit
</button>
<button
type="button"
className="card-action secondary"
onClick={() => handleViewPresentation(presentation.metadata.id, presentation.slides.length)}
>
View
</button>
{presentation.slides.length > 0 && (
<button
type="button"
className="card-action secondary"
onClick={() => {
// TODO: Implement presentation mode
alert('Presentation mode coming soon!');
}}
>
Present
</button>
)}
</div>
</div>
))}
</div>
)}
{presentations.length > 0 && (
<div className="list-footer">
<div className="summary-stats">
<p>
{presentations.length} presentation{presentations.length !== 1 ? 's' : ''} {' '}
{presentations.reduce((total, p) => total + p.slides.length, 0)} total slides
</p>
</div>
<div className="footer-actions">
<button
onClick={loadPresentations}
className="action-button secondary"
>
Refresh
</button>
<Link
to="/presentations/new"
className="action-button primary"
>
Create New
</Link>
</div>
</div>
)}
</main>
</div>
);
};

View File

@ -0,0 +1,723 @@
.slide-editor {
min-height: 100vh;
background: #f8fafc;
display: flex;
flex-direction: column;
}
/* Header */
.slide-editor-header {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 0.75rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.editor-info {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
min-width: 0;
}
.back-button {
background: none;
border: none;
color: #64748b;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
transition: all 0.2s ease;
}
.back-button:hover {
background: #f1f5f9;
color: #334155;
}
.editor-title {
flex: 1;
min-width: 0;
}
.editor-title h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1e293b;
}
.editor-title p {
margin: 0.125rem 0 0 0;
color: #64748b;
font-size: 0.75rem;
}
.editor-actions {
display: flex;
gap: 0.75rem;
flex-shrink: 0;
}
.action-button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
font-size: 0.875rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.action-button.primary {
background: #3b82f6;
color: white;
}
.action-button.primary:hover:not(:disabled) {
background: #2563eb;
}
.action-button.secondary {
background: #f8fafc;
color: #64748b;
border: 1px solid #e2e8f0;
}
.action-button.secondary:hover:not(:disabled) {
background: #f1f5f9;
color: #475569;
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Main Content */
.slide-editor-content {
flex: 1;
padding: 2rem;
}
/* Step Header */
.step-header {
text-align: center;
margin-bottom: 1.5rem;
}
.step-header h2 {
margin: 0 0 0.25rem 0;
font-size: 1.25rem;
font-weight: 600;
color: #1e293b;
}
.step-header p {
margin: 0;
color: #64748b;
font-size: 0.875rem;
}
/* Layout Selection */
.layout-selection {
position: fixed;
top: 80px;
left: 0;
right: 0;
bottom: 0;
background: #f8fafc;
z-index: 10;
overflow-y: auto;
padding: 2rem;
box-sizing: border-box;
}
.layouts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
}
.layout-card {
background: white;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
}
.layout-card:hover {
border-color: #cbd5e1;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.layout-card.selected {
border-color: #3b82f6;
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.2);
}
.layout-preview {
height: 300px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
overflow: hidden;
position: relative;
}
.layout-rendered {
transform: scale(0.4);
transform-origin: top left;
width: 250%;
height: 250%;
pointer-events: none;
}
.layout-info {
padding: 1.5rem;
}
.layout-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
font-weight: 600;
color: #1e293b;
}
.layout-info p {
margin: 0 0 1rem 0;
color: #64748b;
font-size: 0.875rem;
line-height: 1.4;
}
.layout-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.slot-count {
font-size: 0.75rem;
font-weight: 500;
color: #6b7280;
background: #f1f5f9;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.slot-types {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.slot-type-badge {
font-size: 0.625rem;
font-weight: 500;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
text-transform: capitalize;
}
.slot-type-badge.title {
background-color: #fef3c7;
color: #92400e;
}
.slot-type-badge.subtitle {
background-color: #e0e7ff;
color: #3730a3;
}
.slot-type-badge.text {
background-color: #d1fae5;
color: #047857;
}
.slot-type-badge.image {
background-color: #fce7f3;
color: #be185d;
}
.slot-type-badge.video {
background-color: #ddd6fe;
color: #6b21a8;
}
.slot-type-badge.list {
background-color: #fed7d7;
color: #c53030;
}
/* Content Editing */
.content-editing {
/* Use absolute positioning to break out of all DOM constraints */
position: fixed;
top: 80px;
left: 0;
right: 0;
bottom: 0;
background: #f8fafc;
z-index: 10;
overflow: hidden;
}
.editing-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
align-items: stretch;
height: 100%;
width: 100%;
padding: 2rem;
box-sizing: border-box;
max-width: none;
}
.content-form {
background: white;
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
padding: 1.5rem;
overflow-y: auto;
height: 100%;
display: flex;
flex-direction: column;
}
.content-fields {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
overflow-y: auto;
min-height: 0;
}
.content-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-label {
font-weight: 500;
color: #374151;
font-size: 0.875rem;
}
.required {
color: #dc2626;
margin-left: 0.25rem;
}
.field-input,
.field-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
color: #374151;
background: white;
box-sizing: border-box;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.field-input:focus,
.field-textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.field-textarea {
resize: vertical;
min-height: 80px;
font-family: inherit;
}
.content-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
margin-top: 1rem;
}
.action-links {
display: flex;
align-items: center;
}
.action-buttons {
display: flex;
gap: 1rem;
}
.cancel-link {
background: none;
border: none;
color: #64748b;
font-size: 0.875rem;
font-weight: 400;
text-decoration: underline;
cursor: pointer;
padding: 0;
transition: color 0.2s ease;
}
.cancel-link:hover:not(:disabled) {
color: #374151;
text-decoration: underline;
}
.cancel-link:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.content-actions .action-button {
padding: 0.75rem 1.5rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
min-width: 120px;
}
.content-actions .action-button.primary {
background: #3b82f6;
color: white;
}
.content-actions .action-button.primary:hover:not(:disabled) {
background: #2563eb;
}
.content-actions .action-button.secondary {
background: #f8fafc;
color: #64748b;
border: 1px solid #e2e8f0;
}
.content-actions .action-button.secondary:hover:not(:disabled) {
background: #f1f5f9;
color: #475569;
}
.content-actions .action-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.field-hint {
margin: 0;
font-size: 0.75rem;
color: #6b7280;
font-style: italic;
}
/* Content Preview */
.content-preview {
background: white;
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
padding: 1rem;
height: 100%;
display: flex;
flex-direction: column;
}
.content-preview h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
font-weight: 600;
color: #1e293b;
text-align: center;
}
.preview-description {
margin: 0 0 1rem 0;
font-size: 0.75rem;
color: #64748b;
text-align: center;
font-style: italic;
}
.preview-container {
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
background: white;
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
}
.slide-preview-wrapper {
background: #f8fafc;
padding: 1rem;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
position: relative;
/* Ensure this container doesn't exceed available space */
max-height: 100%;
overflow: hidden;
}
/* Use the global aspect ratio classes for proper slide display */
.slide-preview-wrapper .slide-container {
/* Use a width-based approach and let aspect-ratio handle height */
width: min(80%, 70vw);
max-height: 60vh;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
border-radius: 0.5rem;
background: white;
border: 1px solid #e2e8f0;
overflow: hidden;
}
/* Override global aspect ratio classes for preview context */
.slide-preview-wrapper .slide-container.aspect-16-9 {
aspect-ratio: 16 / 9;
width: min(80%, min(70vw, 60vh * (16/9)));
}
.slide-preview-wrapper .slide-container.aspect-4-3 {
aspect-ratio: 4 / 3;
width: min(80%, min(70vw, 60vh * (4/3)));
}
.slide-preview-wrapper .slide-container.aspect-16-10 {
aspect-ratio: 16 / 10;
width: min(80%, min(70vw, 60vh * (16/10)));
}
.preview-meta {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
flex-wrap: wrap;
gap: 0.5rem;
}
.layout-name {
font-size: 0.75rem;
font-weight: 500;
color: #374151;
}
.aspect-ratio-info {
font-size: 0.625rem;
color: #3b82f6;
background: #eff6ff;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 500;
}
.content-count {
font-size: 0.625rem;
color: #6b7280;
background: #e5e7eb;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
/* Error State */
.editor-error {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.5rem;
padding: 1rem;
margin-top: 1rem;
color: #dc2626;
text-align: center;
}
.editor-error p {
margin: 0;
font-weight: 500;
}
/* Loading and Error States */
.loading-content,
.error-content,
.not-found-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 50vh;
text-align: center;
gap: 1rem;
}
.loading-spinner {
color: #64748b;
font-size: 1.125rem;
}
.error-content h2,
.not-found-content h2 {
color: #dc2626;
margin: 0;
}
.error-content p,
.not-found-content p {
color: #64748b;
margin: 0.5rem 0 1.5rem 0;
}
.back-link {
color: #3b82f6;
text-decoration: none;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: 1px solid #e2e8f0;
transition: all 0.2s ease;
}
.back-link:hover {
background: #eff6ff;
border-color: #3b82f6;
}
/* Responsive Design */
@media (max-width: 1024px) {
.content-editing {
top: 60px;
}
.layout-selection {
top: 60px;
}
.editing-layout {
grid-template-columns: 1fr;
gap: 1.5rem;
padding: 1rem;
}
.content-preview {
height: 300px;
min-height: 300px;
}
.slide-preview-wrapper {
padding: 0.5rem;
}
/* Adjust viewport calculations for smaller screens */
.slide-preview-wrapper .slide-container {
width: min(90%, 90vw);
max-height: 250px;
}
.slide-preview-wrapper .slide-container.aspect-16-9,
.slide-preview-wrapper .slide-container.aspect-4-3,
.slide-preview-wrapper .slide-container.aspect-16-10 {
width: min(90%, min(90vw, 250px * var(--aspect-multiplier, 1.78)));
}
}
@media (max-width: 768px) {
.content-editing {
top: 50px;
}
.layout-selection {
top: 50px;
padding: 1rem;
}
.slide-editor-header {
padding: 0.5rem 1rem;
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
.editor-info {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.editor-actions {
justify-content: stretch;
}
.action-button {
flex: 1;
}
.slide-editor-content {
padding: 1rem;
}
.layouts-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.content-form {
padding: 1rem;
}
.content-actions {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.action-links {
justify-content: center;
order: 2;
}
.action-buttons {
justify-content: stretch;
order: 1;
}
.action-buttons .action-button {
flex: 1;
}
.preview-container {
min-height: 200px;
}
}

View File

@ -0,0 +1,434 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import type { Presentation, SlideContent } from '../../types/presentation';
import type { Theme, SlideLayout } from '../../types/theme';
import { getPresentationById, updatePresentation } from '../../utils/presentationStorage';
import { getTheme } from '../../themes';
import { renderTemplateWithSampleData } from '../../utils/templateRenderer';
import './SlideEditor.css';
export const SlideEditor: React.FC = () => {
const { presentationId, slideId } = useParams<{
presentationId: string;
slideId: string;
}>();
const navigate = useNavigate();
const [presentation, setPresentation] = useState<Presentation | null>(null);
const [theme, setTheme] = useState<Theme | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const isEditingExisting = slideId !== 'new';
// Editor state
const [selectedLayout, setSelectedLayout] = useState<SlideLayout | null>(null);
const [slideContent, setSlideContent] = useState<Record<string, string>>({});
const [slideNotes, setSlideNotes] = useState('');
const [currentStep, setCurrentStep] = useState<'layout' | 'content'>(isEditingExisting ? 'content' : 'layout');
const existingSlide = isEditingExisting && presentation
? presentation.slides.find(s => s.id === slideId)
: null;
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);
// If editing existing slide, populate data
if (isEditingExisting && slideId !== 'new') {
const slide = presentationData.slides.find(s => s.id === slideId);
if (slide) {
const layout = themeData.layouts.find(l => l.id === slide.layoutId);
if (layout) {
setSelectedLayout(layout);
setSlideContent(slide.content);
setSlideNotes(slide.notes || '');
// No need to set currentStep here since it's already 'content' for existing slides
}
} else {
setError(`Slide not found: ${slideId}`);
return;
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load presentation');
} finally {
setLoading(false);
}
};
loadPresentationAndTheme();
}, [presentationId, slideId, isEditingExisting]);
// Load theme CSS for layout previews
useEffect(() => {
if (theme) {
const themeStyleId = 'slide-editor-theme-style';
let existingStyle = document.getElementById(themeStyleId);
if (existingStyle) {
existingStyle.remove();
}
const link = document.createElement('link');
link.id = themeStyleId;
link.rel = 'stylesheet';
link.href = `${theme.basePath}/${theme.cssFile}`;
document.head.appendChild(link);
return () => {
const styleToRemove = document.getElementById(themeStyleId);
if (styleToRemove) {
styleToRemove.remove();
}
};
}
}, [theme]);
const selectLayout = (layout: SlideLayout) => {
setSelectedLayout(layout);
// Initialize content with empty values for all slots
const initialContent: Record<string, string> = {};
layout.slots.forEach(slot => {
initialContent[slot.id] = slideContent[slot.id] || '';
});
setSlideContent(initialContent);
// Automatically move to content editing after layout selection
setCurrentStep('content');
};
const updateSlotContent = (slotId: string, content: string) => {
setSlideContent(prev => ({
...prev,
[slotId]: content
}));
};
const saveSlide = async () => {
if (!presentation || !selectedLayout) return;
try {
setSaving(true);
setError(null);
const slideData: SlideContent = {
id: isEditingExisting ? slideId! : `slide_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
layoutId: selectedLayout.id,
content: slideContent,
notes: slideNotes,
order: isEditingExisting
? (existingSlide?.order ?? presentation.slides.length)
: presentation.slides.length
};
const updatedPresentation = { ...presentation };
if (isEditingExisting) {
// Update existing slide
const slideIndex = updatedPresentation.slides.findIndex(s => s.id === slideId);
if (slideIndex !== -1) {
updatedPresentation.slides[slideIndex] = slideData;
}
} else {
// Add new slide
updatedPresentation.slides.push(slideData);
}
await updatePresentation(updatedPresentation);
// Navigate back to editor with the updated slide
const slideNumber = isEditingExisting
? (updatedPresentation.slides.findIndex(s => s.id === slideData.id) + 1)
: updatedPresentation.slides.length;
navigate(`/presentations/${presentationId}/edit/slides/${slideNumber}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save slide');
console.error('Error saving slide:', err);
} finally {
setSaving(false);
}
};
const cancelEditing = () => {
if (isEditingExisting) {
const slideIndex = presentation?.slides.findIndex(s => s.id === slideId) ?? 0;
const slideNumber = slideIndex + 1;
navigate(`/presentations/${presentationId}/edit/slides/${slideNumber}`);
} else {
navigate(`/presentations/${presentationId}/edit/slides/1`);
}
};
if (loading) {
return (
<div className="slide-editor">
<div className="loading-content">
<div className="loading-spinner">Loading slide editor...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="slide-editor">
<div className="error-content">
<h2>Error Loading Slide Editor</h2>
<p>{error}</p>
<Link to="/themes" className="back-link"> Back to Themes</Link>
</div>
</div>
);
}
if (!presentation || !theme) {
return (
<div className="slide-editor">
<div className="not-found-content">
<h2>Presentation Not Found</h2>
<p>The requested presentation could not be found.</p>
<Link to="/themes" className="back-link"> Back to Themes</Link>
</div>
</div>
);
}
return (
<div className="slide-editor">
<header className="slide-editor-header">
<div className="editor-info">
<button
onClick={cancelEditing}
className="back-button"
type="button"
>
Back to Presentation
</button>
<div className="editor-title">
<h1>{isEditingExisting ? 'Edit Slide' : 'Add New Slide'}</h1>
<p>{presentation.metadata.name} {theme.name} theme</p>
</div>
</div>
<div className="editor-actions">
<button
type="button"
className="action-button primary"
onClick={saveSlide}
disabled={!selectedLayout || saving}
>
{saving ? 'Saving...' : (isEditingExisting ? 'Update Slide' : 'Add Slide')}
</button>
</div>
</header>
<main className="slide-editor-content">
{currentStep === 'layout' && (
<div className="layout-selection">
<div className="step-header">
<h2>Choose a Layout</h2>
<p>Select the layout that best fits your content</p>
</div>
<div className="layouts-grid">
{theme.layouts.map((layout) => (
<div
key={layout.id}
className={`layout-card ${selectedLayout?.id === layout.id ? 'selected' : ''}`}
onClick={() => selectLayout(layout)}
>
<div className="layout-preview">
<div
className="layout-rendered"
dangerouslySetInnerHTML={{
__html: renderTemplateWithSampleData(layout.htmlTemplate, layout)
}}
/>
</div>
<div className="layout-info">
<h3>{layout.name}</h3>
<p>{layout.description}</p>
<div className="layout-meta">
<span className="slot-count">{layout.slots.length} slots</span>
<div className="slot-types">
{Array.from(new Set(layout.slots.map(slot => slot.type))).map(type => (
<span key={type} className={`slot-type-badge ${type}`}>
{type}
</span>
))}
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{currentStep === 'content' && selectedLayout && presentation && (
<div className="content-editing">
<div className="editing-layout">
<div className="content-form">
<div className="step-header">
<h2>Edit Slide Content</h2>
<p>Fill in the content for your {selectedLayout.name} slide</p>
</div>
<div className="content-fields">
{selectedLayout.slots.map((slot) => (
<div key={slot.id} className="content-field">
<label htmlFor={slot.id} className="field-label">
{slot.id.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
{slot.required && <span className="required">*</span>}
</label>
{slot.type === 'text' && slot.id.includes('content') ? (
<textarea
id={slot.id}
value={slideContent[slot.id] || ''}
onChange={(e) => updateSlotContent(slot.id, e.target.value)}
placeholder={slot.placeholder || `Enter ${slot.id}`}
className="field-textarea"
rows={4}
/>
) : (
<input
id={slot.id}
type={slot.type === 'image' ? 'url' : 'text'}
value={slideContent[slot.id] || ''}
onChange={(e) => updateSlotContent(slot.id, e.target.value)}
placeholder={slot.placeholder || `Enter ${slot.id}`}
className="field-input"
/>
)}
{slot.placeholder && (
<p className="field-hint">{slot.placeholder}</p>
)}
</div>
))}
<div className="content-field">
<label htmlFor="slide-notes" className="field-label">
Speaker Notes
</label>
<textarea
id="slide-notes"
value={slideNotes}
onChange={(e) => setSlideNotes(e.target.value)}
placeholder="Add notes for this slide (optional)"
className="field-textarea"
rows={3}
/>
</div>
<div className="content-actions">
<div className="action-links">
<button
type="button"
className="cancel-link"
onClick={cancelEditing}
disabled={saving}
>
Cancel editing
</button>
</div>
<div className="action-buttons">
<button
type="button"
className="action-button primary"
onClick={saveSlide}
disabled={saving}
>
{saving ? 'Saving...' : (isEditingExisting ? 'Update Slide' : 'Save Slide')}
</button>
</div>
</div>
</div>
</div>
<div className="content-preview">
<h3>Live Preview</h3>
<p className="preview-description">
Updates automatically as you type
</p>
<div className="preview-container">
<div className="slide-preview-wrapper">
<div
className={`slide-container ${presentation.metadata.aspectRatio ? `aspect-${presentation.metadata.aspectRatio.replace(':', '-')}` : 'aspect-16-9'}`}
dangerouslySetInnerHTML={{
__html: renderTemplateWithContent(selectedLayout, slideContent)
}}
/>
</div>
<div className="preview-meta">
<span className="layout-name">{selectedLayout.name}</span>
<span className="aspect-ratio-info">
{presentation.metadata.aspectRatio || '16:9'} aspect ratio
</span>
<span className="content-count">
{Object.values(slideContent).filter(v => v.trim()).length} / {selectedLayout.slots.length} slots filled
</span>
</div>
</div>
</div>
</div>
</div>
)}
{error && (
<div className="editor-error">
<p>Error: {error}</p>
</div>
)}
</main>
</div>
);
};
// Helper function to render template with actual content
const renderTemplateWithContent = (layout: SlideLayout, content: Record<string, string>): string => {
let rendered = layout.htmlTemplate;
// Replace content placeholders
Object.entries(content).forEach(([slotId, value]) => {
const placeholder = new RegExp(`\\{\\{${slotId}\\}\\}`, 'g');
rendered = rendered.replace(placeholder, value || '');
});
// Clean up any remaining placeholders
rendered = rendered.replace(/\{\{[^}]+\}\}/g, '');
return rendered;
};

View File

@ -0,0 +1,204 @@
.theme-selector {
width: 100%;
}
.themes-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.5rem;
}
.theme-card {
background: white;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.theme-card:hover {
border-color: #cbd5e1;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.theme-card.selected {
border-color: #22c55e;
box-shadow: 0 4px 6px -1px rgba(34, 197, 94, 0.2);
}
.theme-preview {
position: relative;
height: 120px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.color-preview-strip {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
.color-swatch {
width: 48px;
height: 48px;
border-radius: 50%;
border: 3px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
.no-colors {
color: #9ca3af;
font-size: 0.875rem;
text-align: center;
font-style: italic;
}
.selection-indicator {
position: absolute;
top: 0.75rem;
right: 0.75rem;
z-index: 10;
}
.theme-info {
padding: 1.5rem;
}
.theme-name {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
font-weight: 600;
color: #1e293b;
}
.theme-description {
margin: 0 0 1rem 0;
color: #64748b;
font-size: 0.875rem;
line-height: 1.4;
}
.theme-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.theme-stats {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
}
.layouts-count {
font-weight: 500;
color: #475569;
}
.theme-author {
color: #6b7280;
}
.theme-version {
color: #9ca3af;
font-family: 'Courier New', monospace;
}
.theme-actions {
display: flex;
gap: 0.5rem;
}
.preview-link {
color: #3b82f6;
text-decoration: none;
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: background-color 0.2s ease;
}
.preview-link:hover {
background: #eff6ff;
text-decoration: none;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.empty-state h3 {
margin: 0 0 0.5rem 0;
color: #374151;
}
.empty-state p {
margin: 0;
font-size: 0.875rem;
}
/* Responsive Design */
@media (max-width: 768px) {
.themes-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.theme-card {
min-width: 0;
}
.theme-preview {
height: 100px;
padding: 0.75rem;
}
.color-swatch {
width: 36px;
height: 36px;
}
.theme-info {
padding: 1rem;
}
.theme-meta {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
}
/* Focus states for accessibility */
.theme-card:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Animation for selection */
@keyframes selectPulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
.theme-card.selected .selection-indicator {
animation: selectPulse 0.3s ease-out;
}

View File

@ -0,0 +1,105 @@
import React from 'react';
import { Link } from 'react-router-dom';
import type { Theme } from '../../types/theme';
import './ThemeSelector.css';
interface ThemeSelectorProps {
themes: Theme[];
selectedTheme: Theme | null;
onThemeSelect: (theme: Theme) => void;
}
export const ThemeSelector: React.FC<ThemeSelectorProps> = ({
themes,
selectedTheme,
onThemeSelect
}) => {
return (
<div className="theme-selector">
<div className="themes-grid">
{themes.map((theme) => (
<div
key={theme.id}
className={`theme-card ${selectedTheme?.id === theme.id ? 'selected' : ''}`}
onClick={() => onThemeSelect(theme)}
>
<div className="theme-preview">
{/* Color palette preview */}
<div className="color-preview-strip">
{theme.variables && Object.entries(theme.variables)
.filter(([_, value]) => value.startsWith('#') || value.includes('rgb') || value.includes('hsl'))
.slice(0, 4)
.map(([key, value]) => (
<div
key={key}
className="color-swatch"
style={{ backgroundColor: value }}
title={`--${key}: ${value}`}
/>
))
}
{(!theme.variables || Object.entries(theme.variables).filter(([_, value]) =>
value.startsWith('#') || value.includes('rgb') || value.includes('hsl')
).length === 0) && (
<div className="no-colors">No colors defined</div>
)}
</div>
{/* Selection indicator */}
{selectedTheme?.id === theme.id && (
<div className="selection-indicator">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="12" fill="#22c55e"/>
<path
d="m9 12 2 2 4-4"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
)}
</div>
<div className="theme-info">
<h3 className="theme-name">{theme.name}</h3>
<p className="theme-description">{theme.description}</p>
<div className="theme-meta">
<div className="theme-stats">
<span className="layouts-count">
{theme.layouts.length} layout{theme.layouts.length !== 1 ? 's' : ''}
</span>
{theme.author && (
<span className="theme-author">by {theme.author}</span>
)}
{theme.version && (
<span className="theme-version">v{theme.version}</span>
)}
</div>
<div className="theme-actions">
<Link
to={`/themes/${theme.id}`}
className="preview-link"
onClick={(e) => e.stopPropagation()}
>
Preview
</Link>
</div>
</div>
</div>
</div>
))}
</div>
{themes.length === 0 && (
<div className="empty-state">
<h3>No Themes Available</h3>
<p>No themes could be loaded. Please check your theme configuration.</p>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,6 @@
export { NewPresentationPage } from './NewPresentationPage';
export { PresentationViewer } from './PresentationViewer';
export { PresentationEditor } from './PresentationEditor';
export { SlideEditor } from './SlideEditor';
export { ThemeSelector } from './ThemeSelector';
export { PresentationsList } from './PresentationsList';

119
src/styles/aspectRatios.css Normal file
View File

@ -0,0 +1,119 @@
/**
* Global Aspect Ratio CSS Classes for Presentation Theme Engine
* These classes are automatically applied to slides based on presentation settings
*/
/* Base slide container - all themes should use this */
.slide-container {
position: relative;
margin: 0 auto;
background: white;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
border-radius: 8px;
overflow: hidden;
}
/* 16:9 Widescreen - Modern standard */
.slide-container.aspect-16-9 {
aspect-ratio: 16 / 9;
max-width: 1920px;
width: 100%;
}
/* 4:3 Standard - Classic format */
.slide-container.aspect-4-3 {
aspect-ratio: 4 / 3;
max-width: 1024px;
width: 100%;
}
/* 16:10 Wide - Professional displays */
.slide-container.aspect-16-10 {
aspect-ratio: 16 / 10;
max-width: 1920px;
width: 100%;
}
/* Responsive scaling for different contexts */
/* Preview mode - smaller scale for editing */
.slide-preview .slide-container {
max-width: 800px;
}
/* Thumbnail mode - very small scale for sidebar */
.slide-thumbnail .slide-container {
max-width: 200px;
}
/* Presentation mode - full screen */
.presentation-mode .slide-container {
max-width: 100vw;
max-height: 100vh;
width: auto;
height: auto;
}
/* Ensure content fits within aspect ratio */
.slide-container.aspect-16-9 .slide-content {
width: 100%;
height: 100%;
padding: 5.56%; /* 1920/1080 ratio padding */
box-sizing: border-box;
}
.slide-container.aspect-4-3 .slide-content {
width: 100%;
height: 100%;
padding: 6.25%; /* 1024/768 ratio padding */
box-sizing: border-box;
}
.slide-container.aspect-16-10 .slide-content {
width: 100%;
height: 100%;
padding: 5%; /* 1920/1200 ratio padding */
box-sizing: border-box;
}
/* Layout slots should respect aspect ratio boundaries */
.slide-container .layout-slot {
position: relative;
overflow: hidden;
}
/* Print styles - maintain aspect ratios */
@media print {
.slide-container {
page-break-inside: avoid;
box-shadow: none;
border: 1px solid #000;
}
.slide-container.aspect-16-9 {
width: 10in;
height: 5.625in;
}
.slide-container.aspect-4-3 {
width: 10in;
height: 7.5in;
}
.slide-container.aspect-16-10 {
width: 10in;
height: 6.25in;
}
}
/* Mobile responsive */
@media (max-width: 768px) {
.slide-container {
max-width: 100%;
margin: 0;
}
.slide-container .slide-content {
padding: 4%;
}
}

71
src/types/presentation.ts Normal file
View File

@ -0,0 +1,71 @@
export type AspectRatio = '16:9' | '4:3' | '16:10';
export interface AspectRatioConfig {
id: AspectRatio;
name: string;
description: string;
width: number;
height: number;
cssClass: string;
common: boolean;
}
export const ASPECT_RATIOS: AspectRatioConfig[] = [
{
id: '16:9',
name: 'Widescreen (16:9)',
description: 'Modern standard for HD displays and projectors',
width: 1920,
height: 1080,
cssClass: 'aspect-16-9',
common: true
},
{
id: '4:3',
name: 'Standard (4:3)',
description: 'Classic presentation format, good for older projectors',
width: 1024,
height: 768,
cssClass: 'aspect-4-3',
common: true
},
{
id: '16:10',
name: 'Wide (16:10)',
description: 'Popular for laptops and some professional displays',
width: 1920,
height: 1200,
cssClass: 'aspect-16-10',
common: true
}
];
export interface PresentationMetadata {
id: string;
name: string;
description: string;
theme: string;
aspectRatio: AspectRatio;
createdAt: string;
updatedAt: string;
}
export interface SlideContent {
id: string;
layoutId: string;
content: Record<string, string>; // slot_id -> content mapping
notes?: string;
order: number;
}
export interface Presentation {
metadata: PresentationMetadata;
slides: SlideContent[];
}
export interface CreatePresentationRequest {
name: string;
description: string;
theme: string;
aspectRatio: AspectRatio;
}

View File

@ -0,0 +1,214 @@
import type { Presentation, PresentationMetadata, CreatePresentationRequest } from '../types/presentation';
const DB_NAME = 'SlideshareDB';
const DB_VERSION = 1;
const PRESENTATIONS_STORE = 'presentations';
/**
* Initialize IndexedDB database
*/
export const initializeDB = (): Promise<IDBDatabase> => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
reject(new Error('Failed to open IndexedDB database'));
};
request.onsuccess = () => {
resolve(request.result);
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create presentations object store
if (!db.objectStoreNames.contains(PRESENTATIONS_STORE)) {
const store = db.createObjectStore(PRESENTATIONS_STORE, { keyPath: 'metadata.id' });
// Create indexes for efficient querying
store.createIndex('name', 'metadata.name', { unique: false });
store.createIndex('theme', 'metadata.theme', { unique: false });
store.createIndex('createdAt', 'metadata.createdAt', { unique: false });
store.createIndex('updatedAt', 'metadata.updatedAt', { unique: false });
}
};
});
};
/**
* Generate a unique ID for presentations
*/
const generatePresentationId = (): string => {
return `presentation_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};
/**
* Create a new presentation
*/
export const createPresentation = async (request: CreatePresentationRequest): Promise<Presentation> => {
const db = await initializeDB();
const now = new Date().toISOString();
const presentation: Presentation = {
metadata: {
id: generatePresentationId(),
name: request.name,
description: request.description,
theme: request.theme,
aspectRatio: request.aspectRatio,
createdAt: now,
updatedAt: now
},
slides: []
};
return new Promise((resolve, reject) => {
const transaction = db.transaction([PRESENTATIONS_STORE], 'readwrite');
const store = transaction.objectStore(PRESENTATIONS_STORE);
const request = store.add(presentation);
request.onerror = () => {
reject(new Error('Failed to create presentation'));
};
request.onsuccess = () => {
resolve(presentation);
};
transaction.onerror = () => {
reject(new Error('Transaction failed while creating presentation'));
};
});
};
/**
* Get all presentations
*/
export const getAllPresentations = async (): Promise<Presentation[]> => {
const db = await initializeDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([PRESENTATIONS_STORE], 'readonly');
const store = transaction.objectStore(PRESENTATIONS_STORE);
const request = store.getAll();
request.onerror = () => {
reject(new Error('Failed to retrieve presentations'));
};
request.onsuccess = () => {
// Sort by creation date (newest first)
const presentations = request.result.sort((a: Presentation, b: Presentation) =>
new Date(b.metadata.createdAt).getTime() - new Date(a.metadata.createdAt).getTime()
);
resolve(presentations);
};
});
};
/**
* Get a presentation by ID
*/
export const getPresentationById = async (id: string): Promise<Presentation | null> => {
const db = await initializeDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([PRESENTATIONS_STORE], 'readonly');
const store = transaction.objectStore(PRESENTATIONS_STORE);
const request = store.get(id);
request.onerror = () => {
reject(new Error('Failed to retrieve presentation'));
};
request.onsuccess = () => {
resolve(request.result || null);
};
});
};
/**
* Update a presentation
*/
export const updatePresentation = async (presentation: Presentation): Promise<Presentation> => {
const db = await initializeDB();
// Update the updatedAt timestamp
presentation.metadata.updatedAt = new Date().toISOString();
return new Promise((resolve, reject) => {
const transaction = db.transaction([PRESENTATIONS_STORE], 'readwrite');
const store = transaction.objectStore(PRESENTATIONS_STORE);
const request = store.put(presentation);
request.onerror = () => {
reject(new Error('Failed to update presentation'));
};
request.onsuccess = () => {
resolve(presentation);
};
});
};
/**
* Delete a presentation
*/
export const deletePresentation = async (id: string): Promise<void> => {
const db = await initializeDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([PRESENTATIONS_STORE], 'readwrite');
const store = transaction.objectStore(PRESENTATIONS_STORE);
const request = store.delete(id);
request.onerror = () => {
reject(new Error('Failed to delete presentation'));
};
request.onsuccess = () => {
resolve();
};
});
};
/**
* Get presentations by theme
*/
export const getPresentationsByTheme = async (theme: string): Promise<Presentation[]> => {
const db = await initializeDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([PRESENTATIONS_STORE], 'readonly');
const store = transaction.objectStore(PRESENTATIONS_STORE);
const index = store.index('theme');
const request = index.getAll(theme);
request.onerror = () => {
reject(new Error('Failed to retrieve presentations by theme'));
};
request.onsuccess = () => {
// Sort by creation date (newest first)
const presentations = request.result.sort((a: Presentation, b: Presentation) =>
new Date(b.metadata.createdAt).getTime() - new Date(a.metadata.createdAt).getTime()
);
resolve(presentations);
};
});
};
/**
* Get presentation metadata only (for listing views)
*/
export const getPresentationMetadata = async (): Promise<PresentationMetadata[]> => {
const presentations = await getAllPresentations();
return presentations.map(p => p.metadata);
};

View File

@ -23,6 +23,45 @@ const createImageSVG = (slotName: string, width: number = 400, height: number =
return `data:image/svg+xml,${encodedSVG}`;
};
/**
* Sample content templates for more realistic previews
*/
const SAMPLE_CONTENT = {
title: [
'Quarterly Sales Review',
'Project Roadmap 2024',
'Market Analysis Report',
'New Product Launch',
'Team Performance Update'
],
subtitle: [
'Q4 Results and Future Outlook',
'Strategic Planning and Key Milestones',
'Customer Insights and Trends',
'Innovation and Growth Strategy',
'Achievements and Next Steps'
],
heading: [
'Key Findings',
'Next Steps',
'Executive Summary',
'Strategic Goals',
'Market Opportunities'
],
text: [
'This comprehensive analysis provides insights into market trends and customer behavior patterns that will shape our strategic decisions moving forward.',
'Our team has achieved significant milestones this quarter, demonstrating strong execution and commitment to delivering exceptional results.',
'The data shows promising growth opportunities in emerging markets, with particular strength in the technology and healthcare sectors.',
'Customer feedback indicates high satisfaction levels with our current offerings, while highlighting areas for continued innovation.',
'Looking ahead, we will focus on expanding our market presence while maintaining our commitment to quality and customer excellence.'
],
list: [
'• Increase market share by 15%\n• Launch 3 new products\n• Expand to 5 new regions',
'• Improve customer satisfaction\n• Reduce operational costs\n• Enhance digital capabilities',
'• Strengthen brand recognition\n• Optimize supply chain\n• Invest in talent development'
]
};
/**
* Generates sample data for a slot based on its configuration
*/
@ -43,10 +82,20 @@ export const generateSampleDataForSlot = (slot: SlotConfig): string => {
return createImageSVG(slotDisplayName);
case 'title':
const titleSamples = SAMPLE_CONTENT.title;
return titleSamples[Math.floor(Math.random() * titleSamples.length)];
case 'subtitle':
case 'text':
const subtitleSamples = SAMPLE_CONTENT.subtitle;
return subtitleSamples[Math.floor(Math.random() * subtitleSamples.length)];
case 'heading':
return slotDisplayName;
const headingSamples = SAMPLE_CONTENT.heading;
return headingSamples[Math.floor(Math.random() * headingSamples.length)];
case 'text':
const textSamples = SAMPLE_CONTENT.text;
return textSamples[Math.floor(Math.random() * textSamples.length)];
case 'video':
return createImageSVG(`${slotDisplayName} (Video)`, 640, 360);
@ -55,7 +104,8 @@ export const generateSampleDataForSlot = (slot: SlotConfig): string => {
return `[${slotDisplayName} Audio]`;
case 'list':
return `${slotDisplayName} Item 1\n• ${slotDisplayName} Item 2\n• ${slotDisplayName} Item 3`;
const listSamples = SAMPLE_CONTENT.list;
return listSamples[Math.floor(Math.random() * listSamples.length)];
case 'code':
return `// ${slotDisplayName}\nfunction ${id.replace(/-/g, '')}() {\n return "${slotDisplayName}";\n}`;