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`);