Compare commits

..

No commits in common. "1008bd4bca61ddf1ce1cad45dab9a841de74dd14" and "4ce9f225a6d0eb6cb08cfa1a29032f2b182916a5" have entirely different histories.

56 changed files with 555 additions and 2834 deletions

View File

@ -53,9 +53,6 @@
# 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
@ -66,58 +63,3 @@
- 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
# 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

View File

@ -47,9 +47,9 @@
- [ ] User can edit slide content without preview if desired by clicking inside content slot areas
### Remove Slide
- [x] User can delete slides from presentation
- [x] User gets confirmation before slide deletion
- [x] Slide order adjusts automatically
- [ ] User can delete slides from presentation
- [ ] User gets confirmation before slide deletion
- [ ] Slide order adjusts automatically
### Preview Slides
- [ ] User can preview individual slides

28
package-lock.json generated
View File

@ -8,7 +8,6 @@
"name": "slideshare",
"version": "0.0.0",
"dependencies": {
"dompurify": "^3.2.6",
"loglevel": "^1.9.2",
"postcss": "^8.5.6",
"react": "^19.1.1",
@ -17,7 +16,6 @@
},
"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",
@ -1398,16 +1396,6 @@
"@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",
@ -1452,13 +1440,6 @@
"@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",
@ -2016,15 +1997,6 @@
"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",

View File

@ -11,7 +11,6 @@
"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",
@ -20,7 +19,6 @@
},
"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",

View File

@ -12,5 +12,5 @@
"hasMasterSlide": true
}
},
"generated": "2025-08-21T11:51:03.695Z"
"generated": "2025-08-20T22:06:06.798Z"
}

View File

@ -1,15 +1,8 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
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 { ThemeBrowser, ThemeDetailPage, LayoutDetailPage, LayoutPreviewPage } from './components/themes'
import { NewPresentationPage, PresentationViewer, PresentationEditor, SlideEditor, PresentationsList } from './components/presentations'
import { AppHeader } from './components/AppHeader'
import { Welcome } from './components/Welcome'
import './App.css'
import './components/themes/ThemeBrowser.css'

View File

@ -1,88 +0,0 @@
.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;
}

View File

@ -1,44 +0,0 @@
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>
);
};

View File

@ -1,96 +0,0 @@
.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;
}

View File

@ -1,63 +0,0 @@
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>
);
};

View File

@ -1,129 +0,0 @@
.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;
}

View File

@ -1,55 +0,0 @@
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>
);
};

View File

@ -1,15 +1,11 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
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 type { Theme } from '../../types/theme';
import type { AspectRatio } from '../../types/presentation';
import { ASPECT_RATIOS } from '../../types/presentation';
import { getThemes } from '../../themes';
import { createPresentation } from '../../utils/presentationStorage';
import { ThemeSelector } from './ThemeSelector';
import './NewPresentationPage.css';
export const NewPresentationPage: React.FC = () => {
@ -22,10 +18,6 @@ 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 () => {
@ -50,20 +42,12 @@ export const NewPresentationPage: React.FC = () => {
const handleCreatePresentation = async () => {
if (!selectedTheme) {
setAlertDialog({
isOpen: true,
message: 'Please select a theme for your presentation',
type: 'warning'
});
alert('Please select a theme for your presentation');
return;
}
if (!presentationTitle.trim()) {
setAlertDialog({
isOpen: true,
message: 'Please enter a title for your presentation',
type: 'warning'
});
alert('Please enter a title for your presentation');
return;
}
@ -78,13 +62,13 @@ export const NewPresentationPage: React.FC = () => {
aspectRatio: selectedAspectRatio
});
loggers.presentation.info('Presentation created successfully', { presentationId: presentation.metadata.id, name: presentation.metadata.name });
console.log('Presentation created successfully:', presentation);
// Navigate to the new presentation editor (slide 1)
navigate(`/presentations/${presentation.metadata.id}/edit/slides/1`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create presentation');
loggers.presentation.error('Failed to create presentation', err instanceof Error ? err : new Error(String(err)));
console.error('Error creating presentation:', err);
} finally {
setCreating(false);
}
@ -132,41 +116,122 @@ export const NewPresentationPage: React.FC = () => {
<main className="page-content">
<div className="creation-form">
<PresentationDetailsForm
title={presentationTitle}
description={presentationDescription}
onTitleChange={setPresentationTitle}
onDescriptionChange={setPresentationDescription}
<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>
<AspectRatioSelector
selectedAspectRatio={selectedAspectRatio}
onAspectRatioChange={setSelectedAspectRatio}
<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>
<ThemeSelectionSection
<section className="aspect-ratio-selection">
<h2>Choose Aspect Ratio</h2>
<p className="section-description">
Select the aspect ratio that best fits your display setup
</p>
<div className="aspect-ratio-grid">
{ASPECT_RATIOS.map((ratio) => (
<div
key={ratio.id}
className={`aspect-ratio-card ${selectedAspectRatio === ratio.id ? 'selected' : ''}`}
onClick={() => setSelectedAspectRatio(ratio.id)}
>
<div className="aspect-ratio-preview">
<div className={`preview-box ${ratio.cssClass}`}></div>
</div>
<div className="aspect-ratio-info">
<h3>{ratio.name}</h3>
<p className="ratio-description">{ratio.description}</p>
<div className="ratio-dimensions">
{ratio.width} × {ratio.height}
</div>
</div>
</div>
))}
</div>
</section>
<section className="theme-selection">
<h2>Choose a Theme</h2>
<p className="section-description">
Select a theme that matches the style and tone of your presentation
</p>
{themes.length > 0 ? (
<ThemeSelector
themes={themes}
selectedTheme={selectedTheme}
onThemeSelect={setSelectedTheme}
/>
) : (
<div className="no-themes">
<p>No themes available. Please check your theme configuration.</p>
</div>
)}
</section>
<CreationActions
selectedTheme={selectedTheme}
error={error}
creating={creating}
presentationTitle={presentationTitle}
onCancel={() => navigate('/themes')}
onCreate={handleCreatePresentation}
/>
<section className="creation-actions">
<div className="selected-theme-info">
{selectedTheme && (
<div className="theme-preview-info">
<h3>Selected Theme: {selectedTheme.name}</h3>
<p>{selectedTheme.description}</p>
<div className="theme-stats">
<span>{selectedTheme.layouts.length} layouts available</span>
{selectedTheme.author && <span>by {selectedTheme.author}</span>}
</div>
</div>
)}
{error && (
<div className="creation-error">
<p>Failed to create presentation: {error}</p>
</div>
)}
</div>
<div className="action-buttons">
<button
onClick={() => navigate('/themes')}
className="button secondary"
type="button"
disabled={creating}
>
Cancel
</button>
<button
onClick={handleCreatePresentation}
className="button primary"
type="button"
disabled={!selectedTheme || !presentationTitle.trim() || creating}
>
{creating ? 'Creating...' : 'Create Presentation'}
</button>
</div>
</section>
</div>
</main>
<AlertDialog
isOpen={alertDialog.isOpen}
onClose={() => setAlertDialog({ isOpen: false, message: '' })}
message={alertDialog.message}
type={alertDialog.type}
/>
</div>
);
};

View File

@ -1,39 +0,0 @@
.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;
}

View File

@ -1,46 +0,0 @@
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>
);
};

View File

@ -41,12 +41,30 @@
color: #334155;
}
.presentation-title span {
color: #64748b;
.presentation-title {
flex: 1;
min-width: 0;
}
.presentation-title h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presentation-description {
margin: 0.25rem 0 0 0;
color: #64748b;
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presentation-meta {
display: flex;
gap: 1rem;

View File

@ -1,18 +1,9 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
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 type { Presentation } from '../../types/presentation';
import type { Theme } from '../../types/theme';
import { getPresentationById, updatePresentation } from '../../utils/presentationStorage';
import { getTheme } from '../../themes';
import './PresentationEditor.css';
export const PresentationEditor: React.FC = () => {
@ -26,28 +17,10 @@ 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 () => {
if (!presentationId) {
@ -93,6 +66,18 @@ 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;
@ -100,11 +85,150 @@ 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">
<LoadingState message="Loading presentation editor..." />
<div className="loading-content">
<div className="loading-spinner">Loading presentation editor...</div>
</div>
</div>
);
}
@ -112,12 +236,11 @@ export const PresentationEditor: React.FC = () => {
if (error) {
return (
<div className="presentation-editor">
<ErrorState
title="Error Loading Presentation"
message={error}
backLink="/themes"
backText="← Back to Themes"
/>
<div className="error-content">
<h2>Error Loading Presentation</h2>
<p>{error}</p>
<Link to="/themes" className="back-link"> Back to Themes</Link>
</div>
</div>
);
}
@ -125,12 +248,11 @@ export const PresentationEditor: React.FC = () => {
if (!presentation || !theme) {
return (
<div className="presentation-editor">
<ErrorState
title="Presentation Not Found"
message="The requested presentation could not be found."
backLink="/themes"
backText="← Back to Themes"
/>
<div className="not-found-content">
<h2>Presentation Not Found</h2>
<p>The requested presentation could not be found.</p>
<Link to="/themes" className="back-link"> Back to Themes</Link>
</div>
</div>
);
}
@ -144,50 +266,202 @@ export const PresentationEditor: React.FC = () => {
<div className="presentation-info">
<Link to="/themes" className="back-link"> Back</Link>
<div className="presentation-title">
<span>{presentation.metadata.name}</span>
<h1>{presentation.metadata.name}</h1>
{presentation.metadata.description && (
<span className="presentation-description">{presentation.metadata.description}</span>
<p className="presentation-description">{presentation.metadata.description}</p>
)}
</div>
<div className="presentation-meta">
<span className="theme-badge">Theme: {theme.name}</span>
<span className="slide-counter">
{totalSlides === 0 ? 'No slides' : `Slide ${currentSlideIndex + 1} of ${totalSlides}`}
{totalSlides === 0 ? 'No slides' : `Editing slide ${currentSlideIndex + 1} of ${totalSlides}`}
</span>
{saving && <span className="saving-indicator">Saving...</span>}
</div>
</div>
<div className="editor-actions">
<button
type="button"
className="action-button secondary"
onClick={savePresentation}
disabled={saving}
>
{saving ? 'Saving...' : 'Save'}
</button>
<button
type="button"
className="action-button secondary"
onClick={previewPresentation}
>
Preview
</button>
<button
type="button"
className="action-button primary"
onClick={addNewSlide}
>
Add Slide
</button>
</div>
</header>
<main className="editor-content">
{totalSlides === 0 ? (
<EmptyPresentationState
theme={theme}
onAddFirstSlide={addNewSlide}
/>
<div className="empty-presentation">
<div className="empty-content">
<h2>Start creating your presentation</h2>
<p>Add your first slide to begin editing your presentation</p>
{theme && (
<div className="theme-preview">
<h3>Using Theme: {theme.name}</h3>
<p className="theme-description">{theme.description}</p>
<div className="available-layouts">
<h4>Available Layouts ({theme.layouts.length})</h4>
<div className="layouts-grid">
{theme.layouts.slice(0, 6).map((layout) => (
<div key={layout.id} className="layout-preview-card">
<div className="layout-name">{layout.name}</div>
<div className="layout-description">{layout.description}</div>
<div className="slot-count">{layout.slots.length} slots</div>
</div>
))}
{theme.layouts.length > 6 && (
<div className="more-layouts">
+{theme.layouts.length - 6} more layouts
</div>
)}
</div>
</div>
</div>
)}
<button
type="button"
className="action-button primary large"
onClick={addNewSlide}
>
Add First Slide
</button>
</div>
</div>
) : (
<div className="editor-layout">
<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}
/>
<aside className="slide-sidebar">
<div className="sidebar-header">
<h3>Slides</h3>
<button
type="button"
className="add-slide-button"
onClick={addNewSlide}
title="Add new slide"
>
+
</button>
</div>
<div className="slides-list">
{presentation.slides.map((slide, index) => (
<div
key={slide.id}
className={`slide-thumbnail ${index === currentSlideIndex ? 'active' : ''}`}
onClick={() => goToSlide(index)}
onDoubleClick={() => navigate(`/presentations/${presentationId}/slide/${slide.id}/edit`)}
>
<div className="thumbnail-number">{index + 1}</div>
<div className="thumbnail-preview">
<div className="thumbnail-content">
<span className="layout-name">{slide.layoutId}</span>
<span className="content-count">
{Object.keys(slide.content).length} items
</span>
</div>
</div>
<div className="thumbnail-actions">
<button
type="button"
className="thumbnail-action edit"
onClick={(e) => {
e.stopPropagation();
navigate(`/presentations/${presentationId}/slide/${slide.id}/edit`);
}}
title="Edit slide content"
disabled={saving}
>
</button>
<button
type="button"
className="thumbnail-action"
onClick={(e) => {
e.stopPropagation();
duplicateSlide(index);
}}
title="Duplicate slide"
disabled={saving}
>
</button>
<button
type="button"
className="thumbnail-action delete"
onClick={(e) => {
e.stopPropagation();
deleteSlide(index);
}}
title="Delete slide"
disabled={saving}
>
</button>
</div>
</div>
))}
</div>
</aside>
<div className="slide-editor-area">
{currentSlide ? (
<div className="slide-editor">
<div className="slide-header">
<h3>Slide {currentSlideIndex + 1}</h3>
<h3>Slide {currentSlideIndex + 1} - {currentSlide.layoutId}</h3>
<div className="slide-controls">
<button
type="button"
className="control-button edit-slide-button"
onClick={() => navigate(`/presentations/${presentationId}/slide/${currentSlide.id}/edit`)}
disabled={saving}
>
Edit Content
</button>
<button
type="button"
className="control-button"
onClick={goToPreviousSlide}
disabled={currentSlideIndex === 0}
>
Previous
</button>
<button
type="button"
className="control-button"
onClick={goToNextSlide}
disabled={currentSlideIndex === totalSlides - 1}
>
Next
</button>
</div>
</div>
<div className="slide-content-editor">
<div className="content-preview">
{/* TODO: Render actual slide content with editing capabilities */}
<div className="editor-placeholder">
<h4>Slide Content Editor</h4>
<p>Layout: {currentSlide.layoutId}</p>
<p>Content slots: {Object.keys(currentSlide.content).length}</p>
<div className="content-slots">
{Object.entries(currentSlide.content).map(([slotId, content]) => (
<div key={slotId} className="content-slot">
@ -209,7 +483,7 @@ export const PresentationEditor: React.FC = () => {
value={currentSlide.notes}
onChange={(e) => {
// TODO: Update slide notes
loggers.presentation.debug('Slide notes updated', { slideId: currentSlide.id, notesLength: e.target.value.length });
console.log('Update notes:', e.target.value);
}}
placeholder="Add speaker notes for this slide..."
className="notes-textarea"
@ -234,28 +508,6 @@ 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>
);
};

View File

@ -1,10 +1,9 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
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 type { Presentation } from '../../types/presentation';
import type { Theme } from '../../types/theme';
import { getPresentationById } from '../../utils/presentationStorage';
import { getTheme } from '../../themes';
import './PresentationViewer.css';
export const PresentationViewer: React.FC = () => {
@ -87,7 +86,7 @@ export const PresentationViewer: React.FC = () => {
const enterPresentationMode = () => {
// TODO: Implement full-screen presentation mode
loggers.ui.info('Full-screen presentation mode requested - feature to be implemented');
console.log('Full-screen presentation mode to be implemented');
};
if (loading) {

View File

@ -1,8 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { Presentation } from '../../types/presentation.ts';
import { getAllPresentations, deletePresentation } from '../../utils/presentationStorage.ts';
import { loggers } from '../../utils/logger.ts';
import type { Presentation } from '../../types/presentation';
import { getAllPresentations, deletePresentation } from '../../utils/presentationStorage';
import './PresentationsList.css';
export const PresentationsList: React.FC = () => {
@ -39,7 +38,7 @@ export const PresentationsList: React.FC = () => {
await deletePresentation(id);
setPresentations(prev => prev.filter(p => p.metadata.id !== id));
} catch (err) {
loggers.presentation.error('Failed to delete presentation', err instanceof Error ? err : new Error(String(err)));
console.error('Error deleting presentation:', err);
alert('Failed to delete presentation. Please try again.');
} finally {
setDeleting(null);

View File

@ -72,12 +72,6 @@
flex-shrink: 0;
}
.preview-button {
display: flex;
align-items: center;
gap: 0.5rem;
}
.action-button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;

View File

@ -1,13 +1,10 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
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 type { Presentation, SlideContent } from '../../types/presentation';
import type { Theme, SlideLayout } from '../../types/theme';
import { getPresentationById, updatePresentation } from '../../utils/presentationStorage';
import { getTheme } from '../../themes';
import { renderTemplateWithSampleData } from '../../utils/templateRenderer';
import './SlideEditor.css';
export const SlideEditor: React.FC = () => {
@ -22,7 +19,6 @@ 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';
@ -95,7 +91,7 @@ export const SlideEditor: React.FC = () => {
useEffect(() => {
if (theme) {
const themeStyleId = 'slide-editor-theme-style';
const existingStyle = document.getElementById(themeStyleId);
let existingStyle = document.getElementById(themeStyleId);
if (existingStyle) {
existingStyle.remove();
@ -178,7 +174,7 @@ export const SlideEditor: React.FC = () => {
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save slide');
loggers.presentation.error('Failed to save slide', err instanceof Error ? err : new Error(String(err)));
console.error('Error saving slide:', err);
} finally {
setSaving(false);
}
@ -246,17 +242,6 @@ 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"
@ -287,7 +272,7 @@ export const SlideEditor: React.FC = () => {
<div
className="layout-rendered"
dangerouslySetInnerHTML={{
__html: sanitizeSlideTemplate(renderTemplateWithSampleData(layout.htmlTemplate, layout))
__html: renderTemplateWithSampleData(layout.htmlTemplate, layout)
}}
/>
</div>
@ -403,7 +388,7 @@ export const SlideEditor: React.FC = () => {
<div
className={`slide-container ${presentation.metadata.aspectRatio ? `aspect-${presentation.metadata.aspectRatio.replace(':', '-')}` : 'aspect-16-9'}`}
dangerouslySetInnerHTML={{
__html: sanitizeSlideTemplate(renderTemplateWithContent(selectedLayout, slideContent))
__html: renderTemplateWithContent(selectedLayout, slideContent)
}}
/>
</div>
@ -428,18 +413,6 @@ 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>
);
};

View File

@ -1,122 +0,0 @@
.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);
}

View File

@ -1,118 +0,0 @@
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>
);
};

View File

@ -1,53 +0,0 @@
.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;
}

View File

@ -1,61 +0,0 @@
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>
);
};

View File

@ -1,26 +0,0 @@
.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;
}

View File

@ -1,37 +0,0 @@
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>
);
};

View File

@ -1,6 +1,6 @@
import React from 'react';
import { Link } from 'react-router-dom';
import type { Theme } from '../../types/theme.ts';
import type { Theme } from '../../types/theme';
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>

View File

@ -1,6 +1,6 @@
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';
export { NewPresentationPage } from './NewPresentationPage';
export { PresentationViewer } from './PresentationViewer';
export { PresentationEditor } from './PresentationEditor';
export { SlideEditor } from './SlideEditor';
export { ThemeSelector } from './ThemeSelector';
export { PresentationsList } from './PresentationsList';

View File

@ -1,67 +0,0 @@
.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;
}

View File

@ -1,46 +0,0 @@
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>
);
};

View File

@ -1,31 +0,0 @@
.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); }
}

View File

@ -1,18 +0,0 @@
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>
);
};

View File

@ -1,106 +0,0 @@
.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;
}

View File

@ -1,83 +0,0 @@
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>
);
};

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import type { Theme, SlideLayout } from '../../types/theme.ts';
import { getTheme } from '../../themes/index.ts';
import type { Theme, SlideLayout } from '../../types/theme';
import { getTheme } from '../../themes';
import './LayoutDetailPage.css';
export const LayoutDetailPage: React.FC = () => {

View File

@ -1,6 +1,6 @@
import React, { useRef, useEffect } from 'react';
import type { SlideLayout, Theme } from '../../types/theme.ts';
import { renderTemplateWithSampleData, createPreviewDocument } from '../../utils/templateRenderer.ts';
import type { SlideLayout, Theme } from '../../types/theme';
import { renderTemplateWithSampleData, createPreviewDocument } from '../../utils/templateRenderer';
interface LayoutPreviewProps {
layout: SlideLayout;

View File

@ -1,9 +1,8 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
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 type { Theme, SlideLayout } from '../../types/theme';
import { getTheme } from '../../themes';
import { renderTemplateWithSampleData } from '../../utils/templateRenderer';
import './LayoutPreviewPage.css';
export const LayoutPreviewPage: React.FC = () => {
@ -56,7 +55,7 @@ export const LayoutPreviewPage: React.FC = () => {
if (theme) {
// Dynamically load theme CSS
const themeStyleId = 'theme-preview-style';
const existingStyle = document.getElementById(themeStyleId);
let existingStyle = document.getElementById(themeStyleId);
if (existingStyle) {
existingStyle.remove();
@ -153,7 +152,7 @@ export const LayoutPreviewPage: React.FC = () => {
</div>
<div
className="layout-rendered-content"
dangerouslySetInnerHTML={{ __html: sanitizeSlideTemplate(renderedContent) }}
dangerouslySetInnerHTML={{ __html: renderedContent }}
/>
<Link
to={`/themes/${theme.id}/layouts/${layout.id}`}

View File

@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import type { Theme } from '../../types/theme.ts';
import { getThemes } from '../../themes/index.ts';
import { LayoutPreview } from './LayoutPreview.tsx';
import type { Theme } from '../../types/theme';
import { getThemes } from '../../themes';
import { LayoutPreview } from './LayoutPreview';
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

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import type { Theme } from '../../types/theme.ts';
import { getTheme } from '../../themes/index.ts';
import type { Theme } from '../../types/theme';
import { getTheme } from '../../themes';
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

View File

@ -1,6 +1,6 @@
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';
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';

View File

@ -1,122 +0,0 @@
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>
);
};

View File

@ -1,179 +0,0 @@
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>
);
};

View File

@ -1,123 +0,0 @@
/* 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;
}
}

View File

@ -1,80 +0,0 @@
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>
);
};

View File

@ -1,3 +0,0 @@
export { Modal } from './Modal.tsx';
export { AlertDialog } from './AlertDialog.tsx';
export { ConfirmDialog } from './ConfirmDialog.tsx';

View File

@ -1,117 +0,0 @@
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])
};
}

View File

@ -1,144 +0,0 @@
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
};
};

View File

@ -1,8 +1,8 @@
// Re-export from the theme loader utility
export { discoverThemes as getThemes, loadTheme } from '../utils/themeLoader.ts';
export { discoverThemes as getThemes, loadTheme } from '../utils/themeLoader';
// Import for internal use
import { loadTheme } from '../utils/themeLoader.ts';
import { loadTheme } from '../utils/themeLoader';
// Helper function to get a single theme by ID
export const getTheme = async (themeId: string) => {

View File

@ -58,9 +58,6 @@ export interface SlideContent {
order: number;
}
// Alias for backward compatibility and cleaner import names
export type Slide = SlideContent;
export interface Presentation {
metadata: PresentationMetadata;
slides: SlideContent[];

View File

@ -1,5 +1,3 @@
import { loggers } from './logger.ts';
export interface CSSVariables {
[key: string]: string;
}
@ -49,7 +47,7 @@ export const loadCSSVariables = async (cssFilePath: string): Promise<CSSVariable
const cssContent = await response.text();
return parseCSSVariables(cssContent);
} catch (error) {
loggers.theme.warn(`Could not load CSS variables from ${cssFilePath}`, error instanceof Error ? error : new Error(String(error)));
console.warn(`Could not load CSS variables from ${cssFilePath}:`, error);
return {};
}
};
@ -161,7 +159,7 @@ export const loadThemeFromCSS = async (cssFilePath: string): Promise<{
variables: parseCSSVariables(cssContent)
};
} catch (error) {
loggers.theme.warn(`Could not load theme from ${cssFilePath}`, error instanceof Error ? error : new Error(String(error)));
console.warn(`Could not load theme from ${cssFilePath}:`, error);
return {
metadata: null,
variables: {}

View File

@ -1,172 +0,0 @@
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: []
});
}

View File

@ -1,204 +0,0 @@
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);
}
}

View File

@ -1,4 +1,4 @@
import type { Presentation, PresentationMetadata, CreatePresentationRequest } from '../types/presentation.ts';
import type { Presentation, PresentationMetadata, CreatePresentationRequest } from '../types/presentation';
const DB_NAME = 'SlideshareDB';
const DB_VERSION = 1;

View File

@ -1,4 +1,4 @@
import type { SlideLayout, SlotConfig } from '../types/theme.ts';
import type { SlideLayout, SlotConfig } from '../types/theme';
/**
* 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, defaultContent } = slot;
const { id, type, placeholder, defaultContent } = slot;
// Use default content if available
if (defaultContent && defaultContent.trim()) {
@ -81,25 +81,21 @@ 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);
@ -107,10 +103,9 @@ 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}`;

View File

@ -1,6 +1,5 @@
import type { Theme, SlideLayout, SlotConfig } from '../types/theme.ts';
import { parseThemeMetadata, parseCSSVariables } from './cssParser.ts';
import { loggers } from './logger.ts';
import type { Theme, SlideLayout, SlotConfig } from '../types/theme';
import { parseThemeMetadata, parseCSSVariables } from './cssParser';
// Theme cache management
let themeCache: Theme[] | null = null;
@ -37,11 +36,11 @@ export const discoverThemes = async (bustCache = false): Promise<Theme[]> => {
themes.push(theme);
}
} catch (error) {
loggers.theme.warn(`Failed to load theme ${themeId}`, error instanceof Error ? error : new Error(String(error)));
console.warn(`Failed to load theme ${themeId}:`, error);
}
}
} catch (error) {
loggers.theme.warn('Failed to load themes manifest', error instanceof Error ? error : new Error(String(error)));
console.warn('Failed to load themes manifest:', error);
// Fallback to default theme if manifest fails
try {
const defaultTheme = await loadTheme('default', bustCache);
@ -105,7 +104,7 @@ export const loadTheme = async (themeId: string, bustCache = false): Promise<The
masterSlideTemplate
};
} catch (error) {
loggers.theme.warn(`Could not load theme ${themeId}`, error instanceof Error ? error : new Error(String(error)));
console.warn(`Could not load theme ${themeId}:`, error);
return null;
}
};
@ -161,7 +160,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) {
loggers.theme.warn('Error discovering layouts', error instanceof Error ? error : new Error(String(error)));
console.warn('Error discovering layouts:', error);
}
return layouts;