feat: add WebP migration UI and API endpoint
- Add /api/admin/migrate-images endpoint to convert JPEG/PNG to WebP - Add WebPMigration component in admin gallery showing stats - Upload endpoint already converts new images to WebP - Migration saves ~30-50% storage space per image
This commit is contained in:
@@ -145,6 +145,61 @@ function Empty({ icon, title, desc }: any) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── WebP Migration ─── */
|
||||
function WebPMigration({ onToast, onRefresh }: { onToast: (msg: string, type: string) => void; onRefresh: () => void }) {
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
const [stats, setStats] = useState<{ totalImages: number; totalSizeMB: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/migrate-images')
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.then(data => data && setStats({ totalImages: data.totalImages, totalSizeMB: data.totalSizeMB }))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const runMigration = async () => {
|
||||
setMigrating(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/migrate-images', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
onToast(`Converted ${data.converted} images, saved ${data.totalSavedMB} MB`, 'success');
|
||||
onRefresh();
|
||||
} else {
|
||||
onToast(data.error || 'Migration failed', 'error');
|
||||
}
|
||||
} catch {
|
||||
onToast('Migration failed', 'error');
|
||||
}
|
||||
setMigrating(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: '16px',
|
||||
padding: '12px 16px',
|
||||
background: '#fff',
|
||||
borderRadius: '12px',
|
||||
border: `1px solid ${C.border}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: C.navy, fontSize: '14px' }}>🔄 WebP Migration</div>
|
||||
<div style={{ fontSize: '12px', color: C.textLight }}>
|
||||
{stats ? `${stats.totalImages} images · ${stats.totalSizeMB} MB total` : 'Checking...'}
|
||||
</div>
|
||||
</div>
|
||||
<Btn onClick={runMigration} disabled={migrating} size="sm">
|
||||
{migrating ? 'Migrating...' : 'Convert to WebP'}
|
||||
</Btn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SecHead({ title, count, action }: any) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px', flexWrap: 'wrap', gap: '10px' }}>
|
||||
@@ -2462,6 +2517,7 @@ export default function AdminPage() {
|
||||
case 'gallery': return (
|
||||
<div>
|
||||
<SecHead title="🖼️ Image Gallery" count={images.length} />
|
||||
<WebPMigration onToast={showToast} onRefresh={loadImages} />
|
||||
<ImageGallery images={images} onDelete={deleteImage} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import sharp from 'sharp';
|
||||
|
||||
export const maxDuration = 300; // 5 minutes for large migrations
|
||||
|
||||
async function convertToWebP(base64Data: string): Promise<string> {
|
||||
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
return base64Data;
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(matches[2], 'base64');
|
||||
const webpBuffer = await sharp(buffer)
|
||||
.webp({ quality: 90 })
|
||||
.toBuffer();
|
||||
|
||||
return `data:image/webp;base64,${webpBuffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
|
||||
console.log('Starting WebP migration...');
|
||||
|
||||
const { rows: images } = await db.query(
|
||||
'SELECT id, filename, data, thumbnail, medium FROM images'
|
||||
);
|
||||
|
||||
console.log(`Found ${images.length} images to process`);
|
||||
|
||||
let converted = 0;
|
||||
let skipped = 0;
|
||||
let errors: number[] = [];
|
||||
let totalSavedKB = 0;
|
||||
|
||||
for (const img of images) {
|
||||
try {
|
||||
let needsUpdate = false;
|
||||
const updates: { data?: string; thumbnail?: string; medium?: string } = {};
|
||||
|
||||
if (img.data && !img.data.includes('image/webp')) {
|
||||
console.log(`Converting image ${img.id}: ${img.filename} (full)`);
|
||||
updates.data = await convertToWebP(img.data);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (img.thumbnail && !img.thumbnail.includes('image/webp')) {
|
||||
console.log(`Converting image ${img.id}: ${img.filename} (thumbnail)`);
|
||||
updates.thumbnail = await convertToWebP(img.thumbnail);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (img.medium && !img.medium.includes('image/webp')) {
|
||||
console.log(`Converting image ${img.id}: ${img.filename} (medium)`);
|
||||
updates.medium = await convertToWebP(img.medium);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
const oldSize = (img.data?.length || 0) + (img.thumbnail?.length || 0) + (img.medium?.length || 0);
|
||||
const newSize = (updates.data?.length || img.data?.length || 0) +
|
||||
(updates.thumbnail?.length || img.thumbnail?.length || 0) +
|
||||
(updates.medium?.length || img.medium?.length || 0);
|
||||
const savedKB = Math.round((oldSize - newSize) / 1024);
|
||||
totalSavedKB += savedKB;
|
||||
|
||||
await db.query(
|
||||
'UPDATE images SET data = $1, thumbnail = $2, medium = $3 WHERE id = $4',
|
||||
[updates.data || img.data, updates.thumbnail || img.thumbnail, updates.medium || img.medium, img.id]
|
||||
);
|
||||
converted++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error processing image ${img.id}:`, err);
|
||||
errors.push(img.id);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total: images.length,
|
||||
converted,
|
||||
skipped,
|
||||
errors,
|
||||
totalSavedMB: Math.round(totalSavedKB / 1024 * 10) / 10,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Migration error:', error);
|
||||
if ((error as Error).message === 'Unauthorized') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Migration failed', details: (error as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
|
||||
const { rows: images } = await db.query(
|
||||
'SELECT id, filename, LENGTH(data) as data_size, LENGTH(thumbnail) as thumb_size, LENGTH(medium) as medium_size FROM images'
|
||||
);
|
||||
|
||||
let totalSize = 0;
|
||||
let webpCount = 0;
|
||||
let jpegCount = 0;
|
||||
|
||||
for (const img of images) {
|
||||
totalSize += (img.data_size || 0) + (img.thumb_size || 0) + (img.medium_size || 0);
|
||||
if (img.data_size) {
|
||||
// We'd need to fetch the actual data to check format, so estimate based on size
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
totalImages: images.length,
|
||||
totalSizeKB: Math.round(totalSize / 1024),
|
||||
totalSizeMB: Math.round(totalSize / 1024 / 1024 * 10) / 10,
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as Error).message === 'Unauthorized') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to get stats' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user