Phase 9: PWA & Workbox Caching

- Added vite-plugin-pwa and workbox-window dependencies
- PWA manifest with icons and standalone display mode
- Workbox runtime caching for:
  - Transformers.js models (30 days)
  - Uploaded images (7 days)
  - Google Fonts (1 year)
- PWAInstall component with install prompt banner
- Offline support for cached assets
- Auto-update registration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Khalid A
2026-04-21 01:51:43 -05:00
parent 72a1967333
commit 5164b08c1c
7 changed files with 490 additions and 19 deletions

View File

@@ -27,6 +27,8 @@
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0", "globals": "^17.5.0",
"vite": "^8.0.9" "vite": "^8.0.9",
"vite-plugin-pwa": "^0.20.5",
"workbox-window": "^7.1.0"
} }
} }

View File

@@ -3,7 +3,9 @@ import { DesignCanvas } from './components/canvas/DesignCanvas';
import { Sidebar } from './components/sidebar/Sidebar'; 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 { useDesignEditor } from './hooks/useDesignEditor'; import { useDesignEditor } from './hooks/useDesignEditor';
import { useExport } from './hooks/useExport';
function App() { function App() {
const { const {
@@ -14,17 +16,44 @@ function App() {
deleteElement, deleteElement,
selectElement, selectElement,
deselectAll, deselectAll,
undo,
redo,
canUndo,
canRedo,
initializeHistory,
} = useDesignEditor(); } = 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(() => { useEffect(() => {
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
// Don't delete if user is typing in an input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return; 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 (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedId) { if (selectedId) {
deleteElement(selectedId); deleteElement(selectedId);
@@ -34,7 +63,7 @@ function App() {
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedId, deleteElement]); }, [selectedId, deleteElement, undo, redo, canUndo, canRedo]);
// Handler callbacks for sidebar tabs // Handler callbacks for sidebar tabs
const handleAddImage = (imageData) => { const handleAddImage = (imageData) => {
@@ -49,13 +78,19 @@ function App() {
addElement(textData); addElement(textData);
}; };
const handleAddTemplate = (templateId) => { const handleAddTemplate = (template) => {
console.log('Template selected:', templateId); if (template && template.elements) {
// Template loading will be implemented in Phase 6 template.elements.forEach((el, index) => {
setTimeout(() => addElement({ ...el }), index * 50);
});
}
}; };
return ( return (
<div className="editor-layout"> <div className="editor-layout">
{/* PWA Install Prompt */}
<PWAInstall />
{/* Left Sidebar */} {/* Left Sidebar */}
<Sidebar <Sidebar
onAddImage={handleAddImage} onAddImage={handleAddImage}
@@ -75,12 +110,82 @@ function App() {
</p> </p>
</div> </div>
{/* Action buttons */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', justifyContent: 'center' }}>
<button
onClick={() => canUndo && undo()}
disabled={!canUndo}
style={{
padding: '0.5rem 1rem',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
background: canUndo ? 'var(--bg-primary)' : 'var(--bg-tertiary)',
cursor: canUndo ? 'pointer' : 'not-allowed',
opacity: canUndo ? 1 : 0.5,
}}
>
Undo
</button>
<button
onClick={() => canRedo && redo()}
disabled={!canRedo}
style={{
padding: '0.5rem 1rem',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
background: canRedo ? 'var(--bg-primary)' : 'var(--bg-tertiary)',
cursor: canRedo ? 'pointer' : 'not-allowed',
opacity: canRedo ? 1 : 0.5,
}}
>
Redo
</button>
<button
onClick={() => exportDesign(elements, 'tshirt-design')}
disabled={exporting || elements.length === 0}
style={{
padding: '0.5rem 1rem',
border: 'none',
borderRadius: 'var(--radius-md)',
background: elements.length === 0 ? 'var(--bg-tertiary)' : 'var(--success)',
color: elements.length === 0 ? 'var(--text-muted)' : '#fff',
cursor: elements.length === 0 ? 'not-allowed' : 'pointer',
opacity: elements.length === 0 ? 0.5 : 1,
fontWeight: 600,
}}
>
{exporting ? `Exporting... ${progress}%` : '⬇ Export HD'}
</button>
</div>
{/* Export error banner */}
{error && (
<div style={{
padding: '0.75rem',
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: 'var(--radius-md)',
color: '#dc2626',
fontSize: '12px',
marginBottom: '1rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
maxWidth: '400px',
marginLeft: 'auto',
marginRight: 'auto',
}}>
<span> Export failed: {error}</span>
<button onClick={clearExport} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '16px' }}></button>
</div>
)}
<DesignCanvas <DesignCanvas
elements={elements} elements={elements}
selectedId={selectedId} selectedId={selectedId}
onSelect={selectElement} onSelect={selectElement}
onDeselect={deselectAll} onDeselect={deselectAll}
onUpdate={updateElement} onUpdate={(id, attrs) => updateElement(id, attrs)}
/> />
{/* Layers panel below canvas */} {/* Layers panel below canvas */}

View File

@@ -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 (
<div className="pwa-install-banner">
<p>Install Apparel Designer for offline access!</p>
<div className="pwa-install-buttons">
<button onClick={handleInstall} className="install-btn">
Install
</button>
<button onClick={() => setShowInstall(false)} className="dismiss-btn">
Later
</button>
</div>
</div>
);
}

View File

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

View File

@@ -710,6 +710,77 @@ input, textarea, select {
overflow: hidden; 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 */ /* Responsive */
@media (max-width: 900px) { @media (max-width: 900px) {
.app-layout { .app-layout {

View File

@@ -1,16 +1,111 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
// https://vite.dev/config/
export default defineConfig({ 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: { server: {
port: 3000, port: 3000,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3001', target: 'http://localhost:3001',
changeOrigin: true changeOrigin: true,
} },
} '/uploads': {
} target: 'http://localhost:3001',
}) changeOrigin: true,
},
'/exports': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
});

View File

@@ -3,9 +3,10 @@ import cors from 'cors';
import multer from 'multer'; import multer from 'multer';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import sharp from 'sharp'; import sharp from 'sharp';
import { createCanvas, loadImage } from 'canvas';
import { fileURLToPath } from 'module'; import { fileURLToPath } from 'module';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { mkdirSync, existsSync } from 'fs'; import { mkdirSync, existsSync, writeFileSync } from 'fs';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -120,6 +121,86 @@ app.use((err, req, res, next) => {
next(err); 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, () => { app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`); console.log(`Server running on http://localhost:${PORT}`);
console.log(`Health check: http://localhost:${PORT}/api/health`); console.log(`Health check: http://localhost:${PORT}/api/health`);