Add image upload functionality for slide editing

- Create ImageUploadField component with file upload and URL input
- Support base64 encoding for local IndexedDB storage
- Add image preview, replacement, and removal functionality
- Update SlideEditor to use ImageUploadField for image slots
- Include file validation (5MB limit, image types only)
- Add responsive design and error handling
- Integrate with existing theme system and slot detection

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-08-21 09:45:24 -05:00
parent 7c7c8235f3
commit c51931af9c
8 changed files with 463 additions and 9 deletions

View File

@ -45,6 +45,7 @@
- [x] User can edit presentation notes
- [x] Changes auto-save to presentation
- [ ] User can edit slide content without preview if desired by clicking inside content slot areas
- [ ] When template has an image slot, slide editor allows user to upload an image (that will be stored based64 encoded in indexdb)
### Remove Slide
- [x] User can delete slides from presentation
@ -60,8 +61,8 @@
- [ ] Slide order automatically saves when changed
## Flow #3 - Present to oudience
- [ ] User can start presentation mode from presentation editor
- [ ] User can navigate slides in presentation mode
- [ ] User can exit presentation mode
- [x] User can start presentation mode from presentation editor
- [x] User can navigate slides in presentation mode
- [x] User can exit presentation mode
- [ ] User can see slide notes in presenter view
- [ ] User can control slide transitions and animations

View File

@ -12,5 +12,5 @@
"hasMasterSlide": true
}
},
"generated": "2025-08-21T14:20:36.144Z"
"generated": "2025-08-21T14:43:50.916Z"
}

View File

@ -4,7 +4,7 @@
</h1>
<div class="slot image-container" data-slot="image" data-placeholder="Click to add image" data-accept="image/*">
{{#image}}
<img src="{{image}}" alt="{{imageAlt}}" />
<img id="main-image" src="{{image}}" alt="{{imageAlt}}" />
{{/image}}
</div>
<input type="text" class="slot image-alt" data-slot="image-alt" data-placeholder="Image description" data-hidden="true">

View File

@ -69,7 +69,9 @@
/* Ensure slot content inherits proper centering */
text-align: inherit;
}
#main-image {
scale: .7;
}
.slot:hover,
.slot.editing {
border-color: var(--theme-accent);

View File

@ -726,4 +726,15 @@
.preview-container {
min-height: 200px;
}
}
/* Image slot field integration */
.content-field .image-slot-field {
margin-top: 0.5rem;
border: 1px solid #e2e8f0;
background: white;
}
.content-field .image-slot-field:focus-within {
border-color: #3b82f6;
}

View File

@ -8,6 +8,7 @@ import { renderTemplateWithSampleData } from '../../utils/templateRenderer.ts';
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts';
import { loggers } from '../../utils/logger.ts';
import { SlidePreviewModal } from './SlidePreviewModal.tsx';
import { ImageUploadField } from '../ui/ImageUploadField.tsx';
import './SlideEditor.css';
export const SlideEditor: React.FC = () => {
@ -328,7 +329,15 @@ export const SlideEditor: React.FC = () => {
{slot.required && <span className="required">*</span>}
</label>
{slot.type === 'text' && slot.id.includes('content') ? (
{slot.type === 'image' ? (
<ImageUploadField
id={slot.id}
value={slideContent[slot.id] || ''}
onChange={(value) => updateSlotContent(slot.id, value)}
placeholder={slot.placeholder || `Upload image or enter URL for ${slot.id}`}
className="image-slot-field"
/>
) : slot.type === 'text' && slot.id.includes('content') ? (
<textarea
id={slot.id}
value={slideContent[slot.id] || ''}
@ -340,7 +349,7 @@ export const SlideEditor: React.FC = () => {
) : (
<input
id={slot.id}
type={slot.type === 'image' ? 'url' : 'text'}
type="text"
value={slideContent[slot.id] || ''}
onChange={(e) => updateSlotContent(slot.id, e.target.value)}
placeholder={slot.placeholder || `Enter ${slot.id}`}
@ -348,7 +357,7 @@ export const SlideEditor: React.FC = () => {
/>
)}
{slot.placeholder && (
{slot.placeholder && slot.type !== 'image' && (
<p className="field-hint">{slot.placeholder}</p>
)}
</div>

View File

@ -0,0 +1,258 @@
/* Image Upload Field Styles */
.image-upload-field {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border: 2px dashed #d1d5db;
border-radius: 8px;
background: #f9fafb;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.image-upload-field:hover {
border-color: #9ca3af;
background: #f3f4f6;
}
.image-upload-field:focus-within {
border-color: #3b82f6;
background: #eff6ff;
}
/* Image Preview */
.image-preview {
position: relative;
max-width: 100%;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.image-preview img {
width: 100%;
height: auto;
max-height: 300px;
object-fit: contain;
display: block;
background: #fff;
}
.remove-image-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 2rem;
height: 2rem;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
color: white;
font-size: 1.2rem;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease;
}
.remove-image-button:hover {
background: rgba(0, 0, 0, 0.9);
}
/* Upload Area */
.upload-area {
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
padding: 1rem 0;
}
.upload-controls {
display: flex;
align-items: center;
gap: 0.75rem;
}
.upload-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: 1px solid transparent;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.upload-button.primary {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.upload-button.primary:hover:not(:disabled) {
background: #2563eb;
border-color: #2563eb;
}
.upload-button.secondary {
background: #f3f4f6;
color: #374151;
border-color: #d1d5db;
}
.upload-button.secondary:hover:not(:disabled) {
background: #e5e7eb;
border-color: #9ca3af;
}
.upload-button.small {
padding: 0.5rem 1rem;
font-size: 0.8rem;
}
.upload-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.upload-divider {
color: #6b7280;
font-size: 0.9rem;
font-weight: 500;
}
.url-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
transition: border-color 0.2s ease;
background: white;
}
.url-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6;
}
.url-input.small {
padding: 0.5rem;
font-size: 0.8rem;
}
.url-input::placeholder {
color: #9ca3af;
}
/* Image Controls (when image is loaded) */
.image-controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid #e5e7eb;
}
/* Error Message */
.upload-error {
color: #ef4444;
font-size: 0.8rem;
font-weight: 500;
padding: 0.5rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 4px;
}
/* Help Text */
.upload-help {
color: #6b7280;
font-size: 0.75rem;
text-align: center;
margin-top: 0.25rem;
}
/* Responsive Design */
@media (max-width: 768px) {
.image-upload-field {
padding: 0.75rem;
}
.upload-controls {
flex-direction: column;
gap: 0.5rem;
}
.upload-divider {
order: -1;
}
.upload-button {
width: 100%;
justify-content: center;
}
.image-controls {
align-items: stretch;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.image-upload-field {
border-color: #4b5563;
background: #111827;
color: #f9fafb;
}
.image-upload-field:hover {
border-color: #6b7280;
background: #1f2937;
}
.image-upload-field:focus-within {
border-color: #60a5fa;
background: #1e3a8a;
}
.url-input {
background: #1f2937;
border-color: #4b5563;
color: #f9fafb;
}
.url-input:focus {
border-color: #60a5fa;
box-shadow: 0 0 0 1px #60a5fa;
}
.upload-button.secondary {
background: #374151;
color: #f9fafb;
border-color: #4b5563;
}
.upload-button.secondary:hover:not(:disabled) {
background: #4b5563;
border-color: #6b7280;
}
.image-controls {
border-top-color: #4b5563;
}
.upload-error {
background: #7f1d1d;
border-color: #991b1b;
color: #fecaca;
}
}

View File

@ -0,0 +1,173 @@
import React, { useState, useRef } from 'react';
import './ImageUploadField.css';
interface ImageUploadFieldProps {
id: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export const ImageUploadField: React.FC<ImageUploadFieldProps> = ({
id,
value,
onChange,
placeholder = "Click to upload image or enter URL",
className = ""
}) => {
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
setError('Please select a valid image file');
return;
}
// Validate file size (max 5MB)
const maxSizeInBytes = 5 * 1024 * 1024; // 5MB
if (file.size > maxSizeInBytes) {
setError('Image file must be smaller than 5MB');
return;
}
try {
setIsUploading(true);
setError(null);
// Convert file to base64
const reader = new FileReader();
reader.onload = (e) => {
const base64 = e.target?.result as string;
onChange(base64);
setIsUploading(false);
};
reader.onerror = () => {
setError('Failed to read image file');
setIsUploading(false);
};
reader.readAsDataURL(file);
} catch (err) {
setError('Failed to upload image');
setIsUploading(false);
}
};
const handleUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
setError(null);
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleRemoveImage = () => {
onChange('');
setError(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const isImageUrl = value && (value.startsWith('data:image/') || value.startsWith('http'));
return (
<div className={`image-upload-field ${className}`}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
{/* Image preview */}
{isImageUrl && (
<div className="image-preview">
<img
src={value}
alt="Preview"
onError={() => setError('Invalid image URL')}
/>
<button
type="button"
className="remove-image-button"
onClick={handleRemoveImage}
title="Remove image"
>
×
</button>
</div>
)}
{/* Upload/URL input area */}
{!isImageUrl && (
<div className="upload-area">
<div className="upload-controls">
<button
type="button"
className="upload-button primary"
onClick={handleUploadClick}
disabled={isUploading}
>
{isUploading ? 'Uploading...' : '📁 Upload Image'}
</button>
<span className="upload-divider">or</span>
</div>
<input
id={id}
type="url"
value={value && !value.startsWith('data:image/') ? value : ''}
onChange={handleUrlChange}
placeholder={placeholder}
className="url-input"
/>
</div>
)}
{/* Replace image controls when image is loaded */}
{isImageUrl && (
<div className="image-controls">
<button
type="button"
className="upload-button secondary small"
onClick={handleUploadClick}
disabled={isUploading}
>
{isUploading ? 'Uploading...' : '📁 Replace'}
</button>
<input
type="url"
value={value && !value.startsWith('data:image/') ? value : ''}
onChange={handleUrlChange}
placeholder="Or enter image URL"
className="url-input small"
/>
</div>
)}
{/* Error message */}
{error && (
<div className="upload-error">
{error}
</div>
)}
{/* Help text */}
<div className="upload-help">
Supported formats: JPG, PNG, GIF, WebP (max 5MB)
</div>
</div>
);
};