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:
@@ -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>
|
||||
@@ -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 ?? ''}
|
||||
{#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"
|
||||
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -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']
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user