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:
Khalid A
2026-04-21 22:08:22 -05:00
parent 304a6b247b
commit 009557c249
16 changed files with 1181 additions and 32 deletions

View File

@@ -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>
); );
} }

View 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>
);
}

View File

@@ -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>
)}
</>
); );
} }

View File

@@ -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 */}

View 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>
);
}

View 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>
);
}

View File

@@ -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';

View File

@@ -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>
); );
} }

View File

@@ -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' && (
<> <>

View File

@@ -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;
} }

View File

@@ -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>
); );
} }

View File

@@ -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',

View 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,
};
}

View File

@@ -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;
}

View File

@@ -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 />

View File

@@ -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',