diff --git a/client/package.json b/client/package.json index fbd370a..9f4b772 100644 --- a/client/package.json +++ b/client/package.json @@ -27,6 +27,8 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", - "vite": "^8.0.9" + "vite": "^8.0.9", + "vite-plugin-pwa": "^0.20.5", + "workbox-window": "^7.1.0" } } diff --git a/client/src/App.jsx b/client/src/App.jsx index 54c347d..8d0ae7a 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -3,7 +3,9 @@ import { DesignCanvas } from './components/canvas/DesignCanvas'; import { Sidebar } from './components/sidebar/Sidebar'; import { LayersPanel } from './components/panels/LayersPanel'; import { PropertiesPanel } from './components/panels/PropertiesPanel'; +import { PWAInstall } from './components/PWAInstall'; import { useDesignEditor } from './hooks/useDesignEditor'; +import { useExport } from './hooks/useExport'; function App() { const { @@ -14,17 +16,44 @@ function App() { deleteElement, selectElement, deselectAll, + undo, + redo, + canUndo, + canRedo, + initializeHistory, } = useDesignEditor(); - const selectedElement = elements.find(el => el.id === selectedId); + const { exporting, progress, exportDesign, error, clearExport } = useExport(); - // Keyboard shortcut: Delete/Backspace removes selected element + const selectedElement = elements.find((el) => el.id === selectedId); + + // Initialize history on mount + useEffect(() => { + initializeHistory(); + }, [initializeHistory]); + + // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e) => { - // Don't delete if user is typing in an input if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { return; } + + // Undo: Ctrl/Cmd + Z + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + if (canUndo) undo(); + return; + } + + // Redo: Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y + if ((e.ctrlKey || e.metaKey) && ((e.key === 'z' && e.shiftKey) || e.key === 'y')) { + e.preventDefault(); + if (canRedo) redo(); + return; + } + + // Delete/Backspace removes selected element if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedId) { deleteElement(selectedId); @@ -34,7 +63,7 @@ function App() { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [selectedId, deleteElement]); + }, [selectedId, deleteElement, undo, redo, canUndo, canRedo]); // Handler callbacks for sidebar tabs const handleAddImage = (imageData) => { @@ -49,13 +78,19 @@ function App() { addElement(textData); }; - const handleAddTemplate = (templateId) => { - console.log('Template selected:', templateId); - // Template loading will be implemented in Phase 6 + const handleAddTemplate = (template) => { + if (template && template.elements) { + template.elements.forEach((el, index) => { + setTimeout(() => addElement({ ...el }), index * 50); + }); + } }; return (
+ {/* PWA Install Prompt */} + + {/* Left Sidebar */}
+ {/* Action buttons */} +
+ + + +
+ + {/* Export error banner */} + {error && ( +
+ ⚠️ Export failed: {error} + +
+ )} + updateElement(id, attrs)} /> {/* Layers panel below canvas */} diff --git a/client/src/components/PWAInstall.jsx b/client/src/components/PWAInstall.jsx new file mode 100644 index 0000000..87f1ff9 --- /dev/null +++ b/client/src/components/PWAInstall.jsx @@ -0,0 +1,48 @@ +import { useState, useEffect } from 'react'; + +export function PWAInstall() { + const [deferredPrompt, setDeferredPrompt] = useState(null); + const [showInstall, setShowInstall] = useState(false); + + useEffect(() => { + const handleBeforeInstallPrompt = (e) => { + e.preventDefault(); + setDeferredPrompt(e); + setShowInstall(true); + }; + + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + + return () => { + window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + }; + }, []); + + const handleInstall = async () => { + if (!deferredPrompt) return; + + deferredPrompt.prompt(); + const { outcome } = await deferredPrompt.userChoice; + + if (outcome === 'accepted') { + setShowInstall(false); + setDeferredPrompt(null); + } + }; + + if (!showInstall) return null; + + return ( +
+

Install Apparel Designer for offline access!

+
+ + +
+
+ ); +} diff --git a/client/src/hooks/useExport.js b/client/src/hooks/useExport.js new file mode 100644 index 0000000..0c5d16e --- /dev/null +++ b/client/src/hooks/useExport.js @@ -0,0 +1,69 @@ +import { useState, useCallback } from 'react'; + +export function useExport() { + const [exporting, setExporting] = useState(false); + const [progress, setProgress] = useState(0); + const [exportUrl, setExportUrl] = useState(null); + const [error, setError] = useState(null); + + const exportDesign = useCallback(async (elements, designName = 'design') => { + setExporting(true); + setProgress(0); + setError(null); + setExportUrl(null); + + try { + // Simulate progress during export + const progressInterval = setInterval(() => { + setProgress((prev) => Math.min(prev + 10, 90)); + }, 200); + + const response = await fetch('/api/export', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ elements, designName }), + }); + + clearInterval(progressInterval); + setProgress(100); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Export failed'); + } + + const data = await response.json(); + setExportUrl(data.export.url); + + // Trigger download + const link = document.createElement('a'); + link.href = data.export.url; + link.download = data.export.filename; + link.click(); + + setExporting(false); + return data; + } catch (err) { + console.error('Export failed:', err); + setError(err.message); + setExporting(false); + throw err; + } + }, []); + + const clearExport = useCallback(() => { + setExportUrl(null); + setError(null); + }, []); + + return { + exporting, + progress, + exportUrl, + error, + exportDesign, + clearExport, + }; +} diff --git a/client/src/index.css b/client/src/index.css index ddf7946..a395e25 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -710,6 +710,77 @@ input, textarea, select { overflow: hidden; } +/* PWA Install Banner */ +.pwa-install-banner { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: var(--bg-primary); + border: 1px solid var(--accent); + border-radius: var(--radius-lg); + padding: 1rem 1.5rem; + box-shadow: var(--shadow-lg); + z-index: 1000; + display: flex; + align-items: center; + gap: 1rem; + animation: slideUp 0.3s ease-out; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateX(-50%) translateY(20px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +.pwa-install-banner p { + margin: 0; + font-size: 0.875rem; + color: var(--text-primary); +} + +.pwa-install-buttons { + display: flex; + gap: 0.5rem; +} + +.install-btn { + padding: 0.5rem 1rem; + background: var(--accent); + color: white; + border: none; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.install-btn:hover { + background: var(--accent-hover); +} + +.dismiss-btn { + padding: 0.5rem 1rem; + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.75rem; + cursor: pointer; + transition: all 0.2s; +} + +.dismiss-btn:hover { + background: var(--bg-secondary); +} + /* Responsive */ @media (max-width: 900px) { .app-layout { diff --git a/client/vite.config.js b/client/vite.config.js index 38f4202..f00e4c1 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -1,16 +1,111 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { VitePWA } from 'vite-plugin-pwa'; -// https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + VitePWA({ + registerType: 'autoUpdate', + includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'masked-icon.svg'], + manifest: { + name: 'Apparel Designer', + short_name: 'ApparelDesigner', + description: 'T-shirt customization editor', + theme_color: '#ffffff', + background_color: '#ffffff', + display: 'standalone', + orientation: 'any', + scope: '/', + start_url: '/', + icons: [ + { + src: 'pwa-192x192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: 'pwa-512x512.png', + sizes: '512x512', + type: 'image/png', + }, + { + src: 'pwa-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any maskable', + }, + ], + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], + runtimeCaching: [ + { + urlPattern: /^https:\/\/cdn\.huggingface\.co\/.*/i, + handler: 'CacheFirst', + options: { + cacheName: 'transformers-models', + expiration: { + maxEntries: 10, + maxAgeSeconds: 60 * 60 * 24 * 30, + }, + cacheableResponse: { + statuses: [0, 200], + }, + }, + }, + { + urlPattern: /^\/api\/uploads\/.*/i, + handler: 'CacheFirst', + options: { + cacheName: 'uploaded-images', + expiration: { + maxEntries: 50, + maxAgeSeconds: 60 * 60 * 24 * 7, + }, + }, + }, + { + urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i, + handler: 'StaleWhileRevalidate', + options: { + cacheName: 'google-fonts', + expiration: { + maxEntries: 10, + maxAgeSeconds: 60 * 60 * 24 * 365, + }, + }, + }, + { + urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i, + handler: 'CacheFirst', + options: { + cacheName: 'gstatic-fonts', + expiration: { + maxEntries: 10, + maxAgeSeconds: 60 * 60 * 24 * 365, + }, + }, + }, + ], + }, + }), + ], server: { port: 3000, proxy: { '/api': { target: 'http://localhost:3001', - changeOrigin: true - } - } - } -}) + changeOrigin: true, + }, + '/uploads': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + '/exports': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + }, + }, +}); diff --git a/server/index.js b/server/index.js index 08bf156..62de584 100644 --- a/server/index.js +++ b/server/index.js @@ -3,9 +3,10 @@ import cors from 'cors'; import multer from 'multer'; import { v4 as uuidv4 } from 'uuid'; import sharp from 'sharp'; +import { createCanvas, loadImage } from 'canvas'; import { fileURLToPath } from 'module'; import { dirname, join } from 'path'; -import { mkdirSync, existsSync } from 'fs'; +import { mkdirSync, existsSync, writeFileSync } from 'fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -120,6 +121,86 @@ app.use((err, req, res, next) => { next(err); }); +// High-resolution export endpoint (300x300px -> 4500x4500px @ 300 DPI) +const EXPORT_SCALE = 15; +const EXPORT_SIZE = 4500; + +app.post('/api/export', async (req, res) => { + try { + const { elements, designName = 'design' } = req.body; + + if (!elements || !Array.isArray(elements)) { + return res.status(400).json({ error: 'Elements array is required' }); + } + + const canvas = createCanvas(EXPORT_SIZE, EXPORT_SIZE); + const ctx = canvas.getContext('2d'); + + // White background + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, EXPORT_SIZE, EXPORT_SIZE); + + // Render each element + for (const el of elements) { + ctx.save(); + + const x = (el.x || 0) * EXPORT_SCALE; + const y = (el.y || 0) * EXPORT_SCALE; + const centerX = x + ((el.width || el.fontSize || 100) * EXPORT_SCALE) / 2; + const centerY = y + ((el.height || el.fontSize || 100) * EXPORT_SCALE) / 2; + + ctx.translate(centerX, centerY); + ctx.rotate((el.rotation || 0) * Math.PI / 180); + ctx.translate(-centerX, -centerY); + + if (el.type === 'image' && el.src) { + try { + const imgUrl = el.src.startsWith('/') + ? join(__dirname, el.src.replace('/uploads', 'uploads')) + : el.src; + const img = await loadImage(imgUrl); + const width = (el.width || 100) * EXPORT_SCALE; + const height = (el.height || 100) * EXPORT_SCALE; + ctx.drawImage(img, x, y, width, height); + } catch (imgError) { + console.error('Failed to load image for export:', imgError); + } + } else if (el.type === 'text') { + const fontSize = (el.fontSize || 32) * EXPORT_SCALE / 32; + ctx.font = `${fontSize}px "${el.fontFamily || 'Arial'}"`; + ctx.fillStyle = el.fill || '#000000'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(el.text || '', centerX, centerY); + } + + ctx.restore(); + } + + // Save to file + const exportFilename = `${designName.replace(/[^a-z0-9]/gi, '_')}_${uuidv4()}.png`; + const exportPath = join(exportsDir, exportFilename); + const buffer = canvas.toBuffer('image/png'); + writeFileSync(exportPath, buffer); + + res.json({ + success: true, + export: { + url: `/exports/${exportFilename}`, + path: exportPath, + filename: exportFilename, + width: EXPORT_SIZE, + height: EXPORT_SIZE, + dpi: 300, + sizeInches: '15x15' + } + }); + } catch (error) { + console.error('Export error:', error); + res.status(500).json({ error: 'Failed to export design', details: error.message }); + } +}); + app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); console.log(`Health check: http://localhost:${PORT}/api/health`);