Compare commits

...

2 Commits

Author SHA1 Message Date
Khalid A
d42a497ae8 Phase 9: PWA & Workbox Caching (merged) 2026-04-21 01:52:43 -05:00
Khalid A
5164b08c1c 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>
2026-04-21 01:51:43 -05:00
6 changed files with 249 additions and 33 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,6 +3,7 @@ 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'; import { useExport } from './hooks/useExport';
@@ -46,10 +47,7 @@ function App() {
} }
// Redo: Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y // Redo: Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y
if ((e.ctrlKey || e.metaKey) && ( if ((e.ctrlKey || e.metaKey) && ((e.key === 'z' && e.shiftKey) || e.key === 'y')) {
(e.key === 'z' && e.shiftKey) ||
e.key === 'y'
)) {
e.preventDefault(); e.preventDefault();
if (canRedo) redo(); if (canRedo) redo();
return; return;
@@ -91,6 +89,9 @@ function App() {
return ( return (
<div className="editor-layout"> <div className="editor-layout">
{/* PWA Install Prompt */}
<PWAInstall />
{/* Left Sidebar */} {/* Left Sidebar */}
<Sidebar <Sidebar
onAddImage={handleAddImage} onAddImage={handleAddImage}
@@ -110,7 +111,8 @@ function App() {
</p> </p>
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}> {/* Action buttons */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', justifyContent: 'center' }}>
<button <button
onClick={() => canUndo && undo()} onClick={() => canUndo && undo()}
disabled={!canUndo} disabled={!canUndo}
@@ -157,6 +159,7 @@ function App() {
</button> </button>
</div> </div>
{/* Export error banner */}
{error && ( {error && (
<div style={{ <div style={{
padding: '0.75rem', padding: '0.75rem',
@@ -169,6 +172,9 @@ function App() {
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
maxWidth: '400px',
marginLeft: 'auto',
marginRight: 'auto',
}}> }}>
<span> Export failed: {error}</span> <span> Export failed: {error}</span>
<button onClick={clearExport} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '16px' }}></button> <button onClick={clearExport} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '16px' }}></button>

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

@@ -774,6 +774,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,7 +3,7 @@ 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, registerFont } from 'canvas'; 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, writeFileSync } from 'fs'; import { mkdirSync, existsSync, writeFileSync } from 'fs';
@@ -17,7 +17,7 @@ const PORT = process.env.PORT || 3001;
// Ensure upload and export directories exist // Ensure upload and export directories exist
const uploadsDir = join(__dirname, 'uploads'); const uploadsDir = join(__dirname, 'uploads');
const exportsDir = join(__dirname, 'exports'); const exportsDir = join(__dirname, 'exports');
[uploadsDir, exportsDir].forEach(dir => { [uploadsDir, exportsDir].forEach((dir) => {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}); });
@@ -39,7 +39,7 @@ const storage = multer.diskStorage({
const ext = file.originalname.split('.').pop(); const ext = file.originalname.split('.').pop();
const filename = `${uuidv4()}.${ext}`; const filename = `${uuidv4()}.${ext}`;
cb(null, filename); cb(null, filename);
} },
}); });
const fileFilter = (req, file, cb) => { const fileFilter = (req, file, cb) => {
@@ -55,8 +55,8 @@ const upload = multer({
storage, storage,
fileFilter, fileFilter,
limits: { limits: {
fileSize: 20 * 1024 * 1024 // 20MB fileSize: 20 * 1024 * 1024, // 20MB
} },
}); });
// Health check endpoint // Health check endpoint
@@ -96,13 +96,13 @@ app.post('/api/upload', upload.single('image'), async (req, res) => {
url: originalUrl, url: originalUrl,
filename: req.file.filename, filename: req.file.filename,
size: req.file.size, size: req.file.size,
mimetype: req.file.mimetype mimetype: req.file.mimetype,
}, },
preview: { preview: {
path: previewPath, path: previewPath,
url: previewUrl, url: previewUrl,
filename: previewFilename filename: previewFilename,
} },
}); });
} catch (error) { } catch (error) {
console.error('Upload error:', error); console.error('Upload error:', error);
@@ -121,9 +121,8 @@ app.use((err, req, res, next) => {
next(err); next(err);
}); });
// High-resolution export endpoint // High-resolution export endpoint (300x300px -> 4500x4500px @ 300 DPI)
// Canvas: 300x300px preview -> 4500x4500px export (15"x15" @ 300 DPI) const EXPORT_SCALE = 15;
const EXPORT_SCALE = 15; // 300px * 15 = 4500px
const EXPORT_SIZE = 4500; const EXPORT_SIZE = 4500;
app.post('/api/export', async (req, res) => { app.post('/api/export', async (req, res) => {
@@ -134,7 +133,6 @@ app.post('/api/export', async (req, res) => {
return res.status(400).json({ error: 'Elements array is required' }); return res.status(400).json({ error: 'Elements array is required' });
} }
// Create high-resolution canvas
const canvas = createCanvas(EXPORT_SIZE, EXPORT_SIZE); const canvas = createCanvas(EXPORT_SIZE, EXPORT_SIZE);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
@@ -146,7 +144,6 @@ app.post('/api/export', async (req, res) => {
for (const el of elements) { for (const el of elements) {
ctx.save(); ctx.save();
// Transform: translate to element position, rotate, then draw
const x = (el.x || 0) * EXPORT_SCALE; const x = (el.x || 0) * EXPORT_SCALE;
const y = (el.y || 0) * EXPORT_SCALE; const y = (el.y || 0) * EXPORT_SCALE;
const centerX = x + ((el.width || el.fontSize || 100) * EXPORT_SCALE) / 2; const centerX = x + ((el.width || el.fontSize || 100) * EXPORT_SCALE) / 2;
@@ -158,15 +155,12 @@ app.post('/api/export', async (req, res) => {
if (el.type === 'image' && el.src) { if (el.type === 'image' && el.src) {
try { try {
// Load image from URL or local path
const imgUrl = el.src.startsWith('/') const imgUrl = el.src.startsWith('/')
? join(__dirname, el.src.replace('/uploads', 'uploads')) ? join(__dirname, el.src.replace('/uploads', 'uploads'))
: el.src; : el.src;
const img = await loadImage(imgUrl); const img = await loadImage(imgUrl);
const width = (el.width || 100) * EXPORT_SCALE; const width = (el.width || 100) * EXPORT_SCALE;
const height = (el.height || 100) * EXPORT_SCALE; const height = (el.height || 100) * EXPORT_SCALE;
ctx.drawImage(img, x, y, width, height); ctx.drawImage(img, x, y, width, height);
} catch (imgError) { } catch (imgError) {
console.error('Failed to load image for export:', imgError); console.error('Failed to load image for export:', imgError);
@@ -198,8 +192,8 @@ app.post('/api/export', async (req, res) => {
width: EXPORT_SIZE, width: EXPORT_SIZE,
height: EXPORT_SIZE, height: EXPORT_SIZE,
dpi: 300, dpi: 300,
sizeInches: '15x15' sizeInches: '15x15',
} },
}); });
} catch (error) { } catch (error) {
console.error('Export error:', error); console.error('Export error:', error);