feat(frontend): ImageVariant component and WASM encoder workers

Add client-side multi-format image encoding via Web Workers using @jsquash and @discourse/jxl libraries. ImageVariant Svelte component renders <picture> elements with format-aware fallback. Vite configured for WASM asset inclusion and encoder dependency exclusion. Portfolio page updated to use multi-format URLs.

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:07:00 +01:00
co-authored by Sisyphus
parent 0da7498ad7
commit 584c02ac6c
16 changed files with 178 additions and 60 deletions
@@ -0,0 +1,41 @@
<script lang="ts">
interface ThumbURLs {
avif: string;
webp: string;
jpg: string;
}
interface FullURLs {
avif: string;
webp: string;
jpg: string;
jxl?: string;
}
let { urls, type = "thumb", alt = "", class: className = "", ...imgProps }: {
urls: ThumbURLs | FullURLs;
type?: "thumb" | "full";
alt?: string;
class?: string;
[key: string]: unknown;
} = $props();
const fallbackSrc = $derived(urls.jpg || urls.webp || urls.avif || "");
const hasJxl = $derived(type === "full" && "jxl" in urls && (urls as FullURLs).jxl);
</script>
<picture>
{#if urls.avif}
<source srcset={urls.avif} type="image/avif" />
{/if}
{#if urls.webp}
<source srcset={urls.webp} type="image/webp" />
{/if}
{#if urls.jpg}
<source srcset={urls.jpg} type="image/jpeg" />
{/if}
{#if hasJxl}
<source srcset={(urls as FullURLs).jxl} type="image/jxl" />
{/if}
<img src={fallbackSrc} {alt} class={className} {...imgProps} />
</picture>
+11
View File
@@ -0,0 +1,11 @@
import { init, default as encode } from '@jsquash/avif/encode.js';
self.onmessage = async (e: MessageEvent<{ imageData: ImageData; quality: number }>) => {
try {
await init({ locateFile: (path: string) => `/${path}` });
const encoded = await encode(e.data.imageData, { quality: e.data.quality });
self.postMessage({ encoded, format: 'avif' }, { transfer: [encoded as Transferable] });
} catch (err) {
self.postMessage({ error: (err as Error).message, format: 'avif' });
}
};
+11
View File
@@ -0,0 +1,11 @@
import { init, default as encode } from '@jsquash/jpeg/encode.js';
self.onmessage = async (e: MessageEvent<{ imageData: ImageData; quality: number }>) => {
try {
await init({ locateFile: (path: string) => `/${path}` });
const encoded = await encode(e.data.imageData, { quality: e.data.quality });
self.postMessage({ encoded, format: 'jpg' }, { transfer: [encoded as Transferable] });
} catch (err) {
self.postMessage({ error: (err as Error).message, format: 'jpg' });
}
};
+11
View File
@@ -0,0 +1,11 @@
import { init, default as encode } from '@discourse/jxl/encode.js';
self.onmessage = async (e: MessageEvent<{ imageData: ImageData; quality: number }>) => {
try {
await init({ locateFile: (path: string) => `/${path}` });
const encoded = await encode(e.data.imageData, { effort: 4, quality: e.data.quality });
self.postMessage({ encoded, format: 'jxl' }, { transfer: [encoded as Transferable] });
} catch (err) {
self.postMessage({ error: (err as Error).message, format: 'jxl' });
}
};
+11
View File
@@ -0,0 +1,11 @@
import { init, default as encode } from '@jsquash/webp/encode.js';
self.onmessage = async (e: MessageEvent<{ imageData: ImageData; quality: number }>) => {
try {
await init({ locateFile: (path: string) => `/${path}` });
const encoded = await encode(e.data.imageData, { quality: e.data.quality });
self.postMessage({ encoded, format: 'webp' }, { transfer: [encoded as Transferable] });
} catch (err) {
self.postMessage({ error: (err as Error).message, format: 'webp' });
}
};
+81 -57
View File
@@ -5,11 +5,25 @@
import { Dialog, DialogContent, DialogOverlay } from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import ImageVariant from '$lib/components/ui/ImageVariant.svelte';
interface ThumbURLs {
avif: string;
webp: string;
jpg: string;
}
interface FullURLs {
avif: string;
webp: string;
jpg: string;
jxl?: string;
}
interface PortfolioImage {
id: string;
full: string;
thumb: string;
full: FullURLs;
thumb: ThumbURLs;
tag_names: string[];
created_at: string;
}
@@ -106,7 +120,7 @@
}
const data = await response.json();
// Sort by total count (most images first)
if (!data || !Array.isArray(data)) return;
filterCategories = data.sort((a: FilterCategory, b: FilterCategory) => {
const countA = a.values.reduce((sum: number, v: FilterValue) => sum + v.count, 0);
const countB = b.values.reduce((sum: number, v: FilterValue) => sum + v.count, 0);
@@ -157,12 +171,22 @@
id: string;
url: string;
thumbnail_url: string;
full: { avif: string; webp: string; jpg: string; jxl?: string };
thumb: { avif: string; webp: string; jpg: string };
tag_names: string[];
created_at: string;
}) => ({
id: img.id,
full: img.url,
thumb: img.thumbnail_url,
full: img.full || {
avif: img.url,
webp: img.url,
jpg: img.url
},
thumb: img.thumb || {
avif: img.thumbnail_url,
webp: img.thumbnail_url,
jpg: img.thumbnail_url
},
tag_names: img.tag_names || [],
created_at: img.created_at
})
@@ -192,8 +216,16 @@
const img = await response.json();
return {
id: img.id,
full: img.url,
thumb: img.thumbnail_url,
full: img.full || {
avif: img.url,
webp: img.url,
jpg: img.url
},
thumb: img.thumb || {
avif: img.thumbnail_url,
webp: img.thumbnail_url,
jpg: img.thumbnail_url
},
tag_names: img.tag_names || [],
created_at: img.created_at
};
@@ -342,7 +374,7 @@
fetchImageById(targetId).then((img) => {
if (img) {
featuredImage = img;
openModal(img.full, img.thumb);
openModal(img);
}
});
}
@@ -365,20 +397,20 @@
let featuredImage = $state<PortfolioImage | null>(null);
let showModal = $state(false);
let selectedImage = $state<string | null>(null);
let selectedThumb = $state<string | null>(null);
let selectedFullURLs = $state<FullURLs | null>(null);
let selectedThumbURLs = $state<ThumbURLs | null>(null);
let currentIndex = $state(0);
let imageLoading = $state(false);
let nextButtonRef = $state<HTMLButtonElement | undefined>(undefined);
let prevButtonRef = $state<HTMLButtonElement | undefined>(undefined);
function openModal(img: string, thumb: string) {
const idx = images.findIndex((i) => i.full === img);
function openModal(img: PortfolioImage) {
const idx = images.findIndex((i) => i.id === img.id);
if (idx !== -1) {
openModalByIndex(idx);
} else {
selectedImage = img;
selectedThumb = thumb;
selectedFullURLs = img.full;
selectedThumbURLs = img.thumb;
imageLoading = true;
showModal = true;
}
@@ -386,18 +418,14 @@
function openModalByIndex(index: number) {
currentIndex = index;
selectedImage = images[index].full;
selectedThumb = images[index].thumb;
selectedFullURLs = images[index].full;
selectedThumbURLs = images[index].thumb;
imageLoading = true;
showModal = true;
// Extract timestamp from URL for shorter sharing URL
const imgUrl = images[index].full;
const timestamp =
imgUrl
.split('/')
.pop()
?.replace(/\.[^.]+$/, '') || images[index].id;
const imgUrl = images[index].full.avif || images[index].full.jpg;
const filename = imgUrl.split('/').pop() || '';
const timestamp = filename.replace(/_full|_thumb|\.[^.]+$/g, '') || images[index].id;
const url = new URL(page.url);
url.searchParams.set('img', timestamp);
@@ -417,16 +445,12 @@
function navigateNext() {
if (currentIndex < images.length - 1) {
currentIndex++;
selectedImage = images[currentIndex].full;
selectedThumb = images[currentIndex].thumb;
selectedFullURLs = images[currentIndex].full;
selectedThumbURLs = images[currentIndex].thumb;
imageLoading = true;
// Update URL with new image timestamp
const imgUrl = images[currentIndex].full;
const timestamp =
imgUrl
.split('/')
.pop()
?.replace(/\.[^.]+$/, '') || images[currentIndex].id;
const imgUrl = images[currentIndex].full.avif || images[currentIndex].full.jpg;
const filename = imgUrl.split('/').pop() || '';
const timestamp = filename.replace(/_full|_thumb|\.[^.]+$/g, '') || images[currentIndex].id;
const url = new URL(page.url);
url.searchParams.set('img', timestamp);
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
@@ -436,16 +460,12 @@
function navigatePrev() {
if (currentIndex > 0) {
currentIndex--;
selectedImage = images[currentIndex].full;
selectedThumb = images[currentIndex].thumb;
selectedFullURLs = images[currentIndex].full;
selectedThumbURLs = images[currentIndex].thumb;
imageLoading = true;
// Update URL with new image timestamp
const imgUrl = images[currentIndex].full;
const timestamp =
imgUrl
.split('/')
.pop()
?.replace(/\.[^.]+$/, '') || images[currentIndex].id;
const imgUrl = images[currentIndex].full.avif || images[currentIndex].full.jpg;
const filename = imgUrl.split('/').pop() || '';
const timestamp = filename.replace(/_full|_thumb|\.[^.]+$/g, '') || images[currentIndex].id;
const url = new URL(page.url);
url.searchParams.set('img', timestamp);
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
@@ -663,15 +683,15 @@
</div>
<div class="grid grid-cols-3 gap-1 p-2 pt-0 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8">
{#each images as img (img.full)}
{#each images as img (img.id)}
<button
class="relative aspect-square overflow-hidden rounded-xs transition-opacity hover:opacity-90 active:opacity-75"
onclick={() => openModal(img.full, img.thumb)}
onclick={() => openModal(img)}
aria-label="View full size portfolio item"
>
<img
loading="lazy"
src={img.thumb}
<ImageVariant
urls={img.thumb}
type="thumb"
alt="Portfolio thumbnail {img.id}"
class="h-full w-full object-cover opacity-0 transition-opacity duration-300"
onload={handleImageLoad}
@@ -702,9 +722,10 @@
class="fixed top-1/2 left-1/2 z-50 max-h-[95vh] max-w-[95vw] -translate-x-1/2 -translate-y-1/2 border-0 bg-transparent p-0 shadow-none focus:outline-none sm:max-h-[90vh] sm:max-w-[90vw]"
>
<div class="relative flex items-center justify-center">
{#if imageLoading}
<img
src={selectedThumb ?? ''}
{#if imageLoading && selectedThumbURLs}
<ImageVariant
urls={selectedThumbURLs}
type="thumb"
alt="Loading preview"
class="absolute max-h-[95vh] max-w-[95vw] scale-110 rounded-sm object-contain blur-xl sm:max-h-[90vh] sm:max-w-[90vw]"
/>
@@ -715,14 +736,17 @@
</div>
{/if}
<img
src={selectedImage ?? ''}
alt="Portfolio full size"
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain transition-opacity duration-300 sm:max-h-[90vh] sm:max-w-[90vw] {imageLoading
? 'opacity-0'
: ''}"
onload={handleFullImageLoad}
/>
{#if selectedFullURLs}
<ImageVariant
urls={selectedFullURLs}
type="full"
alt="Portfolio full size"
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain transition-opacity duration-300 sm:max-h-[90vh] sm:max-w-[90vw] {imageLoading
? 'opacity-0'
: ''}"
onload={handleFullImageLoad}
/>
{/if}
<button
class="absolute top-2 right-2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70 sm:top-4 sm:right-4"