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