From 009557c2491b3dd9f7092f4df334f6b54b328482 Mon Sep 17 00:00:00 2001 From: Khalid A Date: Tue, 21 Apr 2026 22:08:22 -0500 Subject: [PATCH] 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 --- client/src/App.jsx | 76 ++++++- client/src/components/OfflineIndicator.jsx | 27 +++ client/src/components/PWAInstall.jsx | 101 +++++++-- client/src/components/canvas/DesignCanvas.jsx | 39 ++++ .../src/components/canvas/SlotPlaceholder.jsx | 155 ++++++++++++++ .../src/components/canvas/TemplateLayer.jsx | 119 +++++++++++ client/src/components/canvas/index.js | 2 + .../src/components/editor/PhotoPreEditor.jsx | 66 +++++- .../src/components/panels/PropertiesPanel.jsx | 33 ++- client/src/components/sidebar/Sidebar.jsx | 4 +- .../src/components/sidebar/TemplatesTab.jsx | 151 +++++++++++++- client/src/constants/templates.js | 14 ++ client/src/hooks/useTemplate.js | 192 ++++++++++++++++++ client/src/index.css | 167 +++++++++++++++ client/src/main.jsx | 19 ++ client/vite.config.js | 48 ++++- 16 files changed, 1181 insertions(+), 32 deletions(-) create mode 100644 client/src/components/OfflineIndicator.jsx create mode 100644 client/src/components/canvas/SlotPlaceholder.jsx create mode 100644 client/src/components/canvas/TemplateLayer.jsx create mode 100644 client/src/hooks/useTemplate.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 7e4786b..7ce0d97 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -4,12 +4,15 @@ import { Sidebar } from './components/sidebar/Sidebar'; import { LayersPanel } from './components/panels/LayersPanel'; import { PropertiesPanel } from './components/panels/PropertiesPanel'; import { PWAInstall } from './components/PWAInstall'; +import { OfflineIndicator } from './components/OfflineIndicator'; +import { PhotoPreEditor } from './components/editor/PhotoPreEditor'; import { useDesignEditor } from './hooks/useDesignEditor'; import { useExport } from './hooks/useExport'; +import { useTemplate } from './hooks/useTemplate'; import { TEMPLATES } from './constants/templates'; function App() { - const [currentTemplate, setCurrentTemplate] = useState(null); + const [editingElement, setEditingElement] = useState(null); const { elements, @@ -29,6 +32,19 @@ function App() { 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); // Initialize history on mount @@ -83,24 +99,54 @@ function App() { }; const handleAddTemplate = (templateId) => { - // Find template by ID - const template = TEMPLATES.find(t => t.id === templateId); - if (template) { - setCurrentTemplate(template); + if (templateId === 'freeform') { + clearTemplate(); + return; + } + + // Load template using useTemplate hook + const success = loadTemplate(templateId); + if (success) { + const template = TEMPLATES.find(t => t.id === templateId); // Clear existing elements first // Apply template elements to canvas - if (template.elements) { + if (template?.elements) { template.elements.forEach((el, index) => { 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 (
+ {/* Offline Indicator */} + + {/* PWA Install Prompt */} @@ -110,6 +156,7 @@ function App() { onAddSticker={handleAddSticker} onAddText={handleAddText} onAddTemplate={handleAddTemplate} + onSlotImageUpload={handleSlotImageUpload} /> {/* Center Canvas Area */} @@ -200,6 +247,9 @@ function App() { onDeselect={deselectAll} onUpdate={(id, attrs) => updateElement(id, attrs)} onCommit={commitHistory} + currentTemplate={currentTemplate} + assignedSlots={assignedSlots} + getDragBoundFunc={getDragBoundFunc} /> {/* Layers panel below canvas */} @@ -226,7 +276,17 @@ function App() { element={selectedElement} onUpdate={(attrs) => updateElement(selectedId, attrs)} onDelete={deleteElement} + onEditPhoto={handleEditPhoto} /> + + {/* Photo Pre-Editor Modal */} + {editingElement && ( + + )}
); } diff --git a/client/src/components/OfflineIndicator.jsx b/client/src/components/OfflineIndicator.jsx new file mode 100644 index 0000000..842b43c --- /dev/null +++ b/client/src/components/OfflineIndicator.jsx @@ -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 ( +
+ ⚠️ + You're offline - changes are saved locally +
+ ); +} diff --git a/client/src/components/PWAInstall.jsx b/client/src/components/PWAInstall.jsx index 87f1ff9..3cc41ad 100644 --- a/client/src/components/PWAInstall.jsx +++ b/client/src/components/PWAInstall.jsx @@ -3,6 +3,8 @@ import { useState, useEffect } from 'react'; export function PWAInstall() { const [deferredPrompt, setDeferredPrompt] = useState(null); const [showInstall, setShowInstall] = useState(false); + const [updateAvailable, setUpdateAvailable] = useState(false); + const [newWorker, setNewWorker] = useState(null); useEffect(() => { const handleBeforeInstallPrompt = (e) => { @@ -13,11 +15,20 @@ export function PWAInstall() { window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + // Listen for service worker updates + window.addEventListener('swUpdated', handleSWUpdated); + return () => { window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + window.removeEventListener('swUpdated', handleSWUpdated); }; }, []); + const handleSWUpdated = (event) => { + setNewWorker(event.detail); + setUpdateAvailable(true); + }; + const handleInstall = async () => { if (!deferredPrompt) return; @@ -30,19 +41,85 @@ 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 ( -
-

Install Apparel Designer for offline access!

-
- - -
-
+ <> + {showInstall && ( +
+

Install Apparel Designer for offline access!

+
+ + +
+
+ )} + + {updateAvailable && ( +
+ 🔄 New version available! + + +
+ )} + ); } diff --git a/client/src/components/canvas/DesignCanvas.jsx b/client/src/components/canvas/DesignCanvas.jsx index a3f2d99..0f5ddc1 100644 --- a/client/src/components/canvas/DesignCanvas.jsx +++ b/client/src/components/canvas/DesignCanvas.jsx @@ -2,6 +2,8 @@ import { Stage, Layer } from 'react-konva'; import { TShirtSVG } from './TShirtSVG'; import { ImageElement } from './ImageElement'; import { TextElement } from './TextElement'; +import { TemplateLayer } from './TemplateLayer'; +import { SlotPlaceholder, SlotBoundsGuide } from './SlotPlaceholder'; import { useRef, useEffect, memo } from 'react'; const CANVAS_SIZE = 300; @@ -13,7 +15,13 @@ export const DesignCanvas = memo(function DesignCanvas({ onDeselect, onUpdate, onCommit, + currentTemplate, + assignedSlots, + getDragBoundFunc, }) { + // Get slots from current template + const slots = currentTemplate?.slots || []; + return (
{/* T-shirt SVG background */} @@ -35,6 +43,21 @@ export const DesignCanvas = memo(function DesignCanvas({ background: 'rgba(255, 255, 255, 0.5)', }} > + {/* Template Layer - Background and Overlay */} + + {currentTemplate && ( + + )} + + + {/* Slot Bounds Guides - visible even when slots have content */} + + {slots.map((slot) => ( + + ))} + + + {/* User Elements Layer */} {elements.map((el) => { if (el.type === 'image') { @@ -48,10 +71,12 @@ export const DesignCanvas = memo(function DesignCanvas({ height={el.height} rotation={el.rotation} src={el.src} + crop={el.crop} isSelected={el.id === selectedId} onSelect={() => onSelect(el.id)} onUpdate={(attrs) => onUpdate(el.id, attrs)} 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; })} + + {/* Slot Placeholders - show when slots are empty */} + + {slots.map((slot) => { + const isFilled = !!assignedSlots?.[slot.id]; + return ( + + ); + })} + {/* Canvas info bar */} diff --git a/client/src/components/canvas/SlotPlaceholder.jsx b/client/src/components/canvas/SlotPlaceholder.jsx new file mode 100644 index 0000000..ccedd9d --- /dev/null +++ b/client/src/components/canvas/SlotPlaceholder.jsx @@ -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 ( + + {/* Dashed border rectangle */} + + + {/* Background fill (semi-transparent) */} + + + {/* Drop icon */} + + + {/* Label text */} + + + {/* Corner markers */} + + + + + + + + + + ); +} + +/** + * 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 ( + + + + ); +} diff --git a/client/src/components/canvas/TemplateLayer.jsx b/client/src/components/canvas/TemplateLayer.jsx new file mode 100644 index 0000000..0548c40 --- /dev/null +++ b/client/src/components/canvas/TemplateLayer.jsx @@ -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 ( + + ); +} + +// Helper component for text elements +function TemplateText({ text, x, y, fontSize, fontFamily, fill, rotation = 0 }) { + return ( + + ); +} + +/** + * 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 ( + + {/* Background Layer */} + {background && ( + + {background.type === 'color' ? ( + + ) : background.type === 'image' ? ( + + ) : null} + + )} + + {/* 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 ( + + ); + } + + if (el.type === 'text') { + return ( + + ); + } + + return null; + })} + + ); +} diff --git a/client/src/components/canvas/index.js b/client/src/components/canvas/index.js index a8628e2..84f5431 100644 --- a/client/src/components/canvas/index.js +++ b/client/src/components/canvas/index.js @@ -2,3 +2,5 @@ export { DesignCanvas } from './DesignCanvas'; export { TShirtSVG } from './TShirtSVG'; export { ImageElement } from './ImageElement'; export { TextElement } from './TextElement'; +export { TemplateLayer } from './TemplateLayer'; +export { SlotPlaceholder, SlotBoundsGuide } from './SlotPlaceholder'; diff --git a/client/src/components/editor/PhotoPreEditor.jsx b/client/src/components/editor/PhotoPreEditor.jsx index 7167c41..dfa9487 100644 --- a/client/src/components/editor/PhotoPreEditor.jsx +++ b/client/src/components/editor/PhotoPreEditor.jsx @@ -1,8 +1,53 @@ -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; 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 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) => { setSaving(true); @@ -23,8 +68,17 @@ export function PhotoPreEditor({ imageSrc, onComplete, onClose }) { }; return ( -
-
+
+
+

+ Photo Editor +

); } diff --git a/client/src/components/panels/PropertiesPanel.jsx b/client/src/components/panels/PropertiesPanel.jsx index 6e637b0..e436984 100644 --- a/client/src/components/panels/PropertiesPanel.jsx +++ b/client/src/components/panels/PropertiesPanel.jsx @@ -1,6 +1,6 @@ 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) { return (
@@ -178,6 +178,37 @@ export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate
)} + {/* Edit Photo button (for images only) */} + {element.type === 'image' && onEditPhoto && ( +
+ +
+ )} + {/* Font size (for text) */} {element.type === 'text' && ( <> diff --git a/client/src/components/sidebar/Sidebar.jsx b/client/src/components/sidebar/Sidebar.jsx index dc2d69e..99e5215 100644 --- a/client/src/components/sidebar/Sidebar.jsx +++ b/client/src/components/sidebar/Sidebar.jsx @@ -11,7 +11,7 @@ const TABS = [ { 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 renderTabContent = () => { @@ -23,7 +23,7 @@ export function Sidebar({ onAddImage, onAddSticker, onAddText, onAddTemplate }) case 'text': return ; case 'templates': - return ; + return ; default: return null; } diff --git a/client/src/components/sidebar/TemplatesTab.jsx b/client/src/components/sidebar/TemplatesTab.jsx index 8079f9a..2ecf1eb 100644 --- a/client/src/components/sidebar/TemplatesTab.jsx +++ b/client/src/components/sidebar/TemplatesTab.jsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { TEMPLATES, TEMPLATE_CATEGORIES } from '../../constants/templates'; // Helper to get emoji for category @@ -15,7 +16,53 @@ function getCategoryEmoji(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 = [ { id: 'freeform', @@ -35,8 +82,90 @@ export function TemplatesTab({ onAddTemplate }) { onAddTemplate(template.id); }; + // Hidden file input for slot image uploads + const renderFileInput = () => ( + + ); + + // Render slot upload buttons for template with slots + const renderSlotUploads = () => { + if (!selectedTemplateId || selectedTemplateId === 'freeform' || slots.length === 0) { + return null; + } + + return ( +
+

+ Template Slots +

+
+ {slots.map((slot) => ( + + ))} +
+
+ ); + }; + return (
+ {renderFileInput()} +

Templates

@@ -66,17 +195,17 @@ export function TemplatesTab({ onAddTemplate }) { padding: '0.75rem', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', - background: 'var(--bg-primary)', + background: template.id === selectedTemplateId ? 'var(--bg-secondary)' : 'var(--bg-primary)', cursor: 'pointer', opacity: 1, textAlign: 'left', transition: 'all 0.15s ease', }} onMouseEnter={(e) => { - e.target.style.borderColor = 'var(--accent)'; + e.currentTarget.style.borderColor = 'var(--accent)'; }} onMouseLeave={(e) => { - e.target.style.borderColor = 'var(--border)'; + e.currentTarget.style.borderColor = 'var(--border)'; }} >
+ {template.hasSlots && ( + + SLOTS + + )} ))}
+ + {renderSlotUploads()}
); } diff --git a/client/src/constants/templates.js b/client/src/constants/templates.js index f2d3969..9b08438 100644 --- a/client/src/constants/templates.js +++ b/client/src/constants/templates.js @@ -5,6 +5,20 @@ export const TEMPLATES = [ name: 'Team Sport', category: 'Sports', 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: [ { type: 'text', diff --git a/client/src/hooks/useTemplate.js b/client/src/hooks/useTemplate.js new file mode 100644 index 0000000..db63c9b --- /dev/null +++ b/client/src/hooks/useTemplate.js @@ -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, + }; +} diff --git a/client/src/index.css b/client/src/index.css index 1eafe41..46c2994 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -866,3 +866,170 @@ input, textarea, select { 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; +} diff --git a/client/src/main.jsx b/client/src/main.jsx index f55f4f7..ed456e3 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -3,6 +3,25 @@ import { createRoot } from 'react-dom/client' import './index.css' 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( diff --git a/client/vite.config.js b/client/vite.config.js index cc6d2c2..c487481 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -6,7 +6,7 @@ export default defineConfig({ plugins: [ react(), VitePWA({ - registerType: 'autoUpdate', + registerType: 'prompt', includeAssets: ['favicon.ico', 'pwa-192x192.svg', 'pwa-512x512.svg'], manifest: { 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, 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, handler: 'StaleWhileRevalidate',