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:
@@ -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>
|
||||
@@ -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' });
|
||||
}
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user