- Fix unused variable errors by removing unused parameters or using proper destructuring - Fix 'prefer-const' violations by replacing 'let' with 'const' where appropriate - Fix lexical declaration errors in switch cases by adding proper block scoping - Replace explicit 'any' type with proper TypeScript interface for DOMPurify config - Fix React hooks dependency warnings in useDialog hook - Remove unused imports and variables throughout codebase Specific fixes: - Replace '_' parameters with proper destructuring syntax ([, value]) - Add block scopes to switch case statements in templateRenderer.ts - Improve type safety in htmlSanitizer.ts with explicit DOMPurify interface - Fix useCallback dependencies in useDialog hook - Remove unused 'placeholder' parameter in generateSampleDataForSlot All 15 ESLint errors have been resolved, improving code maintainability and consistency. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
435 lines
15 KiB
TypeScript
435 lines
15 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
|
import type { Presentation, SlideContent } from '../../types/presentation';
|
|
import type { Theme, SlideLayout } from '../../types/theme';
|
|
import { getPresentationById, updatePresentation } from '../../utils/presentationStorage';
|
|
import { getTheme } from '../../themes';
|
|
import { renderTemplateWithSampleData } from '../../utils/templateRenderer';
|
|
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer';
|
|
import './SlideEditor.css';
|
|
|
|
export const SlideEditor: React.FC = () => {
|
|
const { presentationId, slideId } = useParams<{
|
|
presentationId: string;
|
|
slideId: string;
|
|
}>();
|
|
const navigate = useNavigate();
|
|
|
|
const [presentation, setPresentation] = useState<Presentation | null>(null);
|
|
const [theme, setTheme] = useState<Theme | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const isEditingExisting = slideId !== 'new';
|
|
|
|
// Editor state
|
|
const [selectedLayout, setSelectedLayout] = useState<SlideLayout | null>(null);
|
|
const [slideContent, setSlideContent] = useState<Record<string, string>>({});
|
|
const [slideNotes, setSlideNotes] = useState('');
|
|
const [currentStep, setCurrentStep] = useState<'layout' | 'content'>(isEditingExisting ? 'content' : 'layout');
|
|
const existingSlide = isEditingExisting && presentation
|
|
? presentation.slides.find(s => s.id === slideId)
|
|
: null;
|
|
|
|
useEffect(() => {
|
|
const loadPresentationAndTheme = async () => {
|
|
if (!presentationId) {
|
|
setError('No presentation ID provided');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
|
|
// Load presentation
|
|
const presentationData = await getPresentationById(presentationId);
|
|
if (!presentationData) {
|
|
setError(`Presentation not found: ${presentationId}`);
|
|
return;
|
|
}
|
|
|
|
setPresentation(presentationData);
|
|
|
|
// Load theme
|
|
const themeData = await getTheme(presentationData.metadata.theme);
|
|
if (!themeData) {
|
|
setError(`Theme not found: ${presentationData.metadata.theme}`);
|
|
return;
|
|
}
|
|
|
|
setTheme(themeData);
|
|
|
|
// If editing existing slide, populate data
|
|
if (isEditingExisting && slideId !== 'new') {
|
|
const slide = presentationData.slides.find(s => s.id === slideId);
|
|
if (slide) {
|
|
const layout = themeData.layouts.find(l => l.id === slide.layoutId);
|
|
if (layout) {
|
|
setSelectedLayout(layout);
|
|
setSlideContent(slide.content);
|
|
setSlideNotes(slide.notes || '');
|
|
// No need to set currentStep here since it's already 'content' for existing slides
|
|
}
|
|
} else {
|
|
setError(`Slide not found: ${slideId}`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load presentation');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadPresentationAndTheme();
|
|
}, [presentationId, slideId, isEditingExisting]);
|
|
|
|
// Load theme CSS for layout previews
|
|
useEffect(() => {
|
|
if (theme) {
|
|
const themeStyleId = 'slide-editor-theme-style';
|
|
const existingStyle = document.getElementById(themeStyleId);
|
|
|
|
if (existingStyle) {
|
|
existingStyle.remove();
|
|
}
|
|
|
|
const link = document.createElement('link');
|
|
link.id = themeStyleId;
|
|
link.rel = 'stylesheet';
|
|
link.href = `${theme.basePath}/${theme.cssFile}`;
|
|
document.head.appendChild(link);
|
|
|
|
return () => {
|
|
const styleToRemove = document.getElementById(themeStyleId);
|
|
if (styleToRemove) {
|
|
styleToRemove.remove();
|
|
}
|
|
};
|
|
}
|
|
}, [theme]);
|
|
|
|
const selectLayout = (layout: SlideLayout) => {
|
|
setSelectedLayout(layout);
|
|
|
|
// Initialize content with empty values for all slots
|
|
const initialContent: Record<string, string> = {};
|
|
layout.slots.forEach(slot => {
|
|
initialContent[slot.id] = slideContent[slot.id] || '';
|
|
});
|
|
setSlideContent(initialContent);
|
|
|
|
// Automatically move to content editing after layout selection
|
|
setCurrentStep('content');
|
|
};
|
|
|
|
const updateSlotContent = (slotId: string, content: string) => {
|
|
setSlideContent(prev => ({
|
|
...prev,
|
|
[slotId]: content
|
|
}));
|
|
};
|
|
|
|
const saveSlide = async () => {
|
|
if (!presentation || !selectedLayout) return;
|
|
|
|
try {
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
const slideData: SlideContent = {
|
|
id: isEditingExisting ? slideId! : `slide_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
layoutId: selectedLayout.id,
|
|
content: slideContent,
|
|
notes: slideNotes,
|
|
order: isEditingExisting
|
|
? (existingSlide?.order ?? presentation.slides.length)
|
|
: presentation.slides.length
|
|
};
|
|
|
|
const updatedPresentation = { ...presentation };
|
|
|
|
if (isEditingExisting) {
|
|
// Update existing slide
|
|
const slideIndex = updatedPresentation.slides.findIndex(s => s.id === slideId);
|
|
if (slideIndex !== -1) {
|
|
updatedPresentation.slides[slideIndex] = slideData;
|
|
}
|
|
} else {
|
|
// Add new slide
|
|
updatedPresentation.slides.push(slideData);
|
|
}
|
|
|
|
await updatePresentation(updatedPresentation);
|
|
|
|
// Navigate back to editor with the updated slide
|
|
const slideNumber = isEditingExisting
|
|
? (updatedPresentation.slides.findIndex(s => s.id === slideData.id) + 1)
|
|
: updatedPresentation.slides.length;
|
|
|
|
navigate(`/presentations/${presentationId}/edit/slides/${slideNumber}`);
|
|
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to save slide');
|
|
console.error('Error saving slide:', err);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const cancelEditing = () => {
|
|
if (isEditingExisting) {
|
|
const slideIndex = presentation?.slides.findIndex(s => s.id === slideId) ?? 0;
|
|
const slideNumber = slideIndex + 1;
|
|
navigate(`/presentations/${presentationId}/edit/slides/${slideNumber}`);
|
|
} else {
|
|
navigate(`/presentations/${presentationId}/edit/slides/1`);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="slide-editor">
|
|
<div className="loading-content">
|
|
<div className="loading-spinner">Loading slide editor...</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="slide-editor">
|
|
<div className="error-content">
|
|
<h2>Error Loading Slide Editor</h2>
|
|
<p>{error}</p>
|
|
<Link to="/themes" className="back-link">← Back to Themes</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!presentation || !theme) {
|
|
return (
|
|
<div className="slide-editor">
|
|
<div className="not-found-content">
|
|
<h2>Presentation Not Found</h2>
|
|
<p>The requested presentation could not be found.</p>
|
|
<Link to="/themes" className="back-link">← Back to Themes</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="slide-editor">
|
|
<header className="slide-editor-header">
|
|
<div className="editor-info">
|
|
<button
|
|
onClick={cancelEditing}
|
|
className="back-button"
|
|
type="button"
|
|
>
|
|
← Back to Presentation
|
|
</button>
|
|
<div className="editor-title">
|
|
<h1>{isEditingExisting ? 'Edit Slide' : 'Add New Slide'}</h1>
|
|
<p>{presentation.metadata.name} • {theme.name} theme</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="editor-actions">
|
|
<button
|
|
type="button"
|
|
className="action-button primary"
|
|
onClick={saveSlide}
|
|
disabled={!selectedLayout || saving}
|
|
>
|
|
{saving ? 'Saving...' : (isEditingExisting ? 'Update Slide' : 'Add Slide')}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="slide-editor-content">
|
|
{currentStep === 'layout' && (
|
|
<div className="layout-selection">
|
|
<div className="step-header">
|
|
<h2>Choose a Layout</h2>
|
|
<p>Select the layout that best fits your content</p>
|
|
</div>
|
|
|
|
<div className="layouts-grid">
|
|
{theme.layouts.map((layout) => (
|
|
<div
|
|
key={layout.id}
|
|
className={`layout-card ${selectedLayout?.id === layout.id ? 'selected' : ''}`}
|
|
onClick={() => selectLayout(layout)}
|
|
>
|
|
<div className="layout-preview">
|
|
<div
|
|
className="layout-rendered"
|
|
dangerouslySetInnerHTML={{
|
|
__html: sanitizeSlideTemplate(renderTemplateWithSampleData(layout.htmlTemplate, layout))
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="layout-info">
|
|
<h3>{layout.name}</h3>
|
|
<p>{layout.description}</p>
|
|
<div className="layout-meta">
|
|
<span className="slot-count">{layout.slots.length} slots</span>
|
|
<div className="slot-types">
|
|
{Array.from(new Set(layout.slots.map(slot => slot.type))).map(type => (
|
|
<span key={type} className={`slot-type-badge ${type}`}>
|
|
{type}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{currentStep === 'content' && selectedLayout && presentation && (
|
|
<div className="content-editing">
|
|
<div className="editing-layout">
|
|
<div className="content-form">
|
|
<div className="step-header">
|
|
<h2>Edit Slide Content</h2>
|
|
<p>Fill in the content for your {selectedLayout.name} slide</p>
|
|
</div>
|
|
|
|
<div className="content-fields">
|
|
{selectedLayout.slots.map((slot) => (
|
|
<div key={slot.id} className="content-field">
|
|
<label htmlFor={slot.id} className="field-label">
|
|
{slot.id.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
|
{slot.required && <span className="required">*</span>}
|
|
</label>
|
|
|
|
{slot.type === 'text' && slot.id.includes('content') ? (
|
|
<textarea
|
|
id={slot.id}
|
|
value={slideContent[slot.id] || ''}
|
|
onChange={(e) => updateSlotContent(slot.id, e.target.value)}
|
|
placeholder={slot.placeholder || `Enter ${slot.id}`}
|
|
className="field-textarea"
|
|
rows={4}
|
|
/>
|
|
) : (
|
|
<input
|
|
id={slot.id}
|
|
type={slot.type === 'image' ? 'url' : 'text'}
|
|
value={slideContent[slot.id] || ''}
|
|
onChange={(e) => updateSlotContent(slot.id, e.target.value)}
|
|
placeholder={slot.placeholder || `Enter ${slot.id}`}
|
|
className="field-input"
|
|
/>
|
|
)}
|
|
|
|
{slot.placeholder && (
|
|
<p className="field-hint">{slot.placeholder}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
<div className="content-field">
|
|
<label htmlFor="slide-notes" className="field-label">
|
|
Speaker Notes
|
|
</label>
|
|
<textarea
|
|
id="slide-notes"
|
|
value={slideNotes}
|
|
onChange={(e) => setSlideNotes(e.target.value)}
|
|
placeholder="Add notes for this slide (optional)"
|
|
className="field-textarea"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
<div className="content-actions">
|
|
<div className="action-links">
|
|
<button
|
|
type="button"
|
|
className="cancel-link"
|
|
onClick={cancelEditing}
|
|
disabled={saving}
|
|
>
|
|
Cancel editing
|
|
</button>
|
|
</div>
|
|
<div className="action-buttons">
|
|
<button
|
|
type="button"
|
|
className="action-button primary"
|
|
onClick={saveSlide}
|
|
disabled={saving}
|
|
>
|
|
{saving ? 'Saving...' : (isEditingExisting ? 'Update Slide' : 'Save Slide')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="content-preview">
|
|
<h3>Live Preview</h3>
|
|
<p className="preview-description">
|
|
Updates automatically as you type
|
|
</p>
|
|
<div className="preview-container">
|
|
<div className="slide-preview-wrapper">
|
|
<div
|
|
className={`slide-container ${presentation.metadata.aspectRatio ? `aspect-${presentation.metadata.aspectRatio.replace(':', '-')}` : 'aspect-16-9'}`}
|
|
dangerouslySetInnerHTML={{
|
|
__html: sanitizeSlideTemplate(renderTemplateWithContent(selectedLayout, slideContent))
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="preview-meta">
|
|
<span className="layout-name">{selectedLayout.name}</span>
|
|
<span className="aspect-ratio-info">
|
|
{presentation.metadata.aspectRatio || '16:9'} aspect ratio
|
|
</span>
|
|
<span className="content-count">
|
|
{Object.values(slideContent).filter(v => v.trim()).length} / {selectedLayout.slots.length} slots filled
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="editor-error">
|
|
<p>Error: {error}</p>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Helper function to render template with actual content
|
|
const renderTemplateWithContent = (layout: SlideLayout, content: Record<string, string>): string => {
|
|
let rendered = layout.htmlTemplate;
|
|
|
|
// Replace content placeholders
|
|
Object.entries(content).forEach(([slotId, value]) => {
|
|
const placeholder = new RegExp(`\\{\\{${slotId}\\}\\}`, 'g');
|
|
rendered = rendered.replace(placeholder, value || '');
|
|
});
|
|
|
|
// Clean up any remaining placeholders
|
|
rendered = rendered.replace(/\{\{[^}]+\}\}/g, '');
|
|
|
|
return rendered;
|
|
}; |