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/compat": "^1.2.5",
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",
"@internationalized/date": "^3.11.0", "@internationalized/date": "^3.11.0",
"@lucide/svelte": "^0.562.0", "@lucide/svelte": "^1.17.0",
"@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-static": "^3.0.9", "@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.22.0", "@sveltejs/kit": "^2.22.0",
@@ -56,9 +56,14 @@
"vite": "^7.0.4" "vite": "^7.0.4"
}, },
"dependencies": { "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/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2", "@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 { Dialog, DialogContent, DialogOverlay } from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button'; 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 { interface PortfolioImage {
id: string; id: string;
full: string; full: FullURLs;
thumb: string; thumb: ThumbURLs;
tag_names: string[]; tag_names: string[];
created_at: string; created_at: string;
} }
@@ -106,7 +120,7 @@
} }
const data = await response.json(); 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) => { filterCategories = data.sort((a: FilterCategory, b: FilterCategory) => {
const countA = a.values.reduce((sum: number, v: FilterValue) => sum + v.count, 0); 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); const countB = b.values.reduce((sum: number, v: FilterValue) => sum + v.count, 0);
@@ -157,12 +171,22 @@
id: string; id: string;
url: string; url: string;
thumbnail_url: string; thumbnail_url: string;
full: { avif: string; webp: string; jpg: string; jxl?: string };
thumb: { avif: string; webp: string; jpg: string };
tag_names: string[]; tag_names: string[];
created_at: string; created_at: string;
}) => ({ }) => ({
id: img.id, id: img.id,
full: img.url, full: img.full || {
thumb: img.thumbnail_url, 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 || [], tag_names: img.tag_names || [],
created_at: img.created_at created_at: img.created_at
}) })
@@ -192,8 +216,16 @@
const img = await response.json(); const img = await response.json();
return { return {
id: img.id, id: img.id,
full: img.url, full: img.full || {
thumb: img.thumbnail_url, 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 || [], tag_names: img.tag_names || [],
created_at: img.created_at created_at: img.created_at
}; };
@@ -342,7 +374,7 @@
fetchImageById(targetId).then((img) => { fetchImageById(targetId).then((img) => {
if (img) { if (img) {
featuredImage = img; featuredImage = img;
openModal(img.full, img.thumb); openModal(img);
} }
}); });
} }
@@ -365,20 +397,20 @@
let featuredImage = $state<PortfolioImage | null>(null); let featuredImage = $state<PortfolioImage | null>(null);
let showModal = $state(false); let showModal = $state(false);
let selectedImage = $state<string | null>(null); let selectedFullURLs = $state<FullURLs | null>(null);
let selectedThumb = $state<string | null>(null); let selectedThumbURLs = $state<ThumbURLs | null>(null);
let currentIndex = $state(0); let currentIndex = $state(0);
let imageLoading = $state(false); let imageLoading = $state(false);
let nextButtonRef = $state<HTMLButtonElement | undefined>(undefined); let nextButtonRef = $state<HTMLButtonElement | undefined>(undefined);
let prevButtonRef = $state<HTMLButtonElement | undefined>(undefined); let prevButtonRef = $state<HTMLButtonElement | undefined>(undefined);
function openModal(img: string, thumb: string) { function openModal(img: PortfolioImage) {
const idx = images.findIndex((i) => i.full === img); const idx = images.findIndex((i) => i.id === img.id);
if (idx !== -1) { if (idx !== -1) {
openModalByIndex(idx); openModalByIndex(idx);
} else { } else {
selectedImage = img; selectedFullURLs = img.full;
selectedThumb = thumb; selectedThumbURLs = img.thumb;
imageLoading = true; imageLoading = true;
showModal = true; showModal = true;
} }
@@ -386,18 +418,14 @@
function openModalByIndex(index: number) { function openModalByIndex(index: number) {
currentIndex = index; currentIndex = index;
selectedImage = images[index].full; selectedFullURLs = images[index].full;
selectedThumb = images[index].thumb; selectedThumbURLs = images[index].thumb;
imageLoading = true; imageLoading = true;
showModal = true; showModal = true;
// Extract timestamp from URL for shorter sharing URL const imgUrl = images[index].full.avif || images[index].full.jpg;
const imgUrl = images[index].full; const filename = imgUrl.split('/').pop() || '';
const timestamp = const timestamp = filename.replace(/_full|_thumb|\.[^.]+$/g, '') || images[index].id;
imgUrl
.split('/')
.pop()
?.replace(/\.[^.]+$/, '') || images[index].id;
const url = new URL(page.url); const url = new URL(page.url);
url.searchParams.set('img', timestamp); url.searchParams.set('img', timestamp);
@@ -417,16 +445,12 @@
function navigateNext() { function navigateNext() {
if (currentIndex < images.length - 1) { if (currentIndex < images.length - 1) {
currentIndex++; currentIndex++;
selectedImage = images[currentIndex].full; selectedFullURLs = images[currentIndex].full;
selectedThumb = images[currentIndex].thumb; selectedThumbURLs = images[currentIndex].thumb;
imageLoading = true; imageLoading = true;
// Update URL with new image timestamp const imgUrl = images[currentIndex].full.avif || images[currentIndex].full.jpg;
const imgUrl = images[currentIndex].full; const filename = imgUrl.split('/').pop() || '';
const timestamp = const timestamp = filename.replace(/_full|_thumb|\.[^.]+$/g, '') || images[currentIndex].id;
imgUrl
.split('/')
.pop()
?.replace(/\.[^.]+$/, '') || images[currentIndex].id;
const url = new URL(page.url); const url = new URL(page.url);
url.searchParams.set('img', timestamp); url.searchParams.set('img', timestamp);
goto(url.pathname + url.search, { replaceState: true, noScroll: true }); goto(url.pathname + url.search, { replaceState: true, noScroll: true });
@@ -436,16 +460,12 @@
function navigatePrev() { function navigatePrev() {
if (currentIndex > 0) { if (currentIndex > 0) {
currentIndex--; currentIndex--;
selectedImage = images[currentIndex].full; selectedFullURLs = images[currentIndex].full;
selectedThumb = images[currentIndex].thumb; selectedThumbURLs = images[currentIndex].thumb;
imageLoading = true; imageLoading = true;
// Update URL with new image timestamp const imgUrl = images[currentIndex].full.avif || images[currentIndex].full.jpg;
const imgUrl = images[currentIndex].full; const filename = imgUrl.split('/').pop() || '';
const timestamp = const timestamp = filename.replace(/_full|_thumb|\.[^.]+$/g, '') || images[currentIndex].id;
imgUrl
.split('/')
.pop()
?.replace(/\.[^.]+$/, '') || images[currentIndex].id;
const url = new URL(page.url); const url = new URL(page.url);
url.searchParams.set('img', timestamp); url.searchParams.set('img', timestamp);
goto(url.pathname + url.search, { replaceState: true, noScroll: true }); goto(url.pathname + url.search, { replaceState: true, noScroll: true });
@@ -663,15 +683,15 @@
</div> </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"> <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 <button
class="relative aspect-square overflow-hidden rounded-xs transition-opacity hover:opacity-90 active:opacity-75" 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" aria-label="View full size portfolio item"
> >
<img <ImageVariant
loading="lazy" urls={img.thumb}
src={img.thumb} type="thumb"
alt="Portfolio thumbnail {img.id}" alt="Portfolio thumbnail {img.id}"
class="h-full w-full object-cover opacity-0 transition-opacity duration-300" class="h-full w-full object-cover opacity-0 transition-opacity duration-300"
onload={handleImageLoad} 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]" 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"> <div class="relative flex items-center justify-center">
{#if imageLoading} {#if imageLoading && selectedThumbURLs}
<img <ImageVariant
src={selectedThumb ?? ''} urls={selectedThumbURLs}
type="thumb"
alt="Loading preview" 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]" 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> </div>
{/if} {/if}
<img {#if selectedFullURLs}
src={selectedImage ?? ''} <ImageVariant
urls={selectedFullURLs}
type="full"
alt="Portfolio full size" 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 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' ? 'opacity-0'
: ''}" : ''}"
onload={handleFullImageLoad} onload={handleFullImageLoad}
/> />
{/if}
<button <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" 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'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit()] plugins: [tailwindcss(), sveltekit()],
assetsInclude: ['**/*.wasm'],
optimizeDeps: {
exclude: ['@discourse/jxl', '@jsquash/avif', '@jsquash/webp', '@jsquash/jpeg']
}
}); });