Compare commits
6 Commits
98958b2cb3
...
4ce9f225a6
Author | SHA1 | Date | |
---|---|---|---|
4ce9f225a6 | |||
79060f0945 | |||
7b262f398c | |||
2a905d50e0 | |||
92672f77e3 | |||
9b0b16969f |
62
USERFLOWS.md
Normal file
62
USERFLOWS.md
Normal 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
|
@ -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"
|
||||
}
|
13
public/themes/default/layouts/2-content-blocks.html
Normal file
13
public/themes/default/layouts/2-content-blocks.html
Normal 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>
|
@ -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 */
|
||||
}
|
||||
|
81
src/App.css
81
src/App.css
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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 />} />
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
382
src/components/presentations/NewPresentationPage.css
Normal file
382
src/components/presentations/NewPresentationPage.css
Normal 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;
|
||||
}
|
||||
}
|
237
src/components/presentations/NewPresentationPage.tsx
Normal file
237
src/components/presentations/NewPresentationPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
754
src/components/presentations/PresentationEditor.css
Normal file
754
src/components/presentations/PresentationEditor.css
Normal 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;
|
||||
}
|
||||
}
|
513
src/components/presentations/PresentationEditor.tsx
Normal file
513
src/components/presentations/PresentationEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
390
src/components/presentations/PresentationViewer.css
Normal file
390
src/components/presentations/PresentationViewer.css
Normal 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;
|
||||
}
|
||||
}
|
249
src/components/presentations/PresentationViewer.tsx
Normal file
249
src/components/presentations/PresentationViewer.tsx
Normal 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>
|
||||
);
|
||||
};
|
430
src/components/presentations/PresentationsList.css
Normal file
430
src/components/presentations/PresentationsList.css
Normal 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;
|
||||
}
|
||||
}
|
256
src/components/presentations/PresentationsList.tsx
Normal file
256
src/components/presentations/PresentationsList.tsx
Normal 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>
|
||||
);
|
||||
};
|
723
src/components/presentations/SlideEditor.css
Normal file
723
src/components/presentations/SlideEditor.css
Normal 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;
|
||||
}
|
||||
}
|
434
src/components/presentations/SlideEditor.tsx
Normal file
434
src/components/presentations/SlideEditor.tsx
Normal 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;
|
||||
};
|
204
src/components/presentations/ThemeSelector.css
Normal file
204
src/components/presentations/ThemeSelector.css
Normal 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;
|
||||
}
|
105
src/components/presentations/ThemeSelector.tsx
Normal file
105
src/components/presentations/ThemeSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
6
src/components/presentations/index.ts
Normal file
6
src/components/presentations/index.ts
Normal 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
119
src/styles/aspectRatios.css
Normal 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
71
src/types/presentation.ts
Normal 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;
|
||||
}
|
214
src/utils/presentationStorage.ts
Normal file
214
src/utils/presentationStorage.ts
Normal 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);
|
||||
};
|
@ -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}`;
|
||||
|
Loading…
Reference in New Issue
Block a user