Phase 6 - Template System: - Add TemplateLayer component for background/overlay rendering - Add SlotPlaceholder component with visual indicators for empty slots - Add useTemplate hook with auto-crop and drag constraint functions - Update templates.js with slot definitions for team-sport template - Integrate template system into DesignCanvas and App - Add slot upload UI in TemplatesTab sidebar Phase 9 - PWA Improvements: - Add Workbox caching rules for HuggingFace LFS, templates, and API - Change registerType to 'prompt' for update notifications - Add service worker update handler in main.jsx - Add refresh prompt UI in PWAInstall component Phase 10 - Responsive and Accessibility: - Add responsive CSS media queries for tablet/mobile layouts - Add OfflineIndicator component with online/offline detection - Add focus trap and keyboard navigation to PhotoPreEditor - Add aria labels and screen reader support to modal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
315 lines
9.7 KiB
JavaScript
315 lines
9.7 KiB
JavaScript
import { memo } from 'react';
|
||
|
||
export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate, onDelete, onEditPhoto }) {
|
||
if (!element) {
|
||
return (
|
||
<div className="properties-panel">
|
||
<div style={{
|
||
padding: '1rem',
|
||
borderBottom: `1px solid var(--border)`,
|
||
}}>
|
||
<h3 style={{
|
||
margin: 0,
|
||
fontSize: '14px',
|
||
fontWeight: '600',
|
||
color: 'var(--text-primary)',
|
||
}}>
|
||
Properties
|
||
</h3>
|
||
</div>
|
||
|
||
<div style={{
|
||
flex: 1,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
padding: '1rem',
|
||
color: 'var(--text-muted)',
|
||
fontSize: '12px',
|
||
textAlign: 'center',
|
||
}}>
|
||
Select an element to edit its properties
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const handlePositionChange = (axis, value) => {
|
||
onUpdate({ [axis]: parseFloat(value) || 0 });
|
||
};
|
||
|
||
const handleSizeChange = (axis, value) => {
|
||
const numValue = parseFloat(value) || 20;
|
||
onUpdate({ [axis]: Math.max(20, numValue) });
|
||
};
|
||
|
||
const handleRotationChange = (value) => {
|
||
const numValue = parseFloat(value) || 0;
|
||
onUpdate({ rotation: Math.max(-180, Math.min(180, numValue)) });
|
||
};
|
||
|
||
return (
|
||
<div className="properties-panel">
|
||
<div style={{
|
||
padding: '1rem',
|
||
borderBottom: `1px solid var(--border)`,
|
||
}}>
|
||
<h3 style={{
|
||
margin: 0,
|
||
fontSize: '14px',
|
||
fontWeight: '600',
|
||
color: 'var(--text-primary)',
|
||
}}>
|
||
Properties
|
||
</h3>
|
||
</div>
|
||
|
||
<div style={{
|
||
flex: 1,
|
||
overflow: 'auto',
|
||
padding: '1rem',
|
||
}}>
|
||
{/* Element type badge */}
|
||
<div style={{
|
||
display: 'inline-block',
|
||
padding: '4px 8px',
|
||
background: 'var(--accent-bg)',
|
||
borderRadius: 'var(--radius-sm)',
|
||
fontSize: '11px',
|
||
fontWeight: '600',
|
||
color: 'var(--accent)',
|
||
textTransform: 'uppercase',
|
||
marginBottom: '1rem',
|
||
}}>
|
||
{element.type}
|
||
</div>
|
||
|
||
{/* Position */}
|
||
<div style={{ marginBottom: '1rem' }}>
|
||
<label style={{
|
||
display: 'block',
|
||
fontSize: '11px',
|
||
fontWeight: '600',
|
||
color: 'var(--text-secondary)',
|
||
marginBottom: '0.5rem',
|
||
textTransform: 'uppercase',
|
||
}}>
|
||
Position
|
||
</label>
|
||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>X</label>
|
||
<input
|
||
type="number"
|
||
value={Math.round(element.x)}
|
||
onChange={(e) => handlePositionChange('x', e.target.value)}
|
||
style={{
|
||
width: '100%',
|
||
padding: '0.5rem',
|
||
border: `1px solid var(--border)`,
|
||
borderRadius: 'var(--radius-sm)',
|
||
fontSize: '13px',
|
||
}}
|
||
/>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Y</label>
|
||
<input
|
||
type="number"
|
||
value={Math.round(element.y)}
|
||
onChange={(e) => handlePositionChange('y', e.target.value)}
|
||
style={{
|
||
width: '100%',
|
||
padding: '0.5rem',
|
||
border: `1px solid var(--border)`,
|
||
borderRadius: 'var(--radius-sm)',
|
||
fontSize: '13px',
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Size (for images and stickers) */}
|
||
{(element.type === 'image' || element.type === 'sticker') && (
|
||
<div style={{ marginBottom: '1rem' }}>
|
||
<label style={{
|
||
display: 'block',
|
||
fontSize: '11px',
|
||
fontWeight: '600',
|
||
color: 'var(--text-secondary)',
|
||
marginBottom: '0.5rem',
|
||
textTransform: 'uppercase',
|
||
}}>
|
||
Size
|
||
</label>
|
||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>W</label>
|
||
<input
|
||
type="number"
|
||
value={Math.round(element.width)}
|
||
onChange={(e) => handleSizeChange('width', e.target.value)}
|
||
style={{
|
||
width: '100%',
|
||
padding: '0.5rem',
|
||
border: `1px solid var(--border)`,
|
||
borderRadius: 'var(--radius-sm)',
|
||
fontSize: '13px',
|
||
}}
|
||
/>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>H</label>
|
||
<input
|
||
type="number"
|
||
value={Math.round(element.height)}
|
||
onChange={(e) => handleSizeChange('height', e.target.value)}
|
||
style={{
|
||
width: '100%',
|
||
padding: '0.5rem',
|
||
border: `1px solid var(--border)`,
|
||
borderRadius: 'var(--radius-sm)',
|
||
fontSize: '13px',
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Edit Photo button (for images only) */}
|
||
{element.type === 'image' && onEditPhoto && (
|
||
<div style={{ marginBottom: '1rem' }}>
|
||
<button
|
||
onClick={() => onEditPhoto(element)}
|
||
style={{
|
||
width: '100%',
|
||
padding: '0.75rem',
|
||
border: `1px solid var(--accent)`,
|
||
borderRadius: 'var(--radius-md)',
|
||
background: 'var(--accent-bg)',
|
||
color: 'var(--accent)',
|
||
fontSize: '13px',
|
||
fontWeight: '600',
|
||
cursor: 'pointer',
|
||
transition: 'all 0.15s ease',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.target.style.background = 'var(--accent)';
|
||
e.target.style.color = '#fff';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.target.style.background = 'var(--accent-bg)';
|
||
e.target.style.color = 'var(--accent)';
|
||
}}
|
||
>
|
||
✏️ Edit Photo
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Font size (for text) */}
|
||
{element.type === 'text' && (
|
||
<>
|
||
<div style={{ marginBottom: '1rem' }}>
|
||
<label style={{
|
||
display: 'block',
|
||
fontSize: '11px',
|
||
fontWeight: '600',
|
||
color: 'var(--text-secondary)',
|
||
marginBottom: '0.5rem',
|
||
textTransform: 'uppercase',
|
||
}}>
|
||
Font Size: {Math.round(element.fontSize)}px
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="12"
|
||
max="120"
|
||
value={element.fontSize}
|
||
onChange={(e) => onUpdate({ fontSize: parseInt(e.target.value, 10) })}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: '1rem' }}>
|
||
<label style={{
|
||
display: 'block',
|
||
fontSize: '11px',
|
||
fontWeight: '600',
|
||
color: 'var(--text-secondary)',
|
||
marginBottom: '0.5rem',
|
||
textTransform: 'uppercase',
|
||
}}>
|
||
Color
|
||
</label>
|
||
<input
|
||
type="color"
|
||
value={element.fill}
|
||
onChange={(e) => onUpdate({ fill: e.target.value })}
|
||
style={{
|
||
width: '100%',
|
||
height: '36px',
|
||
border: `1px solid var(--border)`,
|
||
borderRadius: 'var(--radius-sm)',
|
||
cursor: 'pointer',
|
||
padding: '2px',
|
||
}}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Rotation */}
|
||
<div style={{ marginBottom: '1rem' }}>
|
||
<label style={{
|
||
display: 'block',
|
||
fontSize: '11px',
|
||
fontWeight: '600',
|
||
color: 'var(--text-secondary)',
|
||
marginBottom: '0.5rem',
|
||
textTransform: 'uppercase',
|
||
}}>
|
||
Rotation: {Math.round(element.rotation)}°
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="-180"
|
||
max="180"
|
||
value={element.rotation}
|
||
onChange={(e) => handleRotationChange(e.target.value)}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
|
||
{/* Delete button */}
|
||
<button
|
||
onClick={() => onDelete(element.id)}
|
||
style={{
|
||
width: '100%',
|
||
padding: '0.75rem',
|
||
border: 'none',
|
||
borderRadius: 'var(--radius-md)',
|
||
background: 'var(--error)',
|
||
color: '#fff',
|
||
fontSize: '13px',
|
||
fontWeight: '600',
|
||
cursor: 'pointer',
|
||
transition: 'all 0.15s ease',
|
||
marginTop: '1rem',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.target.style.background = '#dc2626';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.target.style.background = 'var(--error)';
|
||
}}
|
||
>
|
||
Delete Element
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|