use svg instead of puppeteer

This commit is contained in:
chark1es 2025-06-16 12:35:32 -07:00
parent 32848a9a06
commit 7d4e695d30
5 changed files with 627 additions and 12465 deletions

1215
bun.lock

File diff suppressed because it is too large Load diff

11376
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -25,7 +25,6 @@
"@types/highlight.js": "^10.1.0",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.15",
"@types/puppeteer": "^7.0.4",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.1",
"astro": "^5.5.6",
@ -42,7 +41,6 @@
"next": "^15.1.2",
"pocketbase": "^0.25.1",
"prismjs": "^1.29.0",
"puppeteer": "^24.10.1",
"react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0",

View file

@ -1,10 +1,9 @@
import type { APIRoute } from 'astro';
import { initializeEmailServices, authenticatePocketBase, getStatusColor, getStatusText, getNextStepsText } from '../../../scripts/email/EmailHelpers';
// Add function to generate status image URL
function getStatusImageUrl(status: string, baseUrl: string = '', emailOptimized: boolean = true): string {
const emailParam = emailOptimized ? '&email=true' : '';
return `${baseUrl}/api/generate-status-image?status=${status}&width=500&height=150${emailParam}`;
// Add function to generate status image URL (now SVG-based)
function getStatusImageUrl(status: string, baseUrl: string = ''): string {
return `${baseUrl}/api/generate-status-image?status=${status}&width=500&height=150`;
}
export const POST: APIRoute = async ({ request }) => {

View file

@ -1,18 +1,16 @@
import type { APIRoute } from 'astro';
import puppeteer from 'puppeteer';
export const GET: APIRoute = async ({ url }) => {
try {
const searchParams = new URL(url).searchParams;
const status = searchParams.get('status') || 'submitted';
const width = parseInt(searchParams.get('width') || '600');
const height = parseInt(searchParams.get('height') || '200');
const emailOptimized = searchParams.get('email') === 'true'; // New email optimization flag
const width = parseInt(searchParams.get('width') || '500');
const height = parseInt(searchParams.get('height') || '150');
console.log('🎨 Generating status image for:', { status, width, height, emailOptimized });
console.log('🎨 Generating SVG status image for:', { status, width, height });
// Generate status progress bar HTML (email-optimized version for transparent backgrounds)
function generateEmailOptimizedProgressBar(currentStatus: string): string {
// Generate SVG status progress bar
function generateSVGProgressBar(currentStatus: string): string {
const statusOrder = ['submitted', 'under_review', 'approved', 'in_progress', 'paid'];
const rejectedStatus = ['submitted', 'under_review', 'rejected'];
@ -38,429 +36,133 @@ export const GET: APIRoute = async ({ url }) => {
};
const currentIndex = statuses.indexOf(currentStatus);
const circleRadius = 22;
const lineY = height / 2;
const totalWidth = width * 0.8; // Use 80% of width
const startX = width * 0.1; // Start at 10% from left
const stepWidth = totalWidth / (statuses.length - 1);
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* { box-sizing: border-box; }
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: transparent;
width: ${width * 2}px;
height: ${height * 2}px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.progress-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
}
.progress-title {
margin: 0 0 40px 0;
color: #1e293b;
font-size: 28px;
font-weight: 700;
text-align: center;
text-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.progress-container {
position: relative;
width: 100%;
max-width: 800px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
.progress-line {
position: absolute;
top: calc(50% - 14px); /* Align with center of 56px circles (28px from top) */
left: 15%;
right: 15%;
height: 6px;
background: linear-gradient(90deg, #e2e8f0 0%, #cbd5e1 100%);
border-radius: 3px;
z-index: 1;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.status-items {
position: relative;
display: flex;
justify-content: space-between;
width: 80%;
z-index: 2;
align-items: flex-start;
}
.status-item {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.status-circle {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
border: 4px solid white;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
margin-bottom: 12px;
position: relative;
z-index: 3;
}
.status-label {
font-size: 16px;
font-weight: 600;
text-align: center;
line-height: 1.2;
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
</style>
</head>
<body>
<div class="progress-wrapper">
<h3 class="progress-title">Request Progress</h3>
<div class="progress-container">
<div class="progress-line"></div>
<div class="status-items">
${statuses.map((statusName, index) => {
const isActive = index <= currentIndex;
const isCurrent = statusName === currentStatus;
let svgElements = '';
let backgroundColor, textColor;
if (isCurrent) {
if (statusName === 'rejected') {
backgroundColor = '#ef4444';
textColor = 'white';
} else if (statusName === 'paid') {
backgroundColor = '#10b981';
textColor = 'white';
} else if (statusName === 'in_progress') {
backgroundColor = '#f59e0b';
textColor = 'white';
} else {
backgroundColor = '#3b82f6';
textColor = 'white';
}
} else if (isActive) {
backgroundColor = '#e2e8f0';
textColor = '#475569';
} else {
backgroundColor = '#f8fafc';
textColor = '#94a3b8';
}
// Generate background line (behind circles) - decreased height
svgElements += `<line x1="${startX}" y1="${lineY + 1}" x2="${startX + totalWidth}" y2="${lineY + 1}" stroke="#e2e8f0" stroke-width="4" opacity="0.6"/>`;
const labelColor = isCurrent ?
(statusName === 'rejected' ? '#ef4444' :
statusName === 'paid' ? '#10b981' :
statusName === 'in_progress' ? '#f59e0b' : '#3b82f6') :
isActive ? '#475569' : '#94a3b8';
// Generate progress line up to current status
if (currentIndex >= 0) {
const progressEndX = startX + (currentIndex * stepWidth);
let progressColor = '#3b82f6'; // Default blue
return `
<div class="status-item">
<div class="status-circle" style="background: ${backgroundColor}; color: ${textColor};">
${statusIcons[statusName]}
</div>
<div class="status-label" style="color: ${labelColor};">
${statusLabels[statusName]}
</div>
</div>
`;
}).join('')}
</div>
</div>
</div>
</body>
</html>
`;
}
// Set progress color based on current status
if (currentStatus === 'rejected') {
progressColor = '#ef4444';
} else if (currentStatus === 'paid') {
progressColor = '#10b981';
} else if (currentStatus === 'in_progress') {
progressColor = '#f59e0b';
}
// Generate status progress bar HTML (based on the email template)
function generateStatusProgressBarHTML(currentStatus: string): string {
const statusOrder = ['submitted', 'under_review', 'approved', 'in_progress', 'paid'];
const rejectedStatus = ['submitted', 'under_review', 'rejected'];
svgElements += `<line x1="${startX}" y1="${lineY + 1}" x2="${progressEndX}" y2="${lineY + 1}" stroke="${progressColor}" stroke-width="3" opacity="0.9"/>`;
}
const isRejected = currentStatus === 'rejected';
const statuses = isRejected ? rejectedStatus : statusOrder;
const statusIcons: Record<string, string> = {
submitted: '→',
under_review: '?',
approved: '✓',
rejected: '✗',
in_progress: '○',
paid: '$'
};
const statusLabels: Record<string, string> = {
submitted: 'Submitted',
under_review: 'Under Review',
approved: 'Approved',
rejected: 'Rejected',
in_progress: 'In Progress',
paid: 'Paid'
};
const currentIndex = statuses.indexOf(currentStatus);
let progressBarHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
margin: 0;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: transparent;
width: ${(width - 40) * 2}px;
height: ${(height - 40) * 2}px;
display: flex;
align-items: center;
justify-content: center;
}
.progress-container {
background: rgba(248, 250, 252, 0.95);
padding: 60px 40px;
border-radius: 16px;
border: 2px solid rgba(226, 232, 240, 0.8);
width: 100%;
box-sizing: border-box;
backdrop-filter: blur(8px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.progress-title {
margin: 0 0 60px 0;
color: #1e293b;
font-size: 32px;
font-weight: 600;
text-align: center;
}
.progress-table {
width: 100%;
max-width: 1000px;
margin: 0 auto;
border-collapse: collapse;
position: relative;
}
.progress-line {
height: 4px;
background: #e2e8f0;
position: absolute;
top: 42px;
left: 0;
right: 0;
z-index: 1;
}
.progress-row {
position: relative;
z-index: 2;
}
.status-cell {
text-align: center;
padding: 0;
vertical-align: top;
position: relative;
width: ${100/statuses.length}%;
}
.status-content {
position: relative;
z-index: 3;
padding: 10px 0;
}
.status-circle {
width: 64px;
height: 64px;
border-radius: 50%;
text-align: center;
line-height: 64px;
font-size: 32px;
font-weight: bold;
border: 6px solid rgba(248, 250, 252, 0.95);
margin: 0 auto 16px auto;
}
.status-label {
font-size: 22px;
font-weight: 600;
text-align: center;
line-height: 1.2;
white-space: nowrap;
}
.connection-cell {
padding: 0;
vertical-align: top;
position: relative;
width: 40px;
}
.connection-line {
height: 4px;
position: absolute;
top: 42px;
left: 0;
right: 0;
z-index: 2;
}
</style>
</head>
<body>
<div class="progress-container">
<h3 class="progress-title">Request Progress</h3>
<table class="progress-table">
<tr class="progress-line-row">
<td colspan="${statuses.length * 2 - 1}" class="progress-line"></td>
</tr>
<tr class="progress-row">
`;
statuses.forEach((status, index) => {
// Generate status circles and labels
statuses.forEach((statusName, index) => {
const x = startX + (index * stepWidth);
const y = lineY;
const isActive = index <= currentIndex;
const isCurrent = status === currentStatus;
const isCurrent = statusName === currentStatus;
let backgroundColor, textColor, lineColor;
let backgroundColor, textColor;
if (isCurrent) {
if (status === 'rejected') {
if (statusName === 'rejected') {
backgroundColor = '#ef4444';
textColor = 'white';
lineColor = '#ef4444';
} else if (status === 'paid') {
} else if (statusName === 'paid') {
backgroundColor = '#10b981';
textColor = 'white';
lineColor = '#10b981';
} else if (status === 'in_progress') {
} else if (statusName === 'in_progress') {
backgroundColor = '#f59e0b';
textColor = 'white';
lineColor = '#f59e0b';
} else {
backgroundColor = '#3b82f6';
textColor = 'white';
lineColor = '#3b82f6';
}
} else if (isActive) {
backgroundColor = '#e2e8f0';
textColor = '#475569';
lineColor = '#cbd5e1';
} else {
backgroundColor = '#f8fafc';
textColor = '#94a3b8';
lineColor = '#e2e8f0';
}
// Status circle
progressBarHtml += `
<td class="status-cell">
<div class="status-content">
<div class="status-circle" style="
background: ${backgroundColor};
color: ${textColor};
">
${statusIcons[status]}
</div>
<div class="status-label" style="
color: ${isCurrent ? (status === 'rejected' ? '#ef4444' : status === 'paid' ? '#10b981' : status === 'in_progress' ? '#f59e0b' : '#3b82f6') : isActive ? '#475569' : '#94a3b8'};
">
${statusLabels[status]}
</div>
</div>
</td>
`;
const labelColor = isCurrent ?
(statusName === 'rejected' ? '#ef4444' :
statusName === 'paid' ? '#10b981' :
statusName === 'in_progress' ? '#f59e0b' : '#3b82f6') :
isActive ? '#475569' : '#94a3b8';
// Connecting line (except for the last status)
if (index < statuses.length - 1) {
const nextIsActive = (index + 1) <= currentIndex;
const connectionColor = nextIsActive ? lineColor : '#e2e8f0';
// Circle with shadow effect
svgElements += `<circle cx="${x}" cy="${y}" r="${circleRadius}" fill="${backgroundColor}" stroke="white" stroke-width="3" filter="url(#shadow)"/>`;
progressBarHtml += `
<td class="connection-cell">
<div class="connection-line" style="background: ${connectionColor};"></div>
</td>
`;
}
// Icon text - properly centered with dominant-baseline
svgElements += `<text x="${x}" y="${y}" text-anchor="middle" dominant-baseline="central" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="18" font-weight="bold" fill="${textColor}">${statusIcons[statusName]}</text>`;
// Label text
svgElements += `<text x="${x}" y="${y + circleRadius + 18}" text-anchor="middle" dominant-baseline="central" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="11" font-weight="600" fill="${labelColor}">${statusLabels[statusName]}</text>`;
});
progressBarHtml += `
</tr>
</table>
</div>
</body>
</html>
`;
return `
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
text {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
</style>
<!-- Drop shadow filter -->
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.3"/>
</filter>
</defs>
return progressBarHtml;
<!-- Title -->
<text x="${width/2}" y="25" text-anchor="middle" dominant-baseline="central" font-size="16" font-weight="700" fill="#1e293b">Request Progress</text>
${svgElements}
</svg>
`;
}
// Choose which HTML to use based on email optimization flag
const html = emailOptimized ?
generateEmailOptimizedProgressBar(status) :
generateStatusProgressBarHTML(status);
const svg = generateSVGProgressBar(status);
// Launch Puppeteer with high quality settings
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--force-device-scale-factor=2' // Higher DPI for better quality
]
});
console.log('✅ SVG status image generated successfully');
const page = await browser.newPage();
// Set high-resolution viewport for better quality
await page.setViewport({
width: width * 2, // Double resolution for crisp images
height: height * 2,
deviceScaleFactor: 2
});
// Set HTML content
await page.setContent(html, { waitUntil: 'networkidle0' });
// Take high-quality screenshot with transparent background
const screenshot = await page.screenshot({
type: 'png',
fullPage: false,
omitBackground: true, // Transparent background
clip: {
x: 0,
y: 0,
width: width * 2,
height: height * 2
}
});
await browser.close();
console.log('✅ Status image generated successfully');
return new Response(screenshot, {
return new Response(svg, {
headers: {
'Content-Type': 'image/png',
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=3600',
},
});
} catch (error) {
console.error('❌ Error generating status image:', error);
return new Response('Error generating image', { status: 500 });
console.error('❌ Error generating SVG status image:', error);
console.error('Error details:', {
name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
// Return more detailed error information
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
return new Response(
JSON.stringify({
error: 'Failed to generate status image',
details: errorMessage,
timestamp: new Date().toISOString()
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' }
}
);
}
};