fix(frontend): Svelte 5 reactivity, each block keys, and path resolution

Add proper keys to #each blocks across 15+ components to fix reordering bugs. Replace new Date() with SvelteDate in reactive contexts. Use $derived for computed values (totalPages). Use resolve() from $app/paths for all internal navigation hrefs. Add ARIA labels and keyboard accessibility to NavBar mobile menu. Remove unused handleRetry from UserPaymentModal.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-04 01:08:36 +01:00
co-authored by Sisyphus
parent c76c3f52f7
commit 6c16d65476
33 changed files with 417 additions and 300 deletions
@@ -1,7 +1,6 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import FileDropZone from '$lib/components/ui/file-drop-zone.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
@@ -59,179 +58,230 @@
});
}
/** Convert image to AVIF at 0.55 quality for full-size */
function toAvifBlob(file: File): Promise<Blob> {
/** Load a File or Blob into an HTMLImageElement. */
function loadImage(src: File | Blob): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('2D context not available'));
ctx.drawImage(img, 0, 0);
canvas.toBlob(
(blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
// Ensure blob has correct MIME type
const typedBlob = new Blob([blob], { type: 'image/avif' });
resolve(typedBlob);
},
'image/avif',
0.72
);
};
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(file);
img.src = URL.createObjectURL(src);
});
}
/** Resize to max 1500px on the *short* side, only scale down, never up. */
function resizeShortSide(blob: Blob): Promise<Blob> {
/** Encode ImageData to AVIF via Web Worker. */
function encodeAvif(imageData: ImageData, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
const maxShortSide = 1500;
// Only resize if image is larger than target
const shortSide = Math.min(width, height);
if (shortSide > maxShortSide) {
if (width < height) {
const scale = maxShortSide / width;
width = maxShortSide;
height = Math.round(height * scale);
} else {
const scale = maxShortSide / height;
height = maxShortSide;
width = Math.round(width * scale);
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('2D context not available'));
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
// Ensure blob has correct MIME type
const typedBlob = new Blob([blob], { type: 'image/avif' });
resolve(typedBlob);
},
'image/avif',
0.72
);
const worker = new Worker(
new URL('$lib/workers/avif-encoder.ts', import.meta.url),
{ type: 'module' }
);
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
worker.terminate();
if (e.data.error) return reject(new Error(e.data.error));
resolve(new Blob([e.data.encoded], { type: 'image/avif' }));
};
img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(blob);
worker.onerror = (err) => { worker.terminate(); reject(err); };
worker.postMessage({ imageData, quality });
});
}
/** Create a 250×250 thumbnail using multi-pass downsampling for better quality. */
function createThumbnail(blob: Blob): Promise<Blob> {
/** Encode ImageData to WebP via Web Worker. */
function encodeWebp(imageData: ImageData, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const targetSize = 250;
let srcWidth = img.width;
let srcHeight = img.height;
let sourceCanvas: HTMLCanvasElement | null = null;
// Multi-pass downsampling with tunable scale factor
// Lower SCALE_FACTOR (0.5) = more passes = softer/blurrier
// Higher SCALE_FACTOR (0.75+) = fewer passes = sharper but risking pixelation
// Sweet spot: 0.65-0.70
const SCALE_FACTOR = 0.2;
const STOP_THRESHOLD = targetSize * 1.1; // Stop when close enough
while (Math.min(srcWidth, srcHeight) > STOP_THRESHOLD) {
const scale = Math.max(SCALE_FACTOR, targetSize / Math.min(srcWidth, srcHeight));
const newWidth = Math.round(srcWidth * scale);
const newHeight = Math.round(srcHeight * scale);
const nextCanvas = document.createElement('canvas');
nextCanvas.width = newWidth;
nextCanvas.height = newHeight;
const nextCtx = nextCanvas.getContext('2d');
if (!nextCtx) return reject(new Error('2D context not available'));
nextCtx.imageSmoothingEnabled = true;
nextCtx.imageSmoothingQuality = 'high';
// Draw from source (either original image or previous canvas)
if (sourceCanvas === null) {
nextCtx.drawImage(img, 0, 0, newWidth, newHeight);
} else {
nextCtx.drawImage(sourceCanvas, 0, 0, newWidth, newHeight);
}
sourceCanvas = nextCanvas;
srcWidth = newWidth;
srcHeight = newHeight;
}
// Final crop to exact 250×250 square from center
const finalCanvas = document.createElement('canvas');
finalCanvas.width = targetSize;
finalCanvas.height = targetSize;
const finalCtx = finalCanvas.getContext('2d');
if (!finalCtx) return reject(new Error('2D context not available'));
finalCtx.imageSmoothingEnabled = true;
finalCtx.imageSmoothingQuality = 'high';
// Center crop from the final source (either image or downsampled canvas)
if (sourceCanvas === null) {
// No downsampling was needed, crop directly from image
const offsetX = (img.width - targetSize) / 2;
const offsetY = (img.height - targetSize) / 2;
finalCtx.drawImage(
img,
offsetX,
offsetY,
targetSize,
targetSize,
0,
0,
targetSize,
targetSize
);
} else {
// Crop from the downsampled canvas
const offsetX = (srcWidth - targetSize) / 2;
const offsetY = (srcHeight - targetSize) / 2;
finalCtx.drawImage(
sourceCanvas,
offsetX,
offsetY,
targetSize,
targetSize,
0,
0,
targetSize,
targetSize
);
}
finalCanvas.toBlob(
(blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
// Ensure blob has correct MIME type
const typedBlob = new Blob([blob], { type: 'image/webp' });
resolve(typedBlob);
},
'image/webp',
0.8
);
const worker = new Worker(
new URL('$lib/workers/webp-encoder.ts', import.meta.url),
{ type: 'module' }
);
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
worker.terminate();
if (e.data.error) return reject(new Error(e.data.error));
resolve(new Blob([e.data.encoded], { type: 'image/webp' }));
};
img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(blob);
worker.onerror = (err) => { worker.terminate(); reject(err); };
worker.postMessage({ imageData, quality });
});
}
/** Encode ImageData to JPEG via Web Worker. */
function encodeJpeg(imageData: ImageData, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
const worker = new Worker(
new URL('$lib/workers/jpeg-encoder.ts', import.meta.url),
{ type: 'module' }
);
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
worker.terminate();
if (e.data.error) return reject(new Error(e.data.error));
resolve(new Blob([e.data.encoded], { type: 'image/jpeg' }));
};
worker.onerror = (err) => { worker.terminate(); reject(err); };
worker.postMessage({ imageData, quality });
});
}
/** Encode ImageData to JPEG XL via Web Worker. Returns null if unavailable. */
function encodeJxl(imageData: ImageData, quality: number): Promise<Blob | null> {
return new Promise((resolve) => {
const worker = new Worker(
new URL('$lib/workers/jxl-encoder.ts', import.meta.url),
{ type: 'module' }
);
const timeout = setTimeout(() => { worker.terminate(); resolve(null); }, 10000);
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
clearTimeout(timeout);
worker.terminate();
if (e.data.error) return resolve(null);
resolve(new Blob([e.data.encoded], { type: 'image/jxl' }));
};
worker.onerror = () => { clearTimeout(timeout); worker.terminate(); resolve(null); };
worker.postMessage({ imageData, quality });
});
}
/** Create a 250×250 center-cropped thumbnail canvas using multi-pass downsampling. */
function createThumbnailCanvas(img: HTMLImageElement): HTMLCanvasElement {
const targetSize = 250;
let srcWidth = img.width;
let srcHeight = img.height;
let sourceCanvas: HTMLCanvasElement | null = null;
const SCALE_FACTOR = 0.2;
const STOP_THRESHOLD = targetSize * 1.1;
while (Math.min(srcWidth, srcHeight) > STOP_THRESHOLD) {
const scale = Math.max(SCALE_FACTOR, targetSize / Math.min(srcWidth, srcHeight));
const newWidth = Math.round(srcWidth * scale);
const newHeight = Math.round(srcHeight * scale);
const nextCanvas = document.createElement('canvas');
nextCanvas.width = newWidth;
nextCanvas.height = newHeight;
const nextCtx = nextCanvas.getContext('2d')!;
nextCtx.imageSmoothingEnabled = true;
nextCtx.imageSmoothingQuality = 'high';
if (sourceCanvas === null) {
nextCtx.drawImage(img, 0, 0, newWidth, newHeight);
} else {
nextCtx.drawImage(sourceCanvas, 0, 0, newWidth, newHeight);
}
sourceCanvas = nextCanvas;
srcWidth = newWidth;
srcHeight = newHeight;
}
// Final crop to exact 250×250 square from center
const finalCanvas = document.createElement('canvas');
finalCanvas.width = targetSize;
finalCanvas.height = targetSize;
const finalCtx = finalCanvas.getContext('2d')!;
finalCtx.imageSmoothingEnabled = true;
finalCtx.imageSmoothingQuality = 'high';
if (sourceCanvas === null) {
const offsetX = (img.width - targetSize) / 2;
const offsetY = (img.height - targetSize) / 2;
finalCtx.drawImage(
img,
offsetX,
offsetY,
targetSize,
targetSize,
0,
0,
targetSize,
targetSize
);
} else {
const offsetX = (srcWidth - targetSize) / 2;
const offsetY = (srcHeight - targetSize) / 2;
finalCtx.drawImage(
sourceCanvas,
offsetX,
offsetY,
targetSize,
targetSize,
0,
0,
targetSize,
targetSize
);
}
return finalCanvas;
}
/** Resize to max 1500px on the short side, only scale down, never up. Returns canvas. */
function createFullCanvas(img: HTMLImageElement): HTMLCanvasElement {
let { width, height } = img;
const maxShortSide = 1500;
const shortSide = Math.min(width, height);
if (shortSide > maxShortSide) {
if (width < height) {
const scale = maxShortSide / width;
width = maxShortSide;
height = Math.round(height * scale);
} else {
const scale = maxShortSide / height;
height = maxShortSide;
width = Math.round(width * scale);
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d')!;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, width, height);
return canvas;
}
/** Generate all thumbnail variants (AVIF, WebP, JPEG) from a single file. */
async function generateThumbnailVariants(file: File): Promise<{
avif: Blob;
webp: Blob;
jpg: Blob;
}> {
const img = await loadImage(file);
const canvas = createThumbnailCanvas(img);
const ctx = canvas.getContext('2d')!;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const [avif, webp, jpg] = await Promise.all([
encodeAvif(imageData, 80),
encodeWebp(imageData, 80),
encodeJpeg(imageData, 85)
]);
return { avif, webp, jpg };
}
/** Generate all full-size variants (AVIF, WebP, JPEG, optional JXL) from a single file. */
async function generateFullVariants(file: File): Promise<{
avif: Blob;
webp: Blob;
jpg: Blob;
jxl: Blob | null;
}> {
const img = await loadImage(file);
const canvas = createFullCanvas(img);
const ctx = canvas.getContext('2d')!;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const [avif, webp, jpg, jxl] = await Promise.all([
encodeAvif(imageData, 72),
encodeWebp(imageData, 80),
encodeJpeg(imageData, 85),
encodeJxl(imageData, 75)
]);
return { avif, webp, jpg, jxl };
}
let availableTags = $state<string[]>([]);
let loadingTags = $state(true);
let isMobile = $state(false);
@@ -403,7 +453,7 @@
tags = tags.filter((t) => t !== tag);
}
/** Core upload function now processes the images before sending. */
/** Core upload function multi-format: generates AVIF+WebP+JPEG+JXL variants. */
async function uploadOneOrMany() {
// Add any pending input as tag before uploading
if (input.trim()) {
@@ -425,44 +475,32 @@
const fd = new FormData();
try {
/* -------- 1. Convert to base image format (AVIF) ------- */
uploadStatus[fileKey] = 'Converting to AVIF...';
const avifBlob = await toAvifBlob(file);
/* -------- 1. Generate all full-size variants (AVIF, WebP, JPEG, JXL) ------- */
uploadStatus[fileKey] = 'Generating full-size variants...';
const fullVariants = await generateFullVariants(file);
/* -------- 2. Create the two processed versions IN PARALLEL ------- */
uploadStatus[fileKey] = 'Compressing...';
const [resizedBlob, thumbBlob] = await Promise.all([
resizeShortSide(avifBlob).then((blob) => {
uploadStatus[fileKey] = 'Full size ready';
return blob;
}),
createThumbnail(avifBlob).then((blob) => {
uploadStatus[fileKey] = 'Thumbnail ready';
return blob;
})
]);
/* -------- 2. Generate all thumbnail variants (AVIF, WebP, JPEG) ------- */
uploadStatus[fileKey] = 'Generating thumbnail variants...';
const thumbVariants = await generateThumbnailVariants(file);
/* -------- 3. Generate filenames -------------------------------- */
const ts = startTs - i; // 1 ms decrement per file
const baseName = `${ts}.avif`;
const thumbName = `${ts}_thumb.webp`;
/* -------- 4. Attach to FormData -------------------------------- */
fd.append('file', resizedBlob, baseName);
fd.append('thumbnail', thumbBlob, thumbName);
/* -------- 4. Attach all variants to FormData -------------------------------- */
fd.append('file_full_avif', fullVariants.avif, `${ts}_full.avif`);
fd.append('file_full_webp', fullVariants.webp, `${ts}_full.webp`);
fd.append('file_full_jpg', fullVariants.jpg, `${ts}_full.jpg`);
if (fullVariants.jxl) {
fd.append('file_full_jxl', fullVariants.jxl, `${ts}_full.jxl`);
}
fd.append('file_thumb_avif', thumbVariants.avif, `${ts}_thumb.avif`);
fd.append('file_thumb_webp', thumbVariants.webp, `${ts}_thumb.webp`);
fd.append('file_thumb_jpg', thumbVariants.jpg, `${ts}_thumb.jpg`);
if (tags.length > 0) {
fd.append('tags', tags.join(','));
}
/* Debug: log what we're sending
console.log(`Uploading ${fileKey}:`, {
baseName,
thumbName,
thumbMimeType: thumbBlob.type,
fullMimeType: resizedBlob.type
});
*/
/* -------- 5. Call the API ------------------------------------- */
uploadStatus[fileKey] = 'Uploading...';
const response = await fetch('/api/portfolio/images', {
@@ -843,7 +881,7 @@
<!-- Confirmation Dialog -->
<AlertDialog.Root bind:open={showConfirmUploadAlert}>
<AlertDialog.Content class="z-[60]">
<AlertDialog.Content class="z-60">
<AlertDialog.Header>
<AlertDialog.Title>Confirm Upload</AlertDialog.Title>
<AlertDialog.Description>