Implement template system and PWA enhancements
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>
This commit is contained in:
@@ -4,12 +4,15 @@ import { Sidebar } from './components/sidebar/Sidebar';
|
|||||||
import { LayersPanel } from './components/panels/LayersPanel';
|
import { LayersPanel } from './components/panels/LayersPanel';
|
||||||
import { PropertiesPanel } from './components/panels/PropertiesPanel';
|
import { PropertiesPanel } from './components/panels/PropertiesPanel';
|
||||||
import { PWAInstall } from './components/PWAInstall';
|
import { PWAInstall } from './components/PWAInstall';
|
||||||
|
import { OfflineIndicator } from './components/OfflineIndicator';
|
||||||
|
import { PhotoPreEditor } from './components/editor/PhotoPreEditor';
|
||||||
import { useDesignEditor } from './hooks/useDesignEditor';
|
import { useDesignEditor } from './hooks/useDesignEditor';
|
||||||
import { useExport } from './hooks/useExport';
|
import { useExport } from './hooks/useExport';
|
||||||
|
import { useTemplate } from './hooks/useTemplate';
|
||||||
import { TEMPLATES } from './constants/templates';
|
import { TEMPLATES } from './constants/templates';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [currentTemplate, setCurrentTemplate] = useState(null);
|
const [editingElement, setEditingElement] = useState(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
elements,
|
elements,
|
||||||
@@ -29,6 +32,19 @@ function App() {
|
|||||||
|
|
||||||
const { exporting, progress, exportDesign, error, clearExport } = useExport();
|
const { exporting, progress, exportDesign, error, clearExport } = useExport();
|
||||||
|
|
||||||
|
// Template management
|
||||||
|
const {
|
||||||
|
currentTemplate,
|
||||||
|
currentTemplateId,
|
||||||
|
assignedSlots,
|
||||||
|
loadTemplate,
|
||||||
|
clearTemplate,
|
||||||
|
getSlots,
|
||||||
|
assignImageToSlot,
|
||||||
|
getDragBoundFunc,
|
||||||
|
isSlotFilled,
|
||||||
|
} = useTemplate(TEMPLATES);
|
||||||
|
|
||||||
const selectedElement = elements.find((el) => el.id === selectedId);
|
const selectedElement = elements.find((el) => el.id === selectedId);
|
||||||
|
|
||||||
// Initialize history on mount
|
// Initialize history on mount
|
||||||
@@ -83,24 +99,54 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddTemplate = (templateId) => {
|
const handleAddTemplate = (templateId) => {
|
||||||
// Find template by ID
|
if (templateId === 'freeform') {
|
||||||
|
clearTemplate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load template using useTemplate hook
|
||||||
|
const success = loadTemplate(templateId);
|
||||||
|
if (success) {
|
||||||
const template = TEMPLATES.find(t => t.id === templateId);
|
const template = TEMPLATES.find(t => t.id === templateId);
|
||||||
if (template) {
|
|
||||||
setCurrentTemplate(template);
|
|
||||||
// Clear existing elements first
|
// Clear existing elements first
|
||||||
// Apply template elements to canvas
|
// Apply template elements to canvas
|
||||||
if (template.elements) {
|
if (template?.elements) {
|
||||||
template.elements.forEach((el, index) => {
|
template.elements.forEach((el, index) => {
|
||||||
setTimeout(() => addElement({ ...el }), index * 50);
|
setTimeout(() => addElement({ ...el }), index * 50);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (templateId === 'freeform') {
|
|
||||||
setCurrentTemplate(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle image upload for slot-based templates
|
||||||
|
const handleSlotImageUpload = (slotId, imageData) => {
|
||||||
|
const elementData = assignImageToSlot(slotId, imageData);
|
||||||
|
if (elementData) {
|
||||||
|
addElement(elementData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle photo editing
|
||||||
|
const handleEditPhoto = (element) => {
|
||||||
|
setEditingElement(element);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhotoEditComplete = (editedImageUrl) => {
|
||||||
|
if (editingElement) {
|
||||||
|
updateElement(editingElement.id, { src: editedImageUrl });
|
||||||
|
}
|
||||||
|
setEditingElement(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhotoEditClose = () => {
|
||||||
|
setEditingElement(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-layout">
|
<div className="editor-layout">
|
||||||
|
{/* Offline Indicator */}
|
||||||
|
<OfflineIndicator />
|
||||||
|
|
||||||
{/* PWA Install Prompt */}
|
{/* PWA Install Prompt */}
|
||||||
<PWAInstall />
|
<PWAInstall />
|
||||||
|
|
||||||
@@ -110,6 +156,7 @@ function App() {
|
|||||||
onAddSticker={handleAddSticker}
|
onAddSticker={handleAddSticker}
|
||||||
onAddText={handleAddText}
|
onAddText={handleAddText}
|
||||||
onAddTemplate={handleAddTemplate}
|
onAddTemplate={handleAddTemplate}
|
||||||
|
onSlotImageUpload={handleSlotImageUpload}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Center Canvas Area */}
|
{/* Center Canvas Area */}
|
||||||
@@ -200,6 +247,9 @@ function App() {
|
|||||||
onDeselect={deselectAll}
|
onDeselect={deselectAll}
|
||||||
onUpdate={(id, attrs) => updateElement(id, attrs)}
|
onUpdate={(id, attrs) => updateElement(id, attrs)}
|
||||||
onCommit={commitHistory}
|
onCommit={commitHistory}
|
||||||
|
currentTemplate={currentTemplate}
|
||||||
|
assignedSlots={assignedSlots}
|
||||||
|
getDragBoundFunc={getDragBoundFunc}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Layers panel below canvas */}
|
{/* Layers panel below canvas */}
|
||||||
@@ -226,7 +276,17 @@ function App() {
|
|||||||
element={selectedElement}
|
element={selectedElement}
|
||||||
onUpdate={(attrs) => updateElement(selectedId, attrs)}
|
onUpdate={(attrs) => updateElement(selectedId, attrs)}
|
||||||
onDelete={deleteElement}
|
onDelete={deleteElement}
|
||||||
|
onEditPhoto={handleEditPhoto}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Photo Pre-Editor Modal */}
|
||||||
|
{editingElement && (
|
||||||
|
<PhotoPreEditor
|
||||||
|
imageSrc={editingElement.src}
|
||||||
|
onComplete={handlePhotoEditComplete}
|
||||||
|
onClose={handlePhotoEditClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
27
client/src/components/OfflineIndicator.jsx
Normal file
27
client/src/components/OfflineIndicator.jsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function OfflineIndicator() {
|
||||||
|
const [isOffline, setIsOffline] = useState(!navigator.onLine);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setIsOffline(false);
|
||||||
|
const handleOffline = () => setIsOffline(true);
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isOffline) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="offline-indicator">
|
||||||
|
<span>⚠️</span>
|
||||||
|
<span>You're offline - changes are saved locally</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { useState, useEffect } from 'react';
|
|||||||
export function PWAInstall() {
|
export function PWAInstall() {
|
||||||
const [deferredPrompt, setDeferredPrompt] = useState(null);
|
const [deferredPrompt, setDeferredPrompt] = useState(null);
|
||||||
const [showInstall, setShowInstall] = useState(false);
|
const [showInstall, setShowInstall] = useState(false);
|
||||||
|
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||||
|
const [newWorker, setNewWorker] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleBeforeInstallPrompt = (e) => {
|
const handleBeforeInstallPrompt = (e) => {
|
||||||
@@ -13,11 +15,20 @@ export function PWAInstall() {
|
|||||||
|
|
||||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||||
|
|
||||||
|
// Listen for service worker updates
|
||||||
|
window.addEventListener('swUpdated', handleSWUpdated);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||||
|
window.removeEventListener('swUpdated', handleSWUpdated);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleSWUpdated = (event) => {
|
||||||
|
setNewWorker(event.detail);
|
||||||
|
setUpdateAvailable(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleInstall = async () => {
|
const handleInstall = async () => {
|
||||||
if (!deferredPrompt) return;
|
if (!deferredPrompt) return;
|
||||||
|
|
||||||
@@ -30,9 +41,23 @@ export function PWAInstall() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!showInstall) return null;
|
const handleUpdate = () => {
|
||||||
|
if (newWorker) {
|
||||||
|
newWorker.postMessage({ type: 'SKIP_WAITING' });
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissUpdate = () => {
|
||||||
|
setUpdateAvailable(false);
|
||||||
|
setNewWorker(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!showInstall && !updateAvailable) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{showInstall && (
|
||||||
<div className="pwa-install-banner">
|
<div className="pwa-install-banner">
|
||||||
<p>Install Apparel Designer for offline access!</p>
|
<p>Install Apparel Designer for offline access!</p>
|
||||||
<div className="pwa-install-buttons">
|
<div className="pwa-install-buttons">
|
||||||
@@ -44,5 +69,57 @@ export function PWAInstall() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{updateAvailable && (
|
||||||
|
<div className="pwa-update-banner" style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '1rem',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
background: 'var(--accent)',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
zIndex: 9999,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
fontSize: '13px',
|
||||||
|
}}>
|
||||||
|
<span>🔄 New version available!</span>
|
||||||
|
<button
|
||||||
|
onClick={handleUpdate}
|
||||||
|
style={{
|
||||||
|
padding: '0.375rem 0.75rem',
|
||||||
|
background: '#fff',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={dismissUpdate}
|
||||||
|
style={{
|
||||||
|
padding: '0.375rem 0.5rem',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '16px',
|
||||||
|
opacity: 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Stage, Layer } from 'react-konva';
|
|||||||
import { TShirtSVG } from './TShirtSVG';
|
import { TShirtSVG } from './TShirtSVG';
|
||||||
import { ImageElement } from './ImageElement';
|
import { ImageElement } from './ImageElement';
|
||||||
import { TextElement } from './TextElement';
|
import { TextElement } from './TextElement';
|
||||||
|
import { TemplateLayer } from './TemplateLayer';
|
||||||
|
import { SlotPlaceholder, SlotBoundsGuide } from './SlotPlaceholder';
|
||||||
import { useRef, useEffect, memo } from 'react';
|
import { useRef, useEffect, memo } from 'react';
|
||||||
|
|
||||||
const CANVAS_SIZE = 300;
|
const CANVAS_SIZE = 300;
|
||||||
@@ -13,7 +15,13 @@ export const DesignCanvas = memo(function DesignCanvas({
|
|||||||
onDeselect,
|
onDeselect,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onCommit,
|
onCommit,
|
||||||
|
currentTemplate,
|
||||||
|
assignedSlots,
|
||||||
|
getDragBoundFunc,
|
||||||
}) {
|
}) {
|
||||||
|
// Get slots from current template
|
||||||
|
const slots = currentTemplate?.slots || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
{/* T-shirt SVG background */}
|
{/* T-shirt SVG background */}
|
||||||
@@ -35,6 +43,21 @@ export const DesignCanvas = memo(function DesignCanvas({
|
|||||||
background: 'rgba(255, 255, 255, 0.5)',
|
background: 'rgba(255, 255, 255, 0.5)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Template Layer - Background and Overlay */}
|
||||||
|
<Layer>
|
||||||
|
{currentTemplate && (
|
||||||
|
<TemplateLayer template={currentTemplate} canvasSize={CANVAS_SIZE} />
|
||||||
|
)}
|
||||||
|
</Layer>
|
||||||
|
|
||||||
|
{/* Slot Bounds Guides - visible even when slots have content */}
|
||||||
|
<Layer listening={false}>
|
||||||
|
{slots.map((slot) => (
|
||||||
|
<SlotBoundsGuide key={slot.id} slot={slot} />
|
||||||
|
))}
|
||||||
|
</Layer>
|
||||||
|
|
||||||
|
{/* User Elements Layer */}
|
||||||
<Layer>
|
<Layer>
|
||||||
{elements.map((el) => {
|
{elements.map((el) => {
|
||||||
if (el.type === 'image') {
|
if (el.type === 'image') {
|
||||||
@@ -48,10 +71,12 @@ export const DesignCanvas = memo(function DesignCanvas({
|
|||||||
height={el.height}
|
height={el.height}
|
||||||
rotation={el.rotation}
|
rotation={el.rotation}
|
||||||
src={el.src}
|
src={el.src}
|
||||||
|
crop={el.crop}
|
||||||
isSelected={el.id === selectedId}
|
isSelected={el.id === selectedId}
|
||||||
onSelect={() => onSelect(el.id)}
|
onSelect={() => onSelect(el.id)}
|
||||||
onUpdate={(attrs) => onUpdate(el.id, attrs)}
|
onUpdate={(attrs) => onUpdate(el.id, attrs)}
|
||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
|
dragBoundFunc={el.slotId ? getDragBoundFunc?.(el.slotId, { width: el.width, height: el.height }) : null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -77,6 +102,20 @@ export const DesignCanvas = memo(function DesignCanvas({
|
|||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
</Layer>
|
</Layer>
|
||||||
|
|
||||||
|
{/* Slot Placeholders - show when slots are empty */}
|
||||||
|
<Layer listening={false}>
|
||||||
|
{slots.map((slot) => {
|
||||||
|
const isFilled = !!assignedSlots?.[slot.id];
|
||||||
|
return (
|
||||||
|
<SlotPlaceholder
|
||||||
|
key={slot.id}
|
||||||
|
slot={slot}
|
||||||
|
isEmpty={!isFilled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Layer>
|
||||||
</Stage>
|
</Stage>
|
||||||
|
|
||||||
{/* Canvas info bar */}
|
{/* Canvas info bar */}
|
||||||
|
|||||||
155
client/src/components/canvas/SlotPlaceholder.jsx
Normal file
155
client/src/components/canvas/SlotPlaceholder.jsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { Group, Rect, Text, Line } from 'react-konva';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SlotPlaceholder - Visual indicator for empty template slots
|
||||||
|
* Shows a dashed border with label when slot is empty
|
||||||
|
*
|
||||||
|
* @param {Object} slot - Slot configuration
|
||||||
|
* @param {string} slot.id - Unique slot identifier
|
||||||
|
* @param {Object} slot.bounds - Slot bounds {x, y, width, height}
|
||||||
|
* @param {string} slot.label - Human-readable label
|
||||||
|
* @param {boolean} isEmpty - Whether slot has no image assigned
|
||||||
|
*/
|
||||||
|
export function SlotPlaceholder({ slot, isEmpty = true }) {
|
||||||
|
const { bounds, label } = slot;
|
||||||
|
const { x, y, width, height } = bounds;
|
||||||
|
|
||||||
|
if (!isEmpty) return null; // Don't show placeholder when slot has content
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group name={`slot-placeholder-${slot.id}`}>
|
||||||
|
{/* Dashed border rectangle */}
|
||||||
|
<Rect
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
stroke="#94a3b8"
|
||||||
|
strokeWidth={2}
|
||||||
|
dash={[8, 4]}
|
||||||
|
cornerRadius={4}
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Background fill (semi-transparent) */}
|
||||||
|
<Rect
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
fill="rgba(148, 163, 184, 0.1)"
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drop icon */}
|
||||||
|
<Text
|
||||||
|
text="📷"
|
||||||
|
x={x + width / 2}
|
||||||
|
y={y + height / 2 - 20}
|
||||||
|
fontSize={24}
|
||||||
|
align="center"
|
||||||
|
offsetX={12}
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Label text */}
|
||||||
|
<Text
|
||||||
|
text={label || 'Drop image here'}
|
||||||
|
x={x + width / 2}
|
||||||
|
y={y + height / 2 + 10}
|
||||||
|
fontSize={11}
|
||||||
|
fontFamily="DM Sans"
|
||||||
|
fill="#64748b"
|
||||||
|
align="center"
|
||||||
|
offsetX={width / 2}
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Corner markers */}
|
||||||
|
<Line
|
||||||
|
points={[x, y, x + 20, y]}
|
||||||
|
stroke="#38bdf8"
|
||||||
|
strokeWidth={3}
|
||||||
|
lineCap="round"
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
points={[x, y, x, y + 20]}
|
||||||
|
stroke="#38bdf8"
|
||||||
|
strokeWidth={3}
|
||||||
|
lineCap="round"
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
points={[x + width, y, x + width - 20, y]}
|
||||||
|
stroke="#38bdf8"
|
||||||
|
strokeWidth={3}
|
||||||
|
lineCap="round"
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
points={[x + width, y, x + width, y + 20]}
|
||||||
|
stroke="#38bdf8"
|
||||||
|
strokeWidth={3}
|
||||||
|
lineCap="round"
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
points={[x, y + height, x + 20, y + height]}
|
||||||
|
stroke="#38bdf8"
|
||||||
|
strokeWidth={3}
|
||||||
|
lineCap="round"
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
points={[x, y + height, x, y + height - 20]}
|
||||||
|
stroke="#38bdf8"
|
||||||
|
strokeWidth={3}
|
||||||
|
lineCap="round"
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
points={[x + width, y + height, x + width - 20, y + height]}
|
||||||
|
stroke="#38bdf8"
|
||||||
|
strokeWidth={3}
|
||||||
|
lineCap="round"
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
points={[x + width, y + height, x + width, y + height - 20]}
|
||||||
|
stroke="#38bdf8"
|
||||||
|
strokeWidth={3}
|
||||||
|
lineCap="round"
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SlotBoundsGuide - Shows the slot boundary during design
|
||||||
|
* Less prominent than placeholder, visible even when slot has content
|
||||||
|
*
|
||||||
|
* @param {Object} slot - Slot configuration
|
||||||
|
* @param {Object} slot.bounds - Slot bounds {x, y, width, height}
|
||||||
|
* @param {string} slot.id - Slot identifier
|
||||||
|
*/
|
||||||
|
export function SlotBoundsGuide({ slot }) {
|
||||||
|
const { bounds, id } = slot;
|
||||||
|
const { x, y, width, height } = bounds;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group name={`slot-bounds-${id}`} listening={false}>
|
||||||
|
<Rect
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
stroke="rgba(56, 189, 248, 0.3)"
|
||||||
|
strokeWidth={1}
|
||||||
|
dash={[4, 4]}
|
||||||
|
cornerRadius={2}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
client/src/components/canvas/TemplateLayer.jsx
Normal file
119
client/src/components/canvas/TemplateLayer.jsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { Group, Image as KonvaImage, Rect, Text as KonvaText } from 'react-konva';
|
||||||
|
import useImage from 'use-image';
|
||||||
|
|
||||||
|
// Helper component to load and render images
|
||||||
|
function TemplateImage({ src, x, y, width, height, opacity = 1, listening = false }) {
|
||||||
|
const [img] = useImage(src, 'anonymous');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KonvaImage
|
||||||
|
image={img}
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
opacity={opacity}
|
||||||
|
listening={listening}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper component for text elements
|
||||||
|
function TemplateText({ text, x, y, fontSize, fontFamily, fill, rotation = 0 }) {
|
||||||
|
return (
|
||||||
|
<KonvaText
|
||||||
|
text={text}
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
fontSize={fontSize}
|
||||||
|
fontFamily={fontFamily}
|
||||||
|
fill={fill}
|
||||||
|
rotation={rotation}
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TemplateLayer - Renders background and overlay layers for templates
|
||||||
|
* Background: Base image/color that appears behind user elements
|
||||||
|
* Overlay: Decorative elements that appear on top of user elements
|
||||||
|
*
|
||||||
|
* @param {Object} template - Template configuration object
|
||||||
|
* @param {Object} template.background - Background layer config
|
||||||
|
* @param {Array} template.overlay - Overlay elements array
|
||||||
|
* @param {number} canvasSize - Size of the canvas (default: 300)
|
||||||
|
*/
|
||||||
|
export function TemplateLayer({ template, canvasSize = 300 }) {
|
||||||
|
if (!template) return null;
|
||||||
|
|
||||||
|
const { background, overlay } = template;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group name="template-layer">
|
||||||
|
{/* Background Layer */}
|
||||||
|
{background && (
|
||||||
|
<Group name="template-background">
|
||||||
|
{background.type === 'color' ? (
|
||||||
|
<Rect
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
width={canvasSize}
|
||||||
|
height={canvasSize}
|
||||||
|
fill={background.color}
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
) : background.type === 'image' ? (
|
||||||
|
<TemplateImage
|
||||||
|
src={background.src}
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
width={canvasSize}
|
||||||
|
height={canvasSize}
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlay Layer */}
|
||||||
|
{overlay && overlay.map((el, index) => {
|
||||||
|
if (el.nonPrintable) return null; // Skip guides and watermarks
|
||||||
|
|
||||||
|
const key = `overlay-${index}`;
|
||||||
|
|
||||||
|
if (el.type === 'image') {
|
||||||
|
return (
|
||||||
|
<TemplateImage
|
||||||
|
key={key}
|
||||||
|
src={el.src}
|
||||||
|
x={el.x || 0}
|
||||||
|
y={el.y || 0}
|
||||||
|
width={el.width || 100}
|
||||||
|
height={el.height || 100}
|
||||||
|
opacity={el.opacity}
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.type === 'text') {
|
||||||
|
return (
|
||||||
|
<TemplateText
|
||||||
|
key={key}
|
||||||
|
text={el.text}
|
||||||
|
x={el.x || 0}
|
||||||
|
y={el.y || 0}
|
||||||
|
fontSize={el.fontSize}
|
||||||
|
fontFamily={el.fontFamily}
|
||||||
|
fill={el.fill}
|
||||||
|
rotation={el.rotation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,3 +2,5 @@ export { DesignCanvas } from './DesignCanvas';
|
|||||||
export { TShirtSVG } from './TShirtSVG';
|
export { TShirtSVG } from './TShirtSVG';
|
||||||
export { ImageElement } from './ImageElement';
|
export { ImageElement } from './ImageElement';
|
||||||
export { TextElement } from './TextElement';
|
export { TextElement } from './TextElement';
|
||||||
|
export { TemplateLayer } from './TemplateLayer';
|
||||||
|
export { SlotPlaceholder, SlotBoundsGuide } from './SlotPlaceholder';
|
||||||
|
|||||||
@@ -1,8 +1,53 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import FilerobotImageEditor from 'react-filerobot-image-editor';
|
import FilerobotImageEditor from 'react-filerobot-image-editor';
|
||||||
|
|
||||||
export function PhotoPreEditor({ imageSrc, onComplete, onClose }) {
|
export function PhotoPreEditor({ imageSrc, onComplete, onClose, triggerElementRef }) {
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const modalContentRef = useRef(null);
|
||||||
|
const previousFocusRef = useRef(null);
|
||||||
|
|
||||||
|
// Focus management - trap focus inside modal and restore on close
|
||||||
|
useEffect(() => {
|
||||||
|
// Store the element that had focus before modal opened
|
||||||
|
previousFocusRef.current = document.activeElement;
|
||||||
|
|
||||||
|
// Focus the modal content when it opens
|
||||||
|
const focusableElement = modalContentRef.current?.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||||
|
focusableElement?.focus();
|
||||||
|
|
||||||
|
// Focus trap handler
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
const modalContent = modalContentRef.current;
|
||||||
|
if (!modalContent) return;
|
||||||
|
|
||||||
|
const focusableElements = modalContent.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||||
|
const firstElement = focusableElements[0];
|
||||||
|
const lastElement = focusableElements[focusableElements.length - 1];
|
||||||
|
|
||||||
|
if (e.shiftKey && document.activeElement === firstElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastElement.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === lastElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstElement.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
// Restore focus to the element that triggered the modal
|
||||||
|
previousFocusRef.current?.focus();
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
const handleComplete = (editedImageObject, designState) => {
|
const handleComplete = (editedImageObject, designState) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -23,8 +68,17 @@ export function PhotoPreEditor({ imageSrc, onComplete, onClose }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="filerobot-overlay">
|
<div
|
||||||
<div className="filerobot-container">
|
className="filerobot-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="photo-editor-title"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="filerobot-container"
|
||||||
|
ref={modalContentRef}
|
||||||
|
role="document"
|
||||||
|
>
|
||||||
<FilerobotImageEditor
|
<FilerobotImageEditor
|
||||||
source={imageSrc}
|
source={imageSrc}
|
||||||
onSave={handleComplete}
|
onSave={handleComplete}
|
||||||
@@ -51,8 +105,12 @@ export function PhotoPreEditor({ imageSrc, onComplete, onClose }) {
|
|||||||
saveButtonProps={{
|
saveButtonProps={{
|
||||||
label: saving ? 'Exporting...' : 'Use Edited Image',
|
label: saving ? 'Exporting...' : 'Use Edited Image',
|
||||||
}}
|
}}
|
||||||
|
closeOnSave
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<h2 id="photo-editor-title" className="sr-only" style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0,0,0,0)' }}>
|
||||||
|
Photo Editor
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate, onDelete }) {
|
export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate, onDelete, onEditPhoto }) {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return (
|
return (
|
||||||
<div className="properties-panel">
|
<div className="properties-panel">
|
||||||
@@ -178,6 +178,37 @@ export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate
|
|||||||
</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) */}
|
{/* Font size (for text) */}
|
||||||
{element.type === 'text' && (
|
{element.type === 'text' && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const TABS = [
|
|||||||
{ id: 'templates', label: 'Templates', icon: '📋' },
|
{ id: 'templates', label: 'Templates', icon: '📋' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar({ onAddImage, onAddSticker, onAddText, onAddTemplate }) {
|
export function Sidebar({ onAddImage, onAddSticker, onAddText, onAddTemplate, onSlotImageUpload }) {
|
||||||
const [activeTab, setActiveTab] = useState('upload');
|
const [activeTab, setActiveTab] = useState('upload');
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
@@ -23,7 +23,7 @@ export function Sidebar({ onAddImage, onAddSticker, onAddText, onAddTemplate })
|
|||||||
case 'text':
|
case 'text':
|
||||||
return <TextTab onAddText={onAddText} />;
|
return <TextTab onAddText={onAddText} />;
|
||||||
case 'templates':
|
case 'templates':
|
||||||
return <TemplatesTab onAddTemplate={onAddTemplate} />;
|
return <TemplatesTab onAddTemplate={onAddTemplate} onSlotImageUpload={onSlotImageUpload} />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { TEMPLATES, TEMPLATE_CATEGORIES } from '../../constants/templates';
|
import { TEMPLATES, TEMPLATE_CATEGORIES } from '../../constants/templates';
|
||||||
|
|
||||||
// Helper to get emoji for category
|
// Helper to get emoji for category
|
||||||
@@ -15,7 +16,53 @@ function getCategoryEmoji(category) {
|
|||||||
return emojis[category] || '🎨';
|
return emojis[category] || '🎨';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TemplatesTab({ onAddTemplate }) {
|
export function TemplatesTab({ onAddTemplate, onSlotImageUpload }) {
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState(null);
|
||||||
|
const [uploadSlotId, setUploadSlotId] = useState(null);
|
||||||
|
|
||||||
|
const templates = [
|
||||||
|
{
|
||||||
|
id: 'freeform',
|
||||||
|
name: 'Freeform',
|
||||||
|
description: 'No template - design freely',
|
||||||
|
thumbnail: '🎨',
|
||||||
|
},
|
||||||
|
...TEMPLATES.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
thumbnail: getCategoryEmoji(t.category),
|
||||||
|
hasSlots: !!t.slots,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSelectTemplate = (template) => {
|
||||||
|
setSelectedTemplateId(template.id);
|
||||||
|
onAddTemplate(template.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlotClick = (slotId) => {
|
||||||
|
setUploadSlotId(slotId);
|
||||||
|
// Trigger file input click
|
||||||
|
document.getElementById('slot-file-input')?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file && uploadSlotId) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
onSlotImageUpload?.(uploadSlotId, event.target.result);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
setUploadSlotId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get slots for selected template
|
||||||
|
const selectedTemplate = TEMPLATES.find(t => t.id === selectedTemplateId);
|
||||||
|
const slots = selectedTemplate?.slots || [];
|
||||||
const templates = [
|
const templates = [
|
||||||
{
|
{
|
||||||
id: 'freeform',
|
id: 'freeform',
|
||||||
@@ -35,8 +82,90 @@ export function TemplatesTab({ onAddTemplate }) {
|
|||||||
onAddTemplate(template.id);
|
onAddTemplate(template.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hidden file input for slot image uploads
|
||||||
|
const renderFileInput = () => (
|
||||||
|
<input
|
||||||
|
id="slot-file-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render slot upload buttons for template with slots
|
||||||
|
const renderSlotUploads = () => {
|
||||||
|
if (!selectedTemplateId || selectedTemplateId === 'freeform' || slots.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '1rem',
|
||||||
|
background: 'var(--bg-tertiary)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<h4 style={{
|
||||||
|
margin: '0 0 0.75rem 0',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}>
|
||||||
|
Template Slots
|
||||||
|
</h4>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}>
|
||||||
|
{slots.map((slot) => (
|
||||||
|
<button
|
||||||
|
key={slot.id}
|
||||||
|
onClick={() => handleSlotClick(slot.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: 'var(--bg-primary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.target.style.borderColor = 'var(--accent)';
|
||||||
|
e.target.style.background = 'var(--bg-secondary)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.target.style.borderColor = 'var(--border)';
|
||||||
|
e.target.style.background = 'var(--bg-primary)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '16px' }}>📷</span>
|
||||||
|
<span>{slot.label}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
marginLeft: 'auto',
|
||||||
|
}}>
|
||||||
|
{slot.bounds.width}×{slot.bounds.height}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{renderFileInput()}
|
||||||
|
|
||||||
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>
|
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>
|
||||||
Templates
|
Templates
|
||||||
</h3>
|
</h3>
|
||||||
@@ -66,17 +195,17 @@ export function TemplatesTab({ onAddTemplate }) {
|
|||||||
padding: '0.75rem',
|
padding: '0.75rem',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: 'var(--radius-md)',
|
||||||
background: 'var(--bg-primary)',
|
background: template.id === selectedTemplateId ? 'var(--bg-secondary)' : 'var(--bg-primary)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
transition: 'all 0.15s ease',
|
transition: 'all 0.15s ease',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.target.style.borderColor = 'var(--accent)';
|
e.currentTarget.style.borderColor = 'var(--accent)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.target.style.borderColor = 'var(--border)';
|
e.currentTarget.style.borderColor = 'var(--border)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -106,9 +235,23 @@ export function TemplatesTab({ onAddTemplate }) {
|
|||||||
{template.description}
|
{template.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{template.hasSlots && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
background: 'var(--accent)',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: 'var(--radius-xs)',
|
||||||
|
fontWeight: '600',
|
||||||
|
}}>
|
||||||
|
SLOTS
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderSlotUploads()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,20 @@ export const TEMPLATES = [
|
|||||||
name: 'Team Sport',
|
name: 'Team Sport',
|
||||||
category: 'Sports',
|
category: 'Sports',
|
||||||
description: 'Classic team jersey with number and text',
|
description: 'Classic team jersey with number and text',
|
||||||
|
slots: [
|
||||||
|
{
|
||||||
|
id: 'chest-text',
|
||||||
|
label: 'Team Name',
|
||||||
|
bounds: { x: 75, y: 70, width: 150, height: 40 },
|
||||||
|
aspectRatio: 3.75,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chest-number',
|
||||||
|
label: 'Number',
|
||||||
|
bounds: { x: 100, y: 120, width: 100, height: 100 },
|
||||||
|
aspectRatio: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
elements: [
|
elements: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
|||||||
192
client/src/hooks/useTemplate.js
Normal file
192
client/src/hooks/useTemplate.js
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate auto-crop for fitting an image into a slot
|
||||||
|
* Implements object-fit: cover behavior
|
||||||
|
*
|
||||||
|
* @param {Object} imageSize - Original image dimensions {width, height}
|
||||||
|
* @param {Object} slotSize - Slot dimensions {width, height}
|
||||||
|
* @returns {Object} Crop coordinates {sx, sy, sWidth, sHeight}
|
||||||
|
*/
|
||||||
|
export function calculateAutoCrop(imageSize, slotSize) {
|
||||||
|
const imageRatio = imageSize.width / imageSize.height;
|
||||||
|
const slotRatio = slotSize.width / slotSize.height;
|
||||||
|
|
||||||
|
let sx, sy, sWidth, sHeight;
|
||||||
|
|
||||||
|
if (imageRatio > slotRatio) {
|
||||||
|
// Image is wider than slot - crop sides
|
||||||
|
sHeight = imageSize.height;
|
||||||
|
sWidth = imageSize.height * slotRatio;
|
||||||
|
sx = (imageSize.width - sWidth) / 2;
|
||||||
|
sy = 0;
|
||||||
|
} else {
|
||||||
|
// Image is taller than slot - crop top/bottom
|
||||||
|
sWidth = imageSize.width;
|
||||||
|
sHeight = imageSize.width / slotRatio;
|
||||||
|
sx = 0;
|
||||||
|
sy = (imageSize.height - sHeight) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sx, sy, sWidth, sHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a drag bound function that constrains movement to slot bounds
|
||||||
|
*
|
||||||
|
* @param {Object} slot - Slot configuration with bounds
|
||||||
|
* @param {Object} elementSize - Element dimensions {width, height}
|
||||||
|
* @returns {Function} Drag bound function for Konva
|
||||||
|
*/
|
||||||
|
export function createDragBoundFunc(slot, elementSize) {
|
||||||
|
const { bounds } = slot;
|
||||||
|
const minX = bounds.x;
|
||||||
|
const minY = bounds.y;
|
||||||
|
const maxX = bounds.x + bounds.width - elementSize.width;
|
||||||
|
const maxY = bounds.y + bounds.height - elementSize.height;
|
||||||
|
|
||||||
|
return (oldBox, newBox) => {
|
||||||
|
return {
|
||||||
|
x: Math.max(minX, Math.min(newBox.x, maxX)),
|
||||||
|
y: Math.max(minY, Math.min(newBox.y, maxY)),
|
||||||
|
width: newBox.width,
|
||||||
|
height: newBox.height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useTemplate - Hook for managing template state and slot operations
|
||||||
|
*
|
||||||
|
* @param {Array} templates - Available templates
|
||||||
|
* @returns {Object} Template management functions and state
|
||||||
|
*/
|
||||||
|
export function useTemplate(templates = []) {
|
||||||
|
const [currentTemplateId, setCurrentTemplateId] = useState(null);
|
||||||
|
const [assignedSlots, setAssignedSlots] = useState({});
|
||||||
|
const templateRef = useRef(null);
|
||||||
|
|
||||||
|
// Get current template object
|
||||||
|
const currentTemplate = templates.find(t => t.id === currentTemplateId) || null;
|
||||||
|
|
||||||
|
// Get slots for current template
|
||||||
|
const getSlots = useCallback(() => {
|
||||||
|
if (!currentTemplate || !currentTemplate.slots) return [];
|
||||||
|
return currentTemplate.slots;
|
||||||
|
}, [currentTemplate]);
|
||||||
|
|
||||||
|
// Load a template
|
||||||
|
const loadTemplate = useCallback((templateId) => {
|
||||||
|
const template = templates.find(t => t.id === templateId);
|
||||||
|
if (template) {
|
||||||
|
setCurrentTemplateId(templateId);
|
||||||
|
setAssignedSlots({});
|
||||||
|
templateRef.current = template;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [templates]);
|
||||||
|
|
||||||
|
// Clear template (freeform mode)
|
||||||
|
const clearTemplate = useCallback(() => {
|
||||||
|
setCurrentTemplateId(null);
|
||||||
|
setAssignedSlots({});
|
||||||
|
templateRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Assign image to slot
|
||||||
|
const assignImageToSlot = useCallback((slotId, imageData) => {
|
||||||
|
const slots = getSlots();
|
||||||
|
const slot = slots.find(s => s.id === slotId);
|
||||||
|
|
||||||
|
if (!slot) {
|
||||||
|
console.error(`Slot ${slotId} not found`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load image to get dimensions
|
||||||
|
const img = new Image();
|
||||||
|
img.src = imageData;
|
||||||
|
|
||||||
|
const elementData = {
|
||||||
|
type: 'image',
|
||||||
|
src: imageData,
|
||||||
|
x: slot.bounds.x,
|
||||||
|
y: slot.bounds.y,
|
||||||
|
width: slot.bounds.width,
|
||||||
|
height: slot.bounds.height,
|
||||||
|
slotId,
|
||||||
|
// Will be set after image loads
|
||||||
|
crop: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate crop once image is loaded
|
||||||
|
img.onload = () => {
|
||||||
|
const crop = calculateAutoCrop(
|
||||||
|
{ width: img.width, height: img.height },
|
||||||
|
{ width: slot.bounds.width, height: slot.bounds.height }
|
||||||
|
);
|
||||||
|
elementData.crop = crop;
|
||||||
|
};
|
||||||
|
|
||||||
|
setAssignedSlots(prev => ({
|
||||||
|
...prev,
|
||||||
|
[slotId]: elementData,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return elementData;
|
||||||
|
}, [getSlots]);
|
||||||
|
|
||||||
|
// Get assigned slot data
|
||||||
|
const getAssignedSlot = useCallback((slotId) => {
|
||||||
|
return assignedSlots[slotId] || null;
|
||||||
|
}, [assignedSlots]);
|
||||||
|
|
||||||
|
// Remove image from slot
|
||||||
|
const removeFromSlot = useCallback((slotId) => {
|
||||||
|
setAssignedSlots(prev => {
|
||||||
|
const updated = { ...prev };
|
||||||
|
delete updated[slotId];
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get drag bound function for a slot
|
||||||
|
const getDragBoundFunc = useCallback((slotId, elementSize) => {
|
||||||
|
const slot = getSlots().find(s => s.id === slotId);
|
||||||
|
if (!slot) return null;
|
||||||
|
return createDragBoundFunc(slot, elementSize);
|
||||||
|
}, [getSlots]);
|
||||||
|
|
||||||
|
// Check if slot is filled
|
||||||
|
const isSlotFilled = useCallback((slotId) => {
|
||||||
|
return !!assignedSlots[slotId];
|
||||||
|
}, [assignedSlots]);
|
||||||
|
|
||||||
|
// Get all assigned elements
|
||||||
|
const getAssignedElements = useCallback(() => {
|
||||||
|
return Object.values(assignedSlots);
|
||||||
|
}, [assignedSlots]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
currentTemplateId,
|
||||||
|
currentTemplate,
|
||||||
|
assignedSlots,
|
||||||
|
|
||||||
|
// Template management
|
||||||
|
loadTemplate,
|
||||||
|
clearTemplate,
|
||||||
|
getSlots,
|
||||||
|
|
||||||
|
// Slot operations
|
||||||
|
assignImageToSlot,
|
||||||
|
getAssignedSlot,
|
||||||
|
removeFromSlot,
|
||||||
|
isSlotFilled,
|
||||||
|
getAssignedElements,
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
getDragBoundFunc,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -866,3 +866,170 @@ input, textarea, select {
|
|||||||
grid-template-columns: repeat(8, 1fr);
|
grid-template-columns: repeat(8, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tablet - Stack sidebar and properties panel */
|
||||||
|
@media (max-width: 1200px) and (min-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-area {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile - Full width layout */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.editor-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 50vh;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-area {
|
||||||
|
padding: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel {
|
||||||
|
width: 100%;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
max-height: 40vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust tab buttons for mobile */
|
||||||
|
.sidebar-tabs button {
|
||||||
|
padding: 10px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smaller sticker grid on mobile */
|
||||||
|
.sticker-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Templates grid on mobile */
|
||||||
|
.templates-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust canvas size display */
|
||||||
|
.canvas-area h1 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-area p {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stack action buttons */
|
||||||
|
.canvas-area > div:nth-child(2) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-area button {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PWA banners on mobile */
|
||||||
|
.pwa-install-banner,
|
||||||
|
.pwa-update-banner {
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
transform: none;
|
||||||
|
width: auto;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-install-buttons,
|
||||||
|
.pwa-update-buttons {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small mobile */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.sidebar {
|
||||||
|
max-height: 40vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-area {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Larger touch targets */
|
||||||
|
button {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticker-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-pills {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-pill {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch device optimizations */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
.sticker-button:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Larger drop zones */
|
||||||
|
.upload-zone {
|
||||||
|
padding: 2.5rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Offline indicator */
|
||||||
|
.offline-indicator {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-indicator.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,25 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
|
// Service worker update handling
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.ready.then((registration) => {
|
||||||
|
// Listen for updates from the service worker
|
||||||
|
registration.addEventListener('updatefound', () => {
|
||||||
|
const newWorker = registration.installing;
|
||||||
|
|
||||||
|
newWorker.addEventListener('statechange', () => {
|
||||||
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
|
// Dispatch custom event for PWAInstall component
|
||||||
|
window.dispatchEvent(new CustomEvent('swUpdated', { detail: newWorker }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'prompt',
|
||||||
includeAssets: ['favicon.ico', 'pwa-192x192.svg', 'pwa-512x512.svg'],
|
includeAssets: ['favicon.ico', 'pwa-192x192.svg', 'pwa-512x512.svg'],
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Apparel Designer',
|
name: 'Apparel Designer',
|
||||||
@@ -54,6 +54,21 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// HuggingFace LFS (Large File Storage) for model weights
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/cdn-lfs\.huggingface\.co\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'transformers-lfs',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 10,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 30,
|
||||||
|
},
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
urlPattern: /^\/api\/uploads\/.*/i,
|
urlPattern: /^\/api\/uploads\/.*/i,
|
||||||
handler: 'CacheFirst',
|
handler: 'CacheFirst',
|
||||||
@@ -65,6 +80,37 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Template data caching
|
||||||
|
{
|
||||||
|
urlPattern: /^\/api\/templates\/.*/i,
|
||||||
|
handler: 'StaleWhileRevalidate',
|
||||||
|
options: {
|
||||||
|
cacheName: 'template-data',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 20,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 7,
|
||||||
|
},
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// API responses caching
|
||||||
|
{
|
||||||
|
urlPattern: /^\/api\/.*/i,
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'api-responses',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 50,
|
||||||
|
maxAgeSeconds: 300,
|
||||||
|
},
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200],
|
||||||
|
},
|
||||||
|
networkTimeoutSeconds: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
||||||
handler: 'StaleWhileRevalidate',
|
handler: 'StaleWhileRevalidate',
|
||||||
|
|||||||
Reference in New Issue
Block a user