Files
apparel-designer/server.js

282 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import express from 'express';
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 'url';
import { dirname, join } from 'path';
import { mkdirSync, existsSync, writeFileSync } from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3001;
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
// Ensure upload and export directories exist
const uploadsDir = join(__dirname, 'uploads');
const exportsDir = join(__dirname, 'exports');
[uploadsDir, exportsDir].forEach((dir) => {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
});
// Middleware — restrict CORS in production
const corsOptions = IS_PRODUCTION
? { origin: process.env.CORS_ORIGIN || false }
: { origin: true };
app.use(cors(corsOptions));
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// Serve static files for uploads and exports
app.use('/uploads', express.static(uploadsDir));
app.use('/exports', express.static(exportsDir));
// In production, serve the Vite-built client
if (IS_PRODUCTION) {
const clientDist = join(__dirname, 'dist');
app.use(express.static(clientDist));
} else {
// Dev UX: backend doesn't serve the SPA; Vite does.
app.get('/', (_req, res) => {
res
.status(302)
.set('Location', 'http://localhost:5173/')
.send('Redirecting to Vite dev server...');
});
}
// Map MIME types to file extensions
const MIME_TO_EXT = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
};
// Configure multer for image uploads
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
cb(null, uploadsDir);
},
filename: (_req, file, cb) => {
const ext = MIME_TO_EXT[file.mimetype] || 'bin';
cb(null, `${uuidv4()}.${ext}`);
},
});
const fileFilter = (_req, file, cb) => {
if (MIME_TO_EXT[file.mimetype]) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only JPEG, PNG, and WebP are allowed.'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: { fileSize: 20 * 1024 * 1024 },
});
// ── API Routes ──────────────────────────────────────────────────────────────
// Health check
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Upload
app.post('/api/upload', upload.single('image'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const originalUrl = `/uploads/${req.file.filename}`;
// Create preview by resizing to max 1000px
const previewFilename = `${uuidv4()}.png`;
const previewDir = join(uploadsDir, 'preview');
if (!existsSync(previewDir)) mkdirSync(previewDir, { recursive: true });
await sharp(req.file.path)
.resize({ width: 1000, height: 1000, fit: 'inside' })
.png()
.toFile(join(previewDir, previewFilename));
res.json({
success: true,
original: {
url: originalUrl,
filename: req.file.filename,
size: req.file.size,
mimetype: req.file.mimetype,
},
preview: {
url: `/uploads/preview/${previewFilename}`,
filename: previewFilename,
},
});
} catch (error) {
console.error('Upload error:', error);
res.status(500).json({ error: 'Failed to process upload', details: error.message });
}
});
// High-resolution export (300×300 → 4500×4500 @ 300 DPI)
const EXPORT_SCALE = 15;
const EXPORT_SIZE = 4500;
app.post('/api/export', async (req, res) => {
try {
const { elements, designName = 'design', template } = 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');
// Template background
if (template?.background) {
const bg = template.background;
if (bg.type === 'color') {
ctx.fillStyle = bg.color;
ctx.fillRect(0, 0, EXPORT_SIZE, EXPORT_SIZE);
} else if (bg.type === 'image' && bg.src) {
try {
const imgUrl = bg.src.startsWith('/')
? join(__dirname, bg.src.replace('/uploads', 'uploads'))
: bg.src;
const img = await loadImage(imgUrl);
ctx.drawImage(img, 0, 0, EXPORT_SIZE, EXPORT_SIZE);
} catch (imgError) {
console.error('Failed to load template background:', imgError);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, EXPORT_SIZE, EXPORT_SIZE);
}
}
} else {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, EXPORT_SIZE, EXPORT_SIZE);
}
// Render a single element
const renderElement = async (el) => {
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;
if (el.crop) {
ctx.drawImage(img, el.crop.sx, el.crop.sy, el.crop.sWidth, el.crop.sHeight, x, y, width, height);
} else {
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();
};
// Render user elements
for (const el of elements) {
if (el.nonPrintable) continue;
await renderElement(el);
}
// Render template overlay
if (template?.overlay) {
for (const overlayEl of template.overlay) {
if (overlayEl.nonPrintable) continue;
await renderElement(overlayEl);
}
}
// Save to file
const exportFilename = `${designName.replace(/[^a-z0-9]/gi, '_')}_${uuidv4()}.png`;
const exportPath = join(exportsDir, exportFilename);
writeFileSync(exportPath, canvas.toBuffer('image/png'));
res.json({
success: true,
export: {
url: `/exports/${exportFilename}`,
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 });
}
});
// Download
app.get('/api/download/:filename', (req, res) => {
const filePath = join(exportsDir, req.params.filename);
if (!existsSync(filePath)) {
return res.status(404).json({ error: 'File not found' });
}
res.download(filePath);
});
// API 404 catch-all (before SPA catch-all)
app.all('/api/*', (_req, res) => {
res.status(404).json({ error: 'API route not found' });
});
// In production, SPA catch-all
if (IS_PRODUCTION) {
app.get('*', (_req, res) => {
res.sendFile(join(__dirname, 'dist', 'index.html'));
});
}
// Error handling
app.use((err, _req, res, _next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 20MB.' });
}
return res.status(400).json({ error: err.message });
}
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error', details: err.message });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
console.log(`Mode: ${IS_PRODUCTION ? 'production' : 'development'}`);
});