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 (
{/* 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 (
-
-
+
);
}
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',