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
+7 -2
View File
@@ -21,7 +21,7 @@
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.22.0",
"@internationalized/date": "^3.11.0",
"@lucide/svelte": "^0.562.0",
"@lucide/svelte": "^1.17.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.22.0",
@@ -56,9 +56,14 @@
"vite": "^7.0.4"
},
"dependencies": {
"@discourse/jxl": "^1.3.0",
"@jsquash/avif": "^2.1.1",
"@jsquash/jpeg": "^1.6.0",
"@jsquash/webp": "^1.5.0",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
"cropperjs": "^1.6.2"
"cropperjs": "^1.6.2",
"maplibre-gl": "^5.24.0"
}
}
@@ -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' });
}
};
+75 -51
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 ?? ''}
{#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"
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+5 -1
View File
@@ -3,5 +3,9 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
plugins: [tailwindcss(), sveltekit()],
assetsInclude: ['**/*.wasm'],
optimizeDeps: {
exclude: ['@discourse/jxl', '@jsquash/avif', '@jsquash/webp', '@jsquash/jpeg']
}
});