Compare commits
4 Commits
4ce9f225a6
...
1008bd4bca
Author | SHA1 | Date | |
---|---|---|---|
1008bd4bca | |||
8376e77df7 | |||
d88ae6dcc3 | |||
d0e70f81e7 |
60
CLAUDE.md
60
CLAUDE.md
@ -53,6 +53,9 @@
|
||||
|
||||
# General Claude Guidelines
|
||||
- Don't run npm commands, just tell me what to run and I'll run them myself
|
||||
- generally tsx files should be used for React components and they should be less than 200 lines of code
|
||||
- if a file I should be warned when I ask for a code audit
|
||||
- if a function or method is more than 50 lines of code, I should be warned
|
||||
|
||||
# Architecture
|
||||
## Themes
|
||||
@ -62,4 +65,59 @@
|
||||
- don't try to run command to restart, just tell me the command when necessary
|
||||
- Add to memory. Just tell me to run build, I'll paste in results, don't try to run the command
|
||||
- Add to memory. I want to make sure this approach is generally followed
|
||||
- remember to always use introspection when browsing themes and details
|
||||
- remember to always use introspection when browsing themes and details
|
||||
|
||||
# CSS & Component Architecture Guidelines
|
||||
|
||||
## Modal Components
|
||||
- **Follow existing Modal.tsx pattern** - Use simple overlay → content structure
|
||||
- **Single flex container** for centering - Don't nest multiple flex containers competing for layout
|
||||
- **Proper click handling** - Use `event.target === event.currentTarget` for overlay clicks
|
||||
- **Existing UI patterns** - Check components/ui/ folder before creating new modal patterns
|
||||
|
||||
## Slide Rendering & Aspect Ratios
|
||||
- **Reuse established selectors** - `.slide-preview-wrapper .slide-container.aspect-X-X` pattern is proven
|
||||
- **Don't duplicate aspect ratio CSS** - Always extend existing aspect ratio classes, never recreate
|
||||
- **Theme CSS inheritance** - Theme CSS is loaded globally in SlideEditor, modals inherit this automatically
|
||||
- **Check SlideEditor CSS** - Before creating slide display components, see how SlideEditor handles aspect ratios
|
||||
|
||||
## CSS Architecture Principles
|
||||
- **NEVER duplicate CSS** - Search for existing classes before writing new ones
|
||||
- **Avoid nested flex competitions** - Don't create competing flex containers at different levels
|
||||
- **Use semantic class hierarchies** - `.parent-context .slide-container.modifier` pattern works well
|
||||
- **Simplify before adding complexity** - Start with minimal structure, add layers only when needed
|
||||
|
||||
## Component Reuse Checks
|
||||
- **Search existing components** - Use Grep to find similar patterns before coding
|
||||
- **Check shared/ folders** - Look for reusable UI components first
|
||||
- **Extend, don't recreate** - Build on existing patterns rather than starting fresh
|
||||
- **Test integration early** - Verify new components work with existing theme/CSS systems
|
||||
|
||||
# Referenced Coding Standards Files
|
||||
**Always consult these project standards before coding:**
|
||||
|
||||
## IMPORT_STANDARDS.md
|
||||
- **NEVER use barrel files** for component imports (violates Vite best practices)
|
||||
- **ALWAYS use direct file imports** with explicit .tsx extensions
|
||||
- **Example**: `import { Component } from './components/Component.tsx'` ✅
|
||||
- **Avoid**: `import { Component } from './components'` ❌
|
||||
- Performance: Direct imports enable Vite tree shaking and faster module resolution
|
||||
|
||||
## ERROR_HANDLING_STANDARDS.md
|
||||
- **Consistent async patterns** with try/catch/finally and proper loading states
|
||||
- **Replace browser dialogs** - Use AlertDialog/ConfirmDialog instead of alert()/confirm()
|
||||
- **Standard error types**: User errors (recoverable), System errors (technical), Critical errors (unrecoverable)
|
||||
- **useAsyncOperation hook pattern** for consistent error/loading state management
|
||||
- **Error boundaries** required for component-level error handling
|
||||
|
||||
## REACT19_IMPLEMENTATION.md
|
||||
- **Use Actions for form handling** - Replace manual state management with useActionState
|
||||
- **useOptimistic for theme previews** - Implement optimistic updates for better UX
|
||||
- **Modern Context syntax** - Use direct Context rendering instead of Provider wrapper
|
||||
- **Error boundaries with Actions** - Implement proper error handling for React 19 patterns
|
||||
|
||||
# Workflow Reminders
|
||||
- **Check all .md standards** before starting any component work
|
||||
- **Use direct imports** with .tsx extensions per IMPORT_STANDARDS.md
|
||||
- **Implement proper error handling** per ERROR_HANDLING_STANDARDS.md
|
||||
- **Consider React 19 patterns** per REACT19_IMPLEMENTATION.md for new features
|
@ -47,9 +47,9 @@
|
||||
- [ ] 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
|
||||
- [x] User can delete slides from presentation
|
||||
- [x] User gets confirmation before slide deletion
|
||||
- [x] Slide order adjusts automatically
|
||||
|
||||
### Preview Slides
|
||||
- [ ] User can preview individual slides
|
||||
|
28
package-lock.json
generated
28
package-lock.json
generated
@ -8,6 +8,7 @@
|
||||
"name": "slideshare",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.6",
|
||||
"loglevel": "^1.9.2",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.1.1",
|
||||
@ -16,6 +17,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
@ -1396,6 +1398,16 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@ -1440,6 +1452,13 @@
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz",
|
||||
@ -1997,6 +2016,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
|
||||
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.207",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.207.tgz",
|
||||
|
@ -11,6 +11,7 @@
|
||||
"generate-manifest": "node scripts/generate-themes-manifest.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.6",
|
||||
"loglevel": "^1.9.2",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.1.1",
|
||||
@ -19,6 +20,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
|
@ -12,5 +12,5 @@
|
||||
"hasMasterSlide": true
|
||||
}
|
||||
},
|
||||
"generated": "2025-08-20T22:06:06.798Z"
|
||||
"generated": "2025-08-21T11:51:03.695Z"
|
||||
}
|
15
src/App.tsx
15
src/App.tsx
@ -1,8 +1,15 @@
|
||||
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 { ThemeBrowser } from './components/themes/ThemeBrowser.tsx';
|
||||
import { ThemeDetailPage } from './components/themes/ThemeDetailPage.tsx';
|
||||
import { LayoutDetailPage } from './components/themes/LayoutDetailPage.tsx';
|
||||
import { LayoutPreviewPage } from './components/themes/LayoutPreviewPage.tsx';
|
||||
import { NewPresentationPage } from './components/presentations/NewPresentationPage.tsx';
|
||||
import { PresentationViewer } from './components/presentations/PresentationViewer.tsx';
|
||||
import { PresentationEditor } from './components/presentations/PresentationEditor.tsx';
|
||||
import { SlideEditor } from './components/presentations/SlideEditor.tsx';
|
||||
import { PresentationsList } from './components/presentations/PresentationsList.tsx';
|
||||
import { AppHeader } from './components/AppHeader.tsx';
|
||||
import { Welcome } from './components/Welcome.tsx';
|
||||
import './App.css'
|
||||
import './components/themes/ThemeBrowser.css'
|
||||
|
||||
|
88
src/components/presentations/AspectRatioSelector.css
Normal file
88
src/components/presentations/AspectRatioSelector.css
Normal file
@ -0,0 +1,88 @@
|
||||
.aspect-ratio-selection h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: #6b7280;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.aspect-ratio-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.aspect-ratio-card {
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.aspect-ratio-card:hover {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.aspect-ratio-card.selected {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.aspect-ratio-preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
margin-bottom: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
background: #3b82f6;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.preview-box.aspect-16-9 {
|
||||
width: 48px;
|
||||
height: 27px;
|
||||
}
|
||||
|
||||
.preview-box.aspect-4-3 {
|
||||
width: 40px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.preview-box.aspect-1-1 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.aspect-ratio-info h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.ratio-description {
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ratio-dimensions {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
}
|
44
src/components/presentations/AspectRatioSelector.tsx
Normal file
44
src/components/presentations/AspectRatioSelector.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import type { AspectRatio } from '../../types/presentation.ts';
|
||||
import { ASPECT_RATIOS } from '../../types/presentation.ts';
|
||||
import './AspectRatioSelector.css';
|
||||
|
||||
interface AspectRatioSelectorProps {
|
||||
selectedAspectRatio: AspectRatio;
|
||||
onAspectRatioChange: (ratio: AspectRatio) => void;
|
||||
}
|
||||
|
||||
export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
||||
selectedAspectRatio,
|
||||
onAspectRatioChange
|
||||
}) => {
|
||||
return (
|
||||
<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={() => onAspectRatioChange(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>
|
||||
);
|
||||
};
|
96
src/components/presentations/CreationActions.css
Normal file
96
src/components/presentations/CreationActions.css
Normal file
@ -0,0 +1,96 @@
|
||||
.creation-actions {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.selected-theme-info {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.theme-preview-info h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-preview-info p {
|
||||
color: #6b7280;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.theme-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.theme-stats span {
|
||||
background: #f3f4f6;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.creation-error {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.creation-error p {
|
||||
color: #dc2626;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.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;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.button.secondary:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button.primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.button.primary:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
63
src/components/presentations/CreationActions.tsx
Normal file
63
src/components/presentations/CreationActions.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import type { Theme } from '../../types/theme.ts';
|
||||
import './CreationActions.css';
|
||||
|
||||
interface CreationActionsProps {
|
||||
selectedTheme: Theme | null;
|
||||
error: string | null;
|
||||
creating: boolean;
|
||||
presentationTitle: string;
|
||||
onCancel: () => void;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
export const CreationActions: React.FC<CreationActionsProps> = ({
|
||||
selectedTheme,
|
||||
error,
|
||||
creating,
|
||||
presentationTitle,
|
||||
onCancel,
|
||||
onCreate
|
||||
}) => {
|
||||
return (
|
||||
<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={onCancel}
|
||||
className="button secondary"
|
||||
type="button"
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="button primary"
|
||||
type="button"
|
||||
disabled={!selectedTheme || !presentationTitle.trim() || creating}
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Presentation'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
129
src/components/presentations/EmptyPresentationState.css
Normal file
129
src/components/presentations/EmptyPresentationState.css
Normal file
@ -0,0 +1,129 @@
|
||||
.empty-presentation {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.empty-content h2 {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-content > p {
|
||||
font-size: 1.125rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.theme-preview h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-description {
|
||||
color: #6b7280;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.available-layouts h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.layouts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.layout-preview-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.layout-preview-card .layout-name {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layout-preview-card .layout-description {
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layout-preview-card .slot-count {
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.more-layouts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f3f4f6;
|
||||
border: 1px dashed #9ca3af;
|
||||
border-radius: 0.5rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.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.large {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1rem;
|
||||
}
|
55
src/components/presentations/EmptyPresentationState.tsx
Normal file
55
src/components/presentations/EmptyPresentationState.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import type { Theme } from '../../types/theme.ts';
|
||||
import './EmptyPresentationState.css';
|
||||
|
||||
interface EmptyPresentationStateProps {
|
||||
theme: Theme | null;
|
||||
onAddFirstSlide: () => void;
|
||||
}
|
||||
|
||||
export const EmptyPresentationState: React.FC<EmptyPresentationStateProps> = ({
|
||||
theme,
|
||||
onAddFirstSlide
|
||||
}) => {
|
||||
return (
|
||||
<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={onAddFirstSlide}
|
||||
>
|
||||
Add First Slide
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,11 +1,15 @@
|
||||
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 type { Theme } from '../../types/theme.ts';
|
||||
import type { AspectRatio } from '../../types/presentation.ts';
|
||||
import { getThemes } from '../../themes/index.ts';
|
||||
import { createPresentation } from '../../utils/presentationStorage.ts';
|
||||
import { loggers } from '../../utils/logger.ts';
|
||||
import { AlertDialog } from '../ui/AlertDialog.tsx';
|
||||
import { PresentationDetailsForm } from './PresentationDetailsForm.tsx';
|
||||
import { AspectRatioSelector } from './AspectRatioSelector.tsx';
|
||||
import { ThemeSelectionSection } from './ThemeSelectionSection.tsx';
|
||||
import { CreationActions } from './CreationActions.tsx';
|
||||
import './NewPresentationPage.css';
|
||||
|
||||
export const NewPresentationPage: React.FC = () => {
|
||||
@ -18,6 +22,10 @@ export const NewPresentationPage: React.FC = () => {
|
||||
const [presentationTitle, setPresentationTitle] = useState('');
|
||||
const [presentationDescription, setPresentationDescription] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [alertDialog, setAlertDialog] = useState<{ isOpen: boolean; message: string; type?: 'info' | 'warning' | 'error' | 'success' }>({
|
||||
isOpen: false,
|
||||
message: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadThemes = async () => {
|
||||
@ -42,12 +50,20 @@ export const NewPresentationPage: React.FC = () => {
|
||||
|
||||
const handleCreatePresentation = async () => {
|
||||
if (!selectedTheme) {
|
||||
alert('Please select a theme for your presentation');
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
message: 'Please select a theme for your presentation',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!presentationTitle.trim()) {
|
||||
alert('Please enter a title for your presentation');
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
message: 'Please enter a title for your presentation',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -62,13 +78,13 @@ export const NewPresentationPage: React.FC = () => {
|
||||
aspectRatio: selectedAspectRatio
|
||||
});
|
||||
|
||||
console.log('Presentation created successfully:', presentation);
|
||||
loggers.presentation.info('Presentation created successfully', { presentationId: presentation.metadata.id, name: presentation.metadata.name });
|
||||
|
||||
// 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);
|
||||
loggers.presentation.error('Failed to create presentation', err instanceof Error ? err : new Error(String(err)));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
@ -116,122 +132,41 @@ export const NewPresentationPage: React.FC = () => {
|
||||
|
||||
<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>
|
||||
<PresentationDetailsForm
|
||||
title={presentationTitle}
|
||||
description={presentationDescription}
|
||||
onTitleChange={setPresentationTitle}
|
||||
onDescriptionChange={setPresentationDescription}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<AspectRatioSelector
|
||||
selectedAspectRatio={selectedAspectRatio}
|
||||
onAspectRatioChange={setSelectedAspectRatio}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<ThemeSelectionSection
|
||||
themes={themes}
|
||||
selectedTheme={selectedTheme}
|
||||
onThemeSelect={setSelectedTheme}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<CreationActions
|
||||
selectedTheme={selectedTheme}
|
||||
error={error}
|
||||
creating={creating}
|
||||
presentationTitle={presentationTitle}
|
||||
onCancel={() => navigate('/themes')}
|
||||
onCreate={handleCreatePresentation}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<AlertDialog
|
||||
isOpen={alertDialog.isOpen}
|
||||
onClose={() => setAlertDialog({ isOpen: false, message: '' })}
|
||||
message={alertDialog.message}
|
||||
type={alertDialog.type}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
39
src/components/presentations/PresentationDetailsForm.css
Normal file
39
src/components/presentations/PresentationDetailsForm.css
Normal file
@ -0,0 +1,39 @@
|
||||
.presentation-details h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
46
src/components/presentations/PresentationDetailsForm.tsx
Normal file
46
src/components/presentations/PresentationDetailsForm.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import './PresentationDetailsForm.css';
|
||||
|
||||
interface PresentationDetailsFormProps {
|
||||
title: string;
|
||||
description: string;
|
||||
onTitleChange: (title: string) => void;
|
||||
onDescriptionChange: (description: string) => void;
|
||||
}
|
||||
|
||||
export const PresentationDetailsForm: React.FC<PresentationDetailsFormProps> = ({
|
||||
title,
|
||||
description,
|
||||
onTitleChange,
|
||||
onDescriptionChange
|
||||
}) => {
|
||||
return (
|
||||
<section className="presentation-details">
|
||||
<h2>Presentation Details</h2>
|
||||
<div className="form-group">
|
||||
<label htmlFor="title">Title *</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => onTitleChange(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={description}
|
||||
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||
placeholder="Optional description of your presentation"
|
||||
className="form-textarea"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
@ -41,30 +41,12 @@
|
||||
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;
|
||||
.presentation-title span {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.presentation-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
@ -1,9 +1,18 @@
|
||||
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 type { Presentation } from '../../types/presentation.ts';
|
||||
import type { Theme } from '../../types/theme.ts';
|
||||
import { getPresentationById, updatePresentation } from '../../utils/presentationStorage.ts';
|
||||
import { getTheme } from '../../themes/index.ts';
|
||||
import { useDialog } from '../../hooks/useDialog.ts';
|
||||
import { AlertDialog } from '../ui/AlertDialog.tsx';
|
||||
import { ConfirmDialog } from '../ui/ConfirmDialog.tsx';
|
||||
import { LoadingState } from './shared/LoadingState.tsx';
|
||||
import { ErrorState } from './shared/ErrorState.tsx';
|
||||
import { EmptyPresentationState } from './EmptyPresentationState.tsx';
|
||||
import { SlidesSidebar } from './SlidesSidebar.tsx';
|
||||
import { loggers } from '../../utils/logger.ts';
|
||||
import { useSlideOperations } from '../../hooks/useSlideOperations.ts';
|
||||
import './PresentationEditor.css';
|
||||
|
||||
export const PresentationEditor: React.FC = () => {
|
||||
@ -17,9 +26,27 @@ export const PresentationEditor: React.FC = () => {
|
||||
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;
|
||||
|
||||
const {
|
||||
isAlertOpen,
|
||||
isConfirmOpen,
|
||||
alertOptions,
|
||||
confirmOptions,
|
||||
closeAlert,
|
||||
closeConfirm,
|
||||
handleConfirm,
|
||||
confirmDelete
|
||||
} = useDialog();
|
||||
|
||||
const { duplicateSlide, deleteSlide, saving } = useSlideOperations({
|
||||
presentation,
|
||||
presentationId: presentationId || '',
|
||||
onPresentationUpdate: setPresentation,
|
||||
onError: setError,
|
||||
confirmDelete
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadPresentationAndTheme = async () => {
|
||||
@ -66,18 +93,6 @@ export const PresentationEditor: React.FC = () => {
|
||||
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;
|
||||
|
||||
@ -85,150 +100,11 @@ export const PresentationEditor: React.FC = () => {
|
||||
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>
|
||||
<LoadingState message="Loading presentation editor..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -236,11 +112,12 @@ export const PresentationEditor: React.FC = () => {
|
||||
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>
|
||||
<ErrorState
|
||||
title="Error Loading Presentation"
|
||||
message={error}
|
||||
backLink="/themes"
|
||||
backText="← Back to Themes"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -248,11 +125,12 @@ export const PresentationEditor: React.FC = () => {
|
||||
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>
|
||||
<ErrorState
|
||||
title="Presentation Not Found"
|
||||
message="The requested presentation could not be found."
|
||||
backLink="/themes"
|
||||
backText="← Back to Themes"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -266,202 +144,50 @@ export const PresentationEditor: React.FC = () => {
|
||||
<div className="presentation-info">
|
||||
<Link to="/themes" className="back-link">← Back</Link>
|
||||
<div className="presentation-title">
|
||||
<h1>{presentation.metadata.name}</h1>
|
||||
<span>{presentation.metadata.name}</span>
|
||||
{presentation.metadata.description && (
|
||||
<p className="presentation-description">{presentation.metadata.description}</p>
|
||||
<span className="presentation-description">{presentation.metadata.description}</span>
|
||||
)}
|
||||
</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}`}
|
||||
{totalSlides === 0 ? 'No slides' : `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>
|
||||
<EmptyPresentationState
|
||||
theme={theme}
|
||||
onAddFirstSlide={addNewSlide}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
<SlidesSidebar
|
||||
slides={presentation.slides}
|
||||
currentSlideIndex={currentSlideIndex}
|
||||
saving={saving}
|
||||
onSlideClick={goToSlide}
|
||||
onSlideDoubleClick={(slideId) => navigate(`/presentations/${presentationId}/slide/${slideId}/edit`)}
|
||||
onAddSlide={addNewSlide}
|
||||
onEditSlide={(slideId) => navigate(`/presentations/${presentationId}/slide/${slideId}/edit`)}
|
||||
onDuplicateSlide={duplicateSlide}
|
||||
onDeleteSlide={deleteSlide}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<h3>Slide {currentSlideIndex + 1}</h3>
|
||||
</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">
|
||||
@ -483,7 +209,7 @@ export const PresentationEditor: React.FC = () => {
|
||||
value={currentSlide.notes}
|
||||
onChange={(e) => {
|
||||
// TODO: Update slide notes
|
||||
console.log('Update notes:', e.target.value);
|
||||
loggers.presentation.debug('Slide notes updated', { slideId: currentSlide.id, notesLength: e.target.value.length });
|
||||
}}
|
||||
placeholder="Add speaker notes for this slide..."
|
||||
className="notes-textarea"
|
||||
@ -508,6 +234,28 @@ export const PresentationEditor: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Dialog Components */}
|
||||
<AlertDialog
|
||||
isOpen={isAlertOpen}
|
||||
onClose={closeAlert}
|
||||
title={alertOptions.title}
|
||||
message={alertOptions.message}
|
||||
type={alertOptions.type}
|
||||
confirmText={alertOptions.confirmText}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={isConfirmOpen}
|
||||
onClose={closeConfirm}
|
||||
onConfirm={handleConfirm}
|
||||
title={confirmOptions.title}
|
||||
message={confirmOptions.message}
|
||||
type={confirmOptions.type}
|
||||
confirmText={confirmOptions.confirmText}
|
||||
cancelText={confirmOptions.cancelText}
|
||||
isDestructive={confirmOptions.isDestructive}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,9 +1,10 @@
|
||||
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 type { Presentation } from '../../types/presentation.ts';
|
||||
import type { Theme } from '../../types/theme.ts';
|
||||
import { getPresentationById } from '../../utils/presentationStorage.ts';
|
||||
import { getTheme } from '../../themes/index.ts';
|
||||
import { loggers } from '../../utils/logger.ts';
|
||||
import './PresentationViewer.css';
|
||||
|
||||
export const PresentationViewer: React.FC = () => {
|
||||
@ -86,7 +87,7 @@ export const PresentationViewer: React.FC = () => {
|
||||
|
||||
const enterPresentationMode = () => {
|
||||
// TODO: Implement full-screen presentation mode
|
||||
console.log('Full-screen presentation mode to be implemented');
|
||||
loggers.ui.info('Full-screen presentation mode requested - feature to be implemented');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
|
@ -1,7 +1,8 @@
|
||||
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 type { Presentation } from '../../types/presentation.ts';
|
||||
import { getAllPresentations, deletePresentation } from '../../utils/presentationStorage.ts';
|
||||
import { loggers } from '../../utils/logger.ts';
|
||||
import './PresentationsList.css';
|
||||
|
||||
export const PresentationsList: React.FC = () => {
|
||||
@ -38,7 +39,7 @@ export const PresentationsList: React.FC = () => {
|
||||
await deletePresentation(id);
|
||||
setPresentations(prev => prev.filter(p => p.metadata.id !== id));
|
||||
} catch (err) {
|
||||
console.error('Error deleting presentation:', err);
|
||||
loggers.presentation.error('Failed to delete presentation', err instanceof Error ? err : new Error(String(err)));
|
||||
alert('Failed to delete presentation. Please try again.');
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
|
@ -72,6 +72,12 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
|
@ -1,10 +1,13 @@
|
||||
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 type { Presentation, SlideContent } from '../../types/presentation.ts';
|
||||
import type { Theme, SlideLayout } from '../../types/theme.ts';
|
||||
import { getPresentationById, updatePresentation } from '../../utils/presentationStorage.ts';
|
||||
import { getTheme } from '../../themes/index.ts';
|
||||
import { renderTemplateWithSampleData } from '../../utils/templateRenderer.ts';
|
||||
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts';
|
||||
import { loggers } from '../../utils/logger.ts';
|
||||
import { SlidePreviewModal } from './SlidePreviewModal.tsx';
|
||||
import './SlideEditor.css';
|
||||
|
||||
export const SlideEditor: React.FC = () => {
|
||||
@ -19,6 +22,7 @@ export const SlideEditor: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const isEditingExisting = slideId !== 'new';
|
||||
|
||||
@ -91,7 +95,7 @@ export const SlideEditor: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
const themeStyleId = 'slide-editor-theme-style';
|
||||
let existingStyle = document.getElementById(themeStyleId);
|
||||
const existingStyle = document.getElementById(themeStyleId);
|
||||
|
||||
if (existingStyle) {
|
||||
existingStyle.remove();
|
||||
@ -174,7 +178,7 @@ export const SlideEditor: React.FC = () => {
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save slide');
|
||||
console.error('Error saving slide:', err);
|
||||
loggers.presentation.error('Failed to save slide', err instanceof Error ? err : new Error(String(err)));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@ -242,6 +246,17 @@ export const SlideEditor: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="editor-actions">
|
||||
{selectedLayout && currentStep === 'content' && (
|
||||
<button
|
||||
type="button"
|
||||
className="action-button secondary preview-button"
|
||||
onClick={() => setShowPreview(true)}
|
||||
disabled={saving}
|
||||
title="Preview slide in full screen"
|
||||
>
|
||||
🔍 Preview
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="action-button primary"
|
||||
@ -272,7 +287,7 @@ export const SlideEditor: React.FC = () => {
|
||||
<div
|
||||
className="layout-rendered"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderTemplateWithSampleData(layout.htmlTemplate, layout)
|
||||
__html: sanitizeSlideTemplate(renderTemplateWithSampleData(layout.htmlTemplate, layout))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -388,7 +403,7 @@ export const SlideEditor: React.FC = () => {
|
||||
<div
|
||||
className={`slide-container ${presentation.metadata.aspectRatio ? `aspect-${presentation.metadata.aspectRatio.replace(':', '-')}` : 'aspect-16-9'}`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderTemplateWithContent(selectedLayout, slideContent)
|
||||
__html: sanitizeSlideTemplate(renderTemplateWithContent(selectedLayout, slideContent))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -413,6 +428,18 @@ export const SlideEditor: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Preview Modal */}
|
||||
{selectedLayout && presentation && (
|
||||
<SlidePreviewModal
|
||||
isOpen={showPreview}
|
||||
onClose={() => setShowPreview(false)}
|
||||
layout={selectedLayout}
|
||||
content={slideContent}
|
||||
aspectRatio={presentation.metadata.aspectRatio || '16:9'}
|
||||
themeName={theme?.name || 'Unknown Theme'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
122
src/components/presentations/SlidePreviewModal.css
Normal file
122
src/components/presentations/SlidePreviewModal.css
Normal file
@ -0,0 +1,122 @@
|
||||
.slide-preview-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.preview-hint {
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #1f2937;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
animation: fadeInOut 3s ease-in-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
0% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
|
||||
15% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
85% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
100% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
|
||||
}
|
||||
|
||||
.preview-close-button {
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
font-size: 1.25rem;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.preview-close-button:hover {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.preview-info {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
left: 2rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.preview-info span {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.theme-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.layout-name {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.aspect-ratio {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Slide preview - use same selectors as SlideEditor */
|
||||
.slide-preview-wrapper {
|
||||
/* This wrapper provides the context for aspect ratio classes */
|
||||
}
|
||||
|
||||
.slide-preview-wrapper .slide-container {
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Aspect ratio handling - matching SlideEditor pattern */
|
||||
.slide-preview-wrapper .slide-container.aspect-16-9 {
|
||||
aspect-ratio: 16 / 9;
|
||||
width: min(90vw, calc(90vh * 16/9));
|
||||
}
|
||||
|
||||
.slide-preview-wrapper .slide-container.aspect-4-3 {
|
||||
aspect-ratio: 4 / 3;
|
||||
width: min(90vw, calc(90vh * 4/3));
|
||||
}
|
||||
|
||||
.slide-preview-wrapper .slide-container.aspect-16-10 {
|
||||
aspect-ratio: 16 / 10;
|
||||
width: min(90vw, calc(90vh * 16/10));
|
||||
}
|
||||
|
||||
.slide-preview-wrapper .slide-container.aspect-1-1 {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: min(90vw, 90vh);
|
||||
}
|
118
src/components/presentations/SlidePreviewModal.tsx
Normal file
118
src/components/presentations/SlidePreviewModal.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { SlideLayout } from '../../types/theme.ts';
|
||||
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts';
|
||||
import './SlidePreviewModal.css';
|
||||
|
||||
interface SlidePreviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
layout: SlideLayout;
|
||||
content: Record<string, string>;
|
||||
aspectRatio: string;
|
||||
themeName: string;
|
||||
}
|
||||
|
||||
export const SlidePreviewModal: React.FC<SlidePreviewModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
layout,
|
||||
content,
|
||||
aspectRatio,
|
||||
themeName
|
||||
}) => {
|
||||
const [showHint, setShowHint] = useState(true);
|
||||
|
||||
// Handle ESC key press
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden'; // Prevent scrolling
|
||||
|
||||
// Hide hint after 3 seconds
|
||||
const hintTimer = setTimeout(() => {
|
||||
setShowHint(false);
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'unset';
|
||||
clearTimeout(hintTimer);
|
||||
};
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Reset hint when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setShowHint(true);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleOverlayClick = (event: React.MouseEvent) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="slide-preview-modal" onClick={handleOverlayClick}>
|
||||
{/* ESC hint */}
|
||||
{showHint && (
|
||||
<div className="preview-hint">
|
||||
<span>Press ESC to exit preview</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
className="preview-close-button"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
title="Close preview (ESC)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
{/* Theme info */}
|
||||
<div className="preview-info">
|
||||
<span className="theme-name">{themeName}</span>
|
||||
<span className="layout-name">{layout.name}</span>
|
||||
<span className="aspect-ratio">{aspectRatio}</span>
|
||||
</div>
|
||||
|
||||
{/* Slide content */}
|
||||
<div className="slide-preview-wrapper">
|
||||
<div
|
||||
className={`slide-container aspect-${aspectRatio.replace(':', '-')}`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeSlideTemplate(renderTemplateWithContent(layout, content))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
53
src/components/presentations/SlidesSidebar.css
Normal file
53
src/components/presentations/SlidesSidebar.css
Normal file
@ -0,0 +1,53 @@
|
||||
.slide-sidebar {
|
||||
width: 240px;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.sidebar-header h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.add-slide-button {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.add-slide-button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.slides-list {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
61
src/components/presentations/SlidesSidebar.tsx
Normal file
61
src/components/presentations/SlidesSidebar.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import type { Slide } from '../../types/presentation.ts';
|
||||
import { SlideThumbnail } from './shared/SlideThumbnail.tsx';
|
||||
import './SlidesSidebar.css';
|
||||
|
||||
interface SlidesSidebarProps {
|
||||
slides: Slide[];
|
||||
currentSlideIndex: number;
|
||||
saving: boolean;
|
||||
onSlideClick: (index: number) => void;
|
||||
onSlideDoubleClick: (slideId: string) => void;
|
||||
onAddSlide: () => void;
|
||||
onEditSlide: (slideId: string) => void;
|
||||
onDuplicateSlide: (index: number) => void;
|
||||
onDeleteSlide: (index: number) => void;
|
||||
}
|
||||
|
||||
export const SlidesSidebar: React.FC<SlidesSidebarProps> = ({
|
||||
slides,
|
||||
currentSlideIndex,
|
||||
saving,
|
||||
onSlideClick,
|
||||
onSlideDoubleClick,
|
||||
onAddSlide,
|
||||
onEditSlide,
|
||||
onDuplicateSlide,
|
||||
onDeleteSlide
|
||||
}) => {
|
||||
return (
|
||||
<aside className="slide-sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h3>Slides</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="add-slide-button"
|
||||
onClick={onAddSlide}
|
||||
title="Add new slide"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="slides-list">
|
||||
{slides.map((slide, index) => (
|
||||
<SlideThumbnail
|
||||
key={slide.id}
|
||||
slide={slide}
|
||||
index={index}
|
||||
isActive={index === currentSlideIndex}
|
||||
isDisabled={saving}
|
||||
onClick={() => onSlideClick(index)}
|
||||
onDoubleClick={() => onSlideDoubleClick(slide.id)}
|
||||
onEdit={() => onEditSlide(slide.id)}
|
||||
onDuplicate={() => onDuplicateSlide(index)}
|
||||
onDelete={() => onDeleteSlide(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
26
src/components/presentations/ThemeSelectionSection.css
Normal file
26
src/components/presentations/ThemeSelectionSection.css
Normal file
@ -0,0 +1,26 @@
|
||||
.theme-selection h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: #6b7280;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.no-themes {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-themes p {
|
||||
color: #92400e;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
37
src/components/presentations/ThemeSelectionSection.tsx
Normal file
37
src/components/presentations/ThemeSelectionSection.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import type { Theme } from '../../types/theme.ts';
|
||||
import { ThemeSelector } from './ThemeSelector.tsx';
|
||||
import './ThemeSelectionSection.css';
|
||||
|
||||
interface ThemeSelectionSectionProps {
|
||||
themes: Theme[];
|
||||
selectedTheme: Theme | null;
|
||||
onThemeSelect: (theme: Theme | null) => void;
|
||||
}
|
||||
|
||||
export const ThemeSelectionSection: React.FC<ThemeSelectionSectionProps> = ({
|
||||
themes,
|
||||
selectedTheme,
|
||||
onThemeSelect
|
||||
}) => {
|
||||
return (
|
||||
<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={onThemeSelect}
|
||||
/>
|
||||
) : (
|
||||
<div className="no-themes">
|
||||
<p>No themes available. Please check your theme configuration.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Theme } from '../../types/theme';
|
||||
import type { Theme } from '../../types/theme.ts';
|
||||
import './ThemeSelector.css';
|
||||
|
||||
interface ThemeSelectorProps {
|
||||
@ -27,7 +27,7 @@ export const ThemeSelector: React.FC<ThemeSelectorProps> = ({
|
||||
{/* Color palette preview */}
|
||||
<div className="color-preview-strip">
|
||||
{theme.variables && Object.entries(theme.variables)
|
||||
.filter(([_, value]) => value.startsWith('#') || value.includes('rgb') || value.includes('hsl'))
|
||||
.filter(([, value]) => value.startsWith('#') || value.includes('rgb') || value.includes('hsl'))
|
||||
.slice(0, 4)
|
||||
.map(([key, value]) => (
|
||||
<div
|
||||
@ -38,7 +38,7 @@ export const ThemeSelector: React.FC<ThemeSelectorProps> = ({
|
||||
/>
|
||||
))
|
||||
}
|
||||
{(!theme.variables || Object.entries(theme.variables).filter(([_, 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>
|
||||
|
@ -1,6 +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';
|
||||
export { NewPresentationPage } from './NewPresentationPage.tsx';
|
||||
export { PresentationViewer } from './PresentationViewer.tsx';
|
||||
export { PresentationEditor } from './PresentationEditor.tsx';
|
||||
export { SlideEditor } from './SlideEditor.tsx';
|
||||
export { ThemeSelector } from './ThemeSelector.tsx';
|
||||
export { PresentationsList } from './PresentationsList.tsx';
|
67
src/components/presentations/shared/ErrorState.css
Normal file
67
src/components/presentations/shared/ErrorState.css
Normal file
@ -0,0 +1,67 @@
|
||||
.error-content {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-content h2 {
|
||||
color: #dc2626;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-content p {
|
||||
color: #6b7280;
|
||||
margin-bottom: 2rem;
|
||||
max-width: 400px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.button.secondary:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
46
src/components/presentations/shared/ErrorState.tsx
Normal file
46
src/components/presentations/shared/ErrorState.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import './ErrorState.css';
|
||||
|
||||
interface ErrorStateProps {
|
||||
title?: string;
|
||||
message: string;
|
||||
backLink?: string;
|
||||
backText?: string;
|
||||
onRetry?: () => void;
|
||||
retryText?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ErrorState: React.FC<ErrorStateProps> = ({
|
||||
title = "Error",
|
||||
message,
|
||||
backLink,
|
||||
backText = "← Back to Themes",
|
||||
onRetry,
|
||||
retryText = "Try Again",
|
||||
className = ""
|
||||
}) => {
|
||||
return (
|
||||
<div className={`error-content ${className}`.trim()}>
|
||||
<h2>{title}</h2>
|
||||
<p>{message}</p>
|
||||
<div className="error-actions">
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="button secondary"
|
||||
type="button"
|
||||
>
|
||||
{retryText}
|
||||
</button>
|
||||
)}
|
||||
{backLink && (
|
||||
<Link to={backLink} className="back-link">
|
||||
{backText}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
31
src/components/presentations/shared/LoadingState.css
Normal file
31
src/components/presentations/shared/LoadingState.css
Normal file
@ -0,0 +1,31 @@
|
||||
.loading-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading-spinner::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 10px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top: 2px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
18
src/components/presentations/shared/LoadingState.tsx
Normal file
18
src/components/presentations/shared/LoadingState.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import './LoadingState.css';
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LoadingState: React.FC<LoadingStateProps> = ({
|
||||
message = "Loading...",
|
||||
className = ""
|
||||
}) => {
|
||||
return (
|
||||
<div className={`loading-content ${className}`.trim()}>
|
||||
<div className="loading-spinner">{message}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
106
src/components/presentations/shared/SlideThumbnail.css
Normal file
106
src/components/presentations/shared/SlideThumbnail.css
Normal file
@ -0,0 +1,106 @@
|
||||
.slide-thumbnail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slide-thumbnail:hover {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.slide-thumbnail.active {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.thumbnail-number {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
background: #374151;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.slide-thumbnail.active .thumbnail-number {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.thumbnail-preview {
|
||||
padding: 2rem 1rem 1rem;
|
||||
min-height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.thumbnail-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.layout-name {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.content-count {
|
||||
font-size: 0.625rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.thumbnail-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.thumbnail-action {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: background-color 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.thumbnail-action:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.thumbnail-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.thumbnail-action.edit:hover:not(:disabled) {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.thumbnail-action.delete:hover:not(:disabled) {
|
||||
background: #fee2e2;
|
||||
}
|
83
src/components/presentations/shared/SlideThumbnail.tsx
Normal file
83
src/components/presentations/shared/SlideThumbnail.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import type { Slide } from '../../../types/presentation.ts';
|
||||
import './SlideThumbnail.css';
|
||||
|
||||
interface SlideThumbnailProps {
|
||||
slide: Slide;
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
isDisabled?: boolean;
|
||||
onClick: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const SlideThumbnail: React.FC<SlideThumbnailProps> = ({
|
||||
slide,
|
||||
index,
|
||||
isActive,
|
||||
isDisabled = false,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`slide-thumbnail ${isActive ? 'active' : ''}`}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<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();
|
||||
onEdit();
|
||||
}}
|
||||
title="Edit slide content"
|
||||
disabled={isDisabled}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="thumbnail-action"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
title="Duplicate slide"
|
||||
disabled={isDisabled}
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="thumbnail-action delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
title="Delete slide"
|
||||
disabled={isDisabled}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import type { Theme, SlideLayout } from '../../types/theme';
|
||||
import { getTheme } from '../../themes';
|
||||
import type { Theme, SlideLayout } from '../../types/theme.ts';
|
||||
import { getTheme } from '../../themes/index.ts';
|
||||
import './LayoutDetailPage.css';
|
||||
|
||||
export const LayoutDetailPage: React.FC = () => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import type { SlideLayout, Theme } from '../../types/theme';
|
||||
import { renderTemplateWithSampleData, createPreviewDocument } from '../../utils/templateRenderer';
|
||||
import type { SlideLayout, Theme } from '../../types/theme.ts';
|
||||
import { renderTemplateWithSampleData, createPreviewDocument } from '../../utils/templateRenderer.ts';
|
||||
|
||||
interface LayoutPreviewProps {
|
||||
layout: SlideLayout;
|
||||
|
@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import type { Theme, SlideLayout } from '../../types/theme';
|
||||
import { getTheme } from '../../themes';
|
||||
import { renderTemplateWithSampleData } from '../../utils/templateRenderer';
|
||||
import type { Theme, SlideLayout } from '../../types/theme.ts';
|
||||
import { getTheme } from '../../themes/index.ts';
|
||||
import { renderTemplateWithSampleData } from '../../utils/templateRenderer.ts';
|
||||
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts';
|
||||
import './LayoutPreviewPage.css';
|
||||
|
||||
export const LayoutPreviewPage: React.FC = () => {
|
||||
@ -55,7 +56,7 @@ export const LayoutPreviewPage: React.FC = () => {
|
||||
if (theme) {
|
||||
// Dynamically load theme CSS
|
||||
const themeStyleId = 'theme-preview-style';
|
||||
let existingStyle = document.getElementById(themeStyleId);
|
||||
const existingStyle = document.getElementById(themeStyleId);
|
||||
|
||||
if (existingStyle) {
|
||||
existingStyle.remove();
|
||||
@ -152,7 +153,7 @@ export const LayoutPreviewPage: React.FC = () => {
|
||||
</div>
|
||||
<div
|
||||
className="layout-rendered-content"
|
||||
dangerouslySetInnerHTML={{ __html: renderedContent }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeSlideTemplate(renderedContent) }}
|
||||
/>
|
||||
<Link
|
||||
to={`/themes/${theme.id}/layouts/${layout.id}`}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { Theme } from '../../types/theme';
|
||||
import { getThemes } from '../../themes';
|
||||
import { LayoutPreview } from './LayoutPreview';
|
||||
import type { Theme } from '../../types/theme.ts';
|
||||
import { getThemes } from '../../themes/index.ts';
|
||||
import { LayoutPreview } from './LayoutPreview.tsx';
|
||||
|
||||
export const ThemeBrowser: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@ -118,7 +118,7 @@ export const ThemeBrowser: React.FC = () => {
|
||||
<div className="theme-preview">
|
||||
<div className="color-palette">
|
||||
{Object.entries(theme.variables)
|
||||
.filter(([_, value]) => value.startsWith('#') || value.includes('rgb') || value.includes('hsl'))
|
||||
.filter(([, value]) => value.startsWith('#') || value.includes('rgb') || value.includes('hsl'))
|
||||
.slice(0, 6) // Show max 6 color swatches to avoid overcrowding
|
||||
.map(([key, value]) => (
|
||||
<div
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import type { Theme } from '../../types/theme';
|
||||
import { getTheme } from '../../themes';
|
||||
import type { Theme } from '../../types/theme.ts';
|
||||
import { getTheme } from '../../themes/index.ts';
|
||||
import './ThemeDetailPage.css';
|
||||
|
||||
export const ThemeDetailPage: React.FC = () => {
|
||||
@ -87,7 +87,7 @@ export const ThemeDetailPage: React.FC = () => {
|
||||
<h2>Color Palette</h2>
|
||||
<div className="color-palette-large">
|
||||
{Object.entries(theme.variables)
|
||||
.filter(([_, value]) => value.startsWith('#') || value.includes('rgb') || value.includes('hsl'))
|
||||
.filter(([, value]) => value.startsWith('#') || value.includes('rgb') || value.includes('hsl'))
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="color-swatch-large">
|
||||
<div
|
||||
|
@ -1,6 +1,6 @@
|
||||
export { ThemeBrowser } from './ThemeBrowser';
|
||||
export { LayoutPreview } from './LayoutPreview';
|
||||
export { ThemeDetailPage } from './ThemeDetailPage';
|
||||
export { LayoutDetailPage } from './LayoutDetailPage';
|
||||
export { LayoutPreviewPage } from './LayoutPreviewPage';
|
||||
export type { Theme } from '../../types/theme';
|
||||
export { ThemeBrowser } from './ThemeBrowser.tsx';
|
||||
export { LayoutPreview } from './LayoutPreview.tsx';
|
||||
export { ThemeDetailPage } from './ThemeDetailPage.tsx';
|
||||
export { LayoutDetailPage } from './LayoutDetailPage.tsx';
|
||||
export { LayoutPreviewPage } from './LayoutPreviewPage.tsx';
|
||||
export type { Theme } from '../../types/theme.ts';
|
122
src/components/ui/AlertDialog.tsx
Normal file
122
src/components/ui/AlertDialog.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import { Modal } from './Modal.tsx';
|
||||
|
||||
interface AlertDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
message: string;
|
||||
type?: 'info' | 'warning' | 'error' | 'success';
|
||||
confirmText?: string;
|
||||
}
|
||||
|
||||
export const AlertDialog: React.FC<AlertDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
message,
|
||||
type = 'info',
|
||||
confirmText = 'OK'
|
||||
}) => {
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return '❌';
|
||||
case 'warning':
|
||||
return '⚠️';
|
||||
case 'success':
|
||||
return '✅';
|
||||
default:
|
||||
return 'ℹ️';
|
||||
}
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
if (title) return title;
|
||||
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return 'Error';
|
||||
case 'warning':
|
||||
return 'Warning';
|
||||
case 'success':
|
||||
return 'Success';
|
||||
default:
|
||||
return 'Information';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="small" title={getTitle()}>
|
||||
<div className="alert-dialog">
|
||||
<div className="alert-content">
|
||||
<div className="alert-icon">{getIcon()}</div>
|
||||
<p className="alert-message">{message}</p>
|
||||
</div>
|
||||
<div className="alert-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button primary"
|
||||
onClick={onClose}
|
||||
autoFocus
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.alert-dialog {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.alert-content {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert-message {
|
||||
color: #374151;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.alert-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button.primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.button.primary:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`}</style>
|
||||
</Modal>
|
||||
);
|
||||
};
|
179
src/components/ui/ConfirmDialog.tsx
Normal file
179
src/components/ui/ConfirmDialog.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
import React from 'react';
|
||||
import { Modal } from './Modal.tsx';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title?: string;
|
||||
message: string;
|
||||
type?: 'info' | 'warning' | 'danger';
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
isDestructive?: boolean;
|
||||
}
|
||||
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
type = 'info',
|
||||
confirmText,
|
||||
cancelText = 'Cancel',
|
||||
isDestructive = false
|
||||
}) => {
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'danger':
|
||||
return '⚠️';
|
||||
case 'warning':
|
||||
return '⚠️';
|
||||
default:
|
||||
return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
if (title) return title;
|
||||
|
||||
switch (type) {
|
||||
case 'danger':
|
||||
return 'Confirm Deletion';
|
||||
case 'warning':
|
||||
return 'Confirm Action';
|
||||
default:
|
||||
return 'Confirm';
|
||||
}
|
||||
};
|
||||
|
||||
const getConfirmText = () => {
|
||||
if (confirmText) return confirmText;
|
||||
|
||||
switch (type) {
|
||||
case 'danger':
|
||||
return 'Delete';
|
||||
case 'warning':
|
||||
return 'Continue';
|
||||
default:
|
||||
return 'Confirm';
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="small"
|
||||
title={getTitle()}
|
||||
closeOnOverlayClick={false}
|
||||
>
|
||||
<div className="confirm-dialog">
|
||||
<div className="confirm-content">
|
||||
<div className="confirm-icon">{getIcon()}</div>
|
||||
<p className="confirm-message">{message}</p>
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`button ${isDestructive || type === 'danger' ? 'danger' : 'primary'}`}
|
||||
onClick={handleConfirm}
|
||||
autoFocus
|
||||
>
|
||||
{getConfirmText()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.confirm-dialog {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirm-content {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.confirm-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.confirm-message {
|
||||
color: #374151;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button.primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.button.secondary:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button.danger:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.button:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.button.danger:focus {
|
||||
outline-color: #dc2626;
|
||||
}
|
||||
`}</style>
|
||||
</Modal>
|
||||
);
|
||||
};
|
123
src/components/ui/Modal.css
Normal file
123
src/components/ui/Modal.css
Normal file
@ -0,0 +1,123 @@
|
||||
/* Modal Overlay */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Modal Content */
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: modalSlideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal Sizes */
|
||||
.modal-small {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.modal-medium {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.modal-large {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
/* Modal Header */
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 1.5rem 0 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.modal-close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.modal-close-button:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* Modal Body */
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 640px) {
|
||||
.modal-overlay {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
80
src/components/ui/Modal.tsx
Normal file
80
src/components/ui/Modal.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import './Modal.css';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
closeOnOverlayClick?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
}
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'medium',
|
||||
closeOnOverlayClick = true,
|
||||
closeOnEscape = true
|
||||
}) => {
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen || !closeOnEscape) return;
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, closeOnEscape, onClose]);
|
||||
|
||||
// Handle body scroll lock
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleOverlayClick = (event: React.MouseEvent) => {
|
||||
if (event.target === event.currentTarget && closeOnOverlayClick) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleOverlayClick}>
|
||||
<div className={`modal-content modal-${size}`}>
|
||||
{title && (
|
||||
<header className="modal-header">
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-button"
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
)}
|
||||
<div className="modal-body">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
3
src/components/ui/index.ts
Normal file
3
src/components/ui/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { Modal } from './Modal.tsx';
|
||||
export { AlertDialog } from './AlertDialog.tsx';
|
||||
export { ConfirmDialog } from './ConfirmDialog.tsx';
|
117
src/hooks/useDialog.ts
Normal file
117
src/hooks/useDialog.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface AlertOptions {
|
||||
title?: string;
|
||||
message: string;
|
||||
type?: 'info' | 'warning' | 'error' | 'success';
|
||||
confirmText?: string;
|
||||
}
|
||||
|
||||
interface ConfirmOptions {
|
||||
title?: string;
|
||||
message: string;
|
||||
type?: 'info' | 'warning' | 'danger';
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
isDestructive?: boolean;
|
||||
}
|
||||
|
||||
interface DialogState {
|
||||
isAlertOpen: boolean;
|
||||
isConfirmOpen: boolean;
|
||||
alertOptions: AlertOptions;
|
||||
confirmOptions: ConfirmOptions;
|
||||
confirmCallback: (() => void) | null;
|
||||
}
|
||||
|
||||
export function useDialog() {
|
||||
const [state, setState] = useState<DialogState>({
|
||||
isAlertOpen: false,
|
||||
isConfirmOpen: false,
|
||||
alertOptions: { message: '' },
|
||||
confirmOptions: { message: '' },
|
||||
confirmCallback: null
|
||||
});
|
||||
|
||||
const showAlert = useCallback((options: AlertOptions) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isAlertOpen: true,
|
||||
alertOptions: options
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const showConfirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConfirmOpen: true,
|
||||
confirmOptions: options,
|
||||
confirmCallback: () => resolve(true)
|
||||
}));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeAlert = useCallback(() => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isAlertOpen: false
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const closeConfirm = useCallback(() => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConfirmOpen: false,
|
||||
confirmCallback: null
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (state.confirmCallback) {
|
||||
state.confirmCallback();
|
||||
}
|
||||
closeConfirm();
|
||||
}, [state, closeConfirm]);
|
||||
|
||||
const handleConfirmCancel = useCallback(() => {
|
||||
closeConfirm();
|
||||
}, [closeConfirm]);
|
||||
|
||||
return {
|
||||
// State
|
||||
isAlertOpen: state.isAlertOpen,
|
||||
isConfirmOpen: state.isConfirmOpen,
|
||||
alertOptions: state.alertOptions,
|
||||
confirmOptions: state.confirmOptions,
|
||||
|
||||
// Actions
|
||||
showAlert,
|
||||
showConfirm,
|
||||
closeAlert,
|
||||
closeConfirm: handleConfirmCancel,
|
||||
handleConfirm,
|
||||
|
||||
// Convenience methods
|
||||
showError: useCallback((message: string, title?: string) => {
|
||||
showAlert({ message, title, type: 'error' });
|
||||
}, [showAlert]),
|
||||
|
||||
showSuccess: useCallback((message: string, title?: string) => {
|
||||
showAlert({ message, title, type: 'success' });
|
||||
}, [showAlert]),
|
||||
|
||||
showWarning: useCallback((message: string, title?: string) => {
|
||||
showAlert({ message, title, type: 'warning' });
|
||||
}, [showAlert]),
|
||||
|
||||
confirmDelete: useCallback((message: string, title?: string) => {
|
||||
return showConfirm({
|
||||
message,
|
||||
title: title || 'Confirm Deletion',
|
||||
type: 'danger',
|
||||
isDestructive: true
|
||||
});
|
||||
}, [showConfirm])
|
||||
};
|
||||
}
|
144
src/hooks/useSlideOperations.ts
Normal file
144
src/hooks/useSlideOperations.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { Presentation, Slide } from '../types/presentation.ts';
|
||||
import { updatePresentation } from '../utils/presentationStorage.ts';
|
||||
import { loggers } from '../utils/logger.ts';
|
||||
|
||||
interface UseSlideOperationsProps {
|
||||
presentation: Presentation | null;
|
||||
presentationId: string;
|
||||
onPresentationUpdate: (presentation: Presentation) => void;
|
||||
onError: (error: string) => void;
|
||||
confirmDelete: (message: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const useSlideOperations = ({
|
||||
presentation,
|
||||
presentationId,
|
||||
onPresentationUpdate,
|
||||
onError,
|
||||
confirmDelete
|
||||
}: UseSlideOperationsProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const duplicateSlide = async (slideIndex: number) => {
|
||||
if (!presentation) return;
|
||||
|
||||
const slideToDuplicate = presentation.slides[slideIndex];
|
||||
if (!slideToDuplicate) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
onError('');
|
||||
|
||||
// Create a duplicate slide with new ID
|
||||
const duplicatedSlide: Slide = {
|
||||
...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
|
||||
onPresentationUpdate(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) {
|
||||
loggers.presentation.error('Failed to duplicate slide', err instanceof Error ? err : new Error(String(err)));
|
||||
onError(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.`;
|
||||
}
|
||||
|
||||
const confirmed = await confirmDelete(confirmMessage);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
onError('');
|
||||
|
||||
// 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
|
||||
onPresentationUpdate(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) {
|
||||
loggers.presentation.error('Failed to delete slide', err instanceof Error ? err : new Error(String(err)));
|
||||
onError(err instanceof Error ? err.message : 'Failed to delete slide');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
duplicateSlide,
|
||||
deleteSlide,
|
||||
saving
|
||||
};
|
||||
};
|
@ -1,8 +1,8 @@
|
||||
// Re-export from the theme loader utility
|
||||
export { discoverThemes as getThemes, loadTheme } from '../utils/themeLoader';
|
||||
export { discoverThemes as getThemes, loadTheme } from '../utils/themeLoader.ts';
|
||||
|
||||
// Import for internal use
|
||||
import { loadTheme } from '../utils/themeLoader';
|
||||
import { loadTheme } from '../utils/themeLoader.ts';
|
||||
|
||||
// Helper function to get a single theme by ID
|
||||
export const getTheme = async (themeId: string) => {
|
||||
|
@ -58,6 +58,9 @@ export interface SlideContent {
|
||||
order: number;
|
||||
}
|
||||
|
||||
// Alias for backward compatibility and cleaner import names
|
||||
export type Slide = SlideContent;
|
||||
|
||||
export interface Presentation {
|
||||
metadata: PresentationMetadata;
|
||||
slides: SlideContent[];
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { loggers } from './logger.ts';
|
||||
|
||||
export interface CSSVariables {
|
||||
[key: string]: string;
|
||||
}
|
||||
@ -47,7 +49,7 @@ export const loadCSSVariables = async (cssFilePath: string): Promise<CSSVariable
|
||||
const cssContent = await response.text();
|
||||
return parseCSSVariables(cssContent);
|
||||
} catch (error) {
|
||||
console.warn(`Could not load CSS variables from ${cssFilePath}:`, error);
|
||||
loggers.theme.warn(`Could not load CSS variables from ${cssFilePath}`, error instanceof Error ? error : new Error(String(error)));
|
||||
return {};
|
||||
}
|
||||
};
|
||||
@ -159,7 +161,7 @@ export const loadThemeFromCSS = async (cssFilePath: string): Promise<{
|
||||
variables: parseCSSVariables(cssContent)
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`Could not load theme from ${cssFilePath}:`, error);
|
||||
loggers.theme.warn(`Could not load theme from ${cssFilePath}`, error instanceof Error ? error : new Error(String(error)));
|
||||
return {
|
||||
metadata: null,
|
||||
variables: {}
|
||||
|
172
src/utils/htmlSanitizer.ts
Normal file
172
src/utils/htmlSanitizer.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
/**
|
||||
* Configuration for HTML sanitization based on context
|
||||
*/
|
||||
interface SanitizeConfig {
|
||||
/**
|
||||
* Whether to allow basic HTML formatting tags (b, i, em, strong, etc.)
|
||||
*/
|
||||
allowFormatting?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to allow structural HTML elements (div, span, p, etc.)
|
||||
*/
|
||||
allowStructural?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to allow image tags (with strict src validation)
|
||||
*/
|
||||
allowImages?: boolean;
|
||||
|
||||
/**
|
||||
* Custom allowed tags (overrides other options if provided)
|
||||
*/
|
||||
allowedTags?: string[];
|
||||
|
||||
/**
|
||||
* Custom allowed attributes (overrides defaults if provided)
|
||||
*/
|
||||
allowedAttributes?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default safe configuration for slide content
|
||||
*/
|
||||
const DEFAULT_SLIDE_CONFIG: Required<SanitizeConfig> = {
|
||||
allowFormatting: true,
|
||||
allowStructural: true,
|
||||
allowImages: false,
|
||||
allowedTags: [
|
||||
// Text formatting
|
||||
'b', 'i', 'em', 'strong', 'u', 'mark', 'small', 'sub', 'sup',
|
||||
// Structure
|
||||
'div', 'span', 'p', 'br', 'hr',
|
||||
// Lists
|
||||
'ul', 'ol', 'li',
|
||||
// Headings
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
// Quotes
|
||||
'blockquote', 'cite'
|
||||
],
|
||||
allowedAttributes: [
|
||||
'class', 'id', 'style', 'data-*'
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes HTML content to prevent XSS attacks while preserving safe formatting
|
||||
*
|
||||
* @param html - The HTML string to sanitize
|
||||
* @param config - Configuration options for sanitization
|
||||
* @returns Sanitized HTML string safe for rendering
|
||||
*/
|
||||
export function sanitizeHtml(html: string, config: SanitizeConfig = {}): string {
|
||||
if (!html || typeof html !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const finalConfig = { ...DEFAULT_SLIDE_CONFIG, ...config };
|
||||
|
||||
// Build DOMPurify configuration
|
||||
const purifyConfig: {
|
||||
ALLOWED_TAGS: string[];
|
||||
ALLOWED_ATTR: string[];
|
||||
FORBID_TAGS: string[];
|
||||
FORBID_ATTR: string[];
|
||||
KEEP_CONTENT: boolean;
|
||||
ALLOW_DATA_ATTR: boolean;
|
||||
} = {
|
||||
ALLOWED_TAGS: finalConfig.allowedTags,
|
||||
ALLOWED_ATTR: finalConfig.allowedAttributes,
|
||||
// Remove any scripts or dangerous content
|
||||
FORBID_TAGS: ['script', 'object', 'embed', 'base', 'link', 'meta', 'style'],
|
||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
|
||||
// Keep content structure but remove dangerous elements
|
||||
KEEP_CONTENT: true,
|
||||
// Allow data attributes for slots and styling
|
||||
ALLOW_DATA_ATTR: true
|
||||
};
|
||||
|
||||
// Add image support if requested
|
||||
if (finalConfig.allowImages) {
|
||||
purifyConfig.ALLOWED_TAGS.push('img');
|
||||
purifyConfig.ALLOWED_ATTR.push('src', 'alt', 'width', 'height');
|
||||
|
||||
// Add hook to validate image sources
|
||||
DOMPurify.addHook('beforeSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'IMG') {
|
||||
const src = node.getAttribute('src');
|
||||
if (src && !isValidImageSource(src)) {
|
||||
node.removeAttribute('src');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const sanitized = DOMPurify.sanitize(html, purifyConfig);
|
||||
|
||||
// Clean up hooks to prevent memory leaks
|
||||
DOMPurify.removeAllHooks();
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates image sources to ensure they're safe
|
||||
*
|
||||
* @param src - Image source URL
|
||||
* @returns Whether the source is considered safe
|
||||
*/
|
||||
function isValidImageSource(src: string): boolean {
|
||||
try {
|
||||
const url = new URL(src, window.location.origin);
|
||||
|
||||
// Allow data URLs for base64 images
|
||||
if (url.protocol === 'data:') {
|
||||
return src.startsWith('data:image/');
|
||||
}
|
||||
|
||||
// Allow HTTPS and HTTP from trusted domains
|
||||
if (url.protocol === 'https:' || url.protocol === 'http:') {
|
||||
// Add any domain restrictions here if needed
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific sanitization for slide template content
|
||||
* Allows structural elements and formatting but restricts dangerous content
|
||||
*
|
||||
* @param html - Template HTML to sanitize
|
||||
* @returns Sanitized HTML safe for slide rendering
|
||||
*/
|
||||
export function sanitizeSlideTemplate(html: string): string {
|
||||
return sanitizeHtml(html, {
|
||||
allowFormatting: true,
|
||||
allowStructural: true,
|
||||
allowImages: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific sanitization for user-provided content
|
||||
* More restrictive than template sanitization
|
||||
*
|
||||
* @param html - User content to sanitize
|
||||
* @returns Sanitized HTML safe for user content
|
||||
*/
|
||||
export function sanitizeUserContent(html: string): string {
|
||||
return sanitizeHtml(html, {
|
||||
allowFormatting: true,
|
||||
allowStructural: false,
|
||||
allowImages: false,
|
||||
allowedTags: ['b', 'i', 'em', 'strong', 'u', 'br'],
|
||||
allowedAttributes: []
|
||||
});
|
||||
}
|
204
src/utils/logger.ts
Normal file
204
src/utils/logger.ts
Normal file
@ -0,0 +1,204 @@
|
||||
import log from 'loglevel';
|
||||
|
||||
/**
|
||||
* Application logger configuration and utilities
|
||||
*/
|
||||
|
||||
// Define log levels for different parts of the application
|
||||
export enum LogLevel {
|
||||
TRACE = 0,
|
||||
DEBUG = 1,
|
||||
INFO = 2,
|
||||
WARN = 3,
|
||||
ERROR = 4,
|
||||
SILENT = 5,
|
||||
}
|
||||
|
||||
// Log categories for better organization
|
||||
export enum LogCategory {
|
||||
PRESENTATION = 'presentation',
|
||||
THEME = 'theme',
|
||||
STORAGE = 'storage',
|
||||
UI = 'ui',
|
||||
SECURITY = 'security',
|
||||
PERFORMANCE = 'performance',
|
||||
GENERAL = 'general',
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure logging based on environment
|
||||
*/
|
||||
function configureLogger(): void {
|
||||
// Set default log level based on environment
|
||||
const isDevelopment = import.meta.env.DEV;
|
||||
const isProduction = import.meta.env.PROD;
|
||||
|
||||
if (isDevelopment) {
|
||||
log.setLevel(LogLevel.DEBUG);
|
||||
} else if (isProduction) {
|
||||
log.setLevel(LogLevel.WARN);
|
||||
} else {
|
||||
log.setLevel(LogLevel.INFO);
|
||||
}
|
||||
|
||||
// Add timestamp and category formatting
|
||||
const originalFactory = log.methodFactory;
|
||||
log.methodFactory = function (methodName, logLevel, loggerName) {
|
||||
const rawMethod = originalFactory(methodName, logLevel, loggerName);
|
||||
|
||||
return function (category: LogCategory | string, message: string, ...args: LogData[]) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const formattedMessage = `[${timestamp}] [${methodName.toUpperCase()}] [${category}] ${message}`;
|
||||
|
||||
if (args.length > 0) {
|
||||
rawMethod(formattedMessage, ...args);
|
||||
} else {
|
||||
rawMethod(formattedMessage);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Apply the new factory
|
||||
log.setLevel(log.getLevel());
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid log data types
|
||||
*/
|
||||
type LogData = string | number | boolean | null | undefined | Error | Record<string, unknown> | unknown[];
|
||||
|
||||
/**
|
||||
* Logger interface for the application
|
||||
*/
|
||||
export interface Logger {
|
||||
trace(category: LogCategory, message: string, ...args: LogData[]): void;
|
||||
debug(category: LogCategory, message: string, ...args: LogData[]): void;
|
||||
info(category: LogCategory, message: string, ...args: LogData[]): void;
|
||||
warn(category: LogCategory, message: string, ...args: LogData[]): void;
|
||||
error(category: LogCategory, message: string, ...args: LogData[]): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a logger instance with proper configuration
|
||||
*/
|
||||
function createLogger(): Logger {
|
||||
configureLogger();
|
||||
|
||||
return {
|
||||
trace: (category: LogCategory, message: string, ...args: LogData[]) => {
|
||||
log.trace(category, message, ...args);
|
||||
},
|
||||
|
||||
debug: (category: LogCategory, message: string, ...args: LogData[]) => {
|
||||
log.debug(category, message, ...args);
|
||||
},
|
||||
|
||||
info: (category: LogCategory, message: string, ...args: LogData[]) => {
|
||||
log.info(category, message, ...args);
|
||||
},
|
||||
|
||||
warn: (category: LogCategory, message: string, ...args: LogData[]) => {
|
||||
log.warn(category, message, ...args);
|
||||
},
|
||||
|
||||
error: (category: LogCategory, message: string, ...args: LogData[]) => {
|
||||
log.error(category, message, ...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Export the configured logger instance
|
||||
export const logger = createLogger();
|
||||
|
||||
/**
|
||||
* Convenience functions for common logging scenarios
|
||||
*/
|
||||
export const loggers = {
|
||||
presentation: {
|
||||
info: (message: string, ...args: LogData[]) => logger.info(LogCategory.PRESENTATION, message, ...args),
|
||||
warn: (message: string, ...args: LogData[]) => logger.warn(LogCategory.PRESENTATION, message, ...args),
|
||||
error: (message: string, ...args: LogData[]) => logger.error(LogCategory.PRESENTATION, message, ...args),
|
||||
debug: (message: string, ...args: LogData[]) => logger.debug(LogCategory.PRESENTATION, message, ...args),
|
||||
},
|
||||
|
||||
theme: {
|
||||
info: (message: string, ...args: LogData[]) => logger.info(LogCategory.THEME, message, ...args),
|
||||
warn: (message: string, ...args: LogData[]) => logger.warn(LogCategory.THEME, message, ...args),
|
||||
error: (message: string, ...args: LogData[]) => logger.error(LogCategory.THEME, message, ...args),
|
||||
debug: (message: string, ...args: LogData[]) => logger.debug(LogCategory.THEME, message, ...args),
|
||||
},
|
||||
|
||||
storage: {
|
||||
info: (message: string, ...args: LogData[]) => logger.info(LogCategory.STORAGE, message, ...args),
|
||||
warn: (message: string, ...args: LogData[]) => logger.warn(LogCategory.STORAGE, message, ...args),
|
||||
error: (message: string, ...args: LogData[]) => logger.error(LogCategory.STORAGE, message, ...args),
|
||||
debug: (message: string, ...args: LogData[]) => logger.debug(LogCategory.STORAGE, message, ...args),
|
||||
},
|
||||
|
||||
ui: {
|
||||
info: (message: string, ...args: LogData[]) => logger.info(LogCategory.UI, message, ...args),
|
||||
warn: (message: string, ...args: LogData[]) => logger.warn(LogCategory.UI, message, ...args),
|
||||
error: (message: string, ...args: LogData[]) => logger.error(LogCategory.UI, message, ...args),
|
||||
debug: (message: string, ...args: LogData[]) => logger.debug(LogCategory.UI, message, ...args),
|
||||
},
|
||||
|
||||
security: {
|
||||
info: (message: string, ...args: LogData[]) => logger.info(LogCategory.SECURITY, message, ...args),
|
||||
warn: (message: string, ...args: LogData[]) => logger.warn(LogCategory.SECURITY, message, ...args),
|
||||
error: (message: string, ...args: LogData[]) => logger.error(LogCategory.SECURITY, message, ...args),
|
||||
debug: (message: string, ...args: LogData[]) => logger.debug(LogCategory.SECURITY, message, ...args),
|
||||
},
|
||||
|
||||
performance: {
|
||||
info: (message: string, ...args: LogData[]) => logger.info(LogCategory.PERFORMANCE, message, ...args),
|
||||
warn: (message: string, ...args: LogData[]) => logger.warn(LogCategory.PERFORMANCE, message, ...args),
|
||||
error: (message: string, ...args: LogData[]) => logger.error(LogCategory.PERFORMANCE, message, ...args),
|
||||
debug: (message: string, ...args: LogData[]) => logger.debug(LogCategory.PERFORMANCE, message, ...args),
|
||||
},
|
||||
|
||||
general: {
|
||||
info: (message: string, ...args: LogData[]) => logger.info(LogCategory.GENERAL, message, ...args),
|
||||
warn: (message: string, ...args: LogData[]) => logger.warn(LogCategory.GENERAL, message, ...args),
|
||||
error: (message: string, ...args: LogData[]) => logger.error(LogCategory.GENERAL, message, ...args),
|
||||
debug: (message: string, ...args: LogData[]) => logger.debug(LogCategory.GENERAL, message, ...args),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Performance timing utility
|
||||
*/
|
||||
export function createPerformanceTimer(operation: string) {
|
||||
const startTime = performance.now();
|
||||
|
||||
return {
|
||||
end: () => {
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
loggers.performance.info(`Operation '${operation}' completed in ${duration.toFixed(2)}ms`);
|
||||
return duration;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Error logging utility with stack trace
|
||||
*/
|
||||
export function logError(category: LogCategory, message: string, error?: Error | unknown, context?: Record<string, any>) {
|
||||
if (error instanceof Error) {
|
||||
logger.error(category, `${message}: ${error.message}`, {
|
||||
stack: error.stack,
|
||||
context,
|
||||
});
|
||||
} else {
|
||||
logger.error(category, message, { error, context });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Development-only logging
|
||||
*/
|
||||
export function devLog(category: LogCategory, message: string, ...args: any[]) {
|
||||
if (import.meta.env.DEV) {
|
||||
logger.debug(category, `[DEV] ${message}`, ...args);
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import type { Presentation, PresentationMetadata, CreatePresentationRequest } from '../types/presentation';
|
||||
import type { Presentation, PresentationMetadata, CreatePresentationRequest } from '../types/presentation.ts';
|
||||
|
||||
const DB_NAME = 'SlideshareDB';
|
||||
const DB_VERSION = 1;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import type { SlideLayout, SlotConfig } from '../types/theme';
|
||||
import type { SlideLayout, SlotConfig } from '../types/theme.ts';
|
||||
|
||||
/**
|
||||
* Creates a simple SVG pattern for image slots
|
||||
@ -66,7 +66,7 @@ const SAMPLE_CONTENT = {
|
||||
* Generates sample data for a slot based on its configuration
|
||||
*/
|
||||
export const generateSampleDataForSlot = (slot: SlotConfig): string => {
|
||||
const { id, type, placeholder, defaultContent } = slot;
|
||||
const { id, type, defaultContent } = slot;
|
||||
|
||||
// Use default content if available
|
||||
if (defaultContent && defaultContent.trim()) {
|
||||
@ -81,21 +81,25 @@ export const generateSampleDataForSlot = (slot: SlotConfig): string => {
|
||||
case 'image':
|
||||
return createImageSVG(slotDisplayName);
|
||||
|
||||
case 'title':
|
||||
case 'title': {
|
||||
const titleSamples = SAMPLE_CONTENT.title;
|
||||
return titleSamples[Math.floor(Math.random() * titleSamples.length)];
|
||||
}
|
||||
|
||||
case 'subtitle':
|
||||
case 'subtitle': {
|
||||
const subtitleSamples = SAMPLE_CONTENT.subtitle;
|
||||
return subtitleSamples[Math.floor(Math.random() * subtitleSamples.length)];
|
||||
}
|
||||
|
||||
case 'heading':
|
||||
case 'heading': {
|
||||
const headingSamples = SAMPLE_CONTENT.heading;
|
||||
return headingSamples[Math.floor(Math.random() * headingSamples.length)];
|
||||
}
|
||||
|
||||
case 'text':
|
||||
case 'text': {
|
||||
const textSamples = SAMPLE_CONTENT.text;
|
||||
return textSamples[Math.floor(Math.random() * textSamples.length)];
|
||||
}
|
||||
|
||||
case 'video':
|
||||
return createImageSVG(`${slotDisplayName} (Video)`, 640, 360);
|
||||
@ -103,9 +107,10 @@ export const generateSampleDataForSlot = (slot: SlotConfig): string => {
|
||||
case 'audio':
|
||||
return `[${slotDisplayName} Audio]`;
|
||||
|
||||
case 'list':
|
||||
case 'list': {
|
||||
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}`;
|
||||
|
@ -1,5 +1,6 @@
|
||||
import type { Theme, SlideLayout, SlotConfig } from '../types/theme';
|
||||
import { parseThemeMetadata, parseCSSVariables } from './cssParser';
|
||||
import type { Theme, SlideLayout, SlotConfig } from '../types/theme.ts';
|
||||
import { parseThemeMetadata, parseCSSVariables } from './cssParser.ts';
|
||||
import { loggers } from './logger.ts';
|
||||
|
||||
// Theme cache management
|
||||
let themeCache: Theme[] | null = null;
|
||||
@ -36,11 +37,11 @@ export const discoverThemes = async (bustCache = false): Promise<Theme[]> => {
|
||||
themes.push(theme);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load theme ${themeId}:`, error);
|
||||
loggers.theme.warn(`Failed to load theme ${themeId}`, error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load themes manifest:', error);
|
||||
loggers.theme.warn('Failed to load themes manifest', error instanceof Error ? error : new Error(String(error)));
|
||||
// Fallback to default theme if manifest fails
|
||||
try {
|
||||
const defaultTheme = await loadTheme('default', bustCache);
|
||||
@ -104,7 +105,7 @@ export const loadTheme = async (themeId: string, bustCache = false): Promise<The
|
||||
masterSlideTemplate
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`Could not load theme ${themeId}:`, error);
|
||||
loggers.theme.warn(`Could not load theme ${themeId}`, error instanceof Error ? error : new Error(String(error)));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@ -160,7 +161,7 @@ const discoverLayouts = async (basePath: string, themeId: string, bustCache = fa
|
||||
const results = await Promise.all(layoutPromises);
|
||||
layouts.push(...results.filter((layout): layout is SlideLayout => layout !== null));
|
||||
} catch (error) {
|
||||
console.warn('Error discovering layouts:', error);
|
||||
loggers.theme.warn('Error discovering layouts', error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
|
||||
return layouts;
|
||||
|
Loading…
Reference in New Issue
Block a user