diff --git a/frontend/package.json b/frontend/package.json index b6ab2c1..5c04762 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/lib/components/ui/ImageVariant.svelte b/frontend/src/lib/components/ui/ImageVariant.svelte new file mode 100644 index 0000000..0ab7abb --- /dev/null +++ b/frontend/src/lib/components/ui/ImageVariant.svelte @@ -0,0 +1,41 @@ + + + + {#if urls.avif} + + {/if} + {#if urls.webp} + + {/if} + {#if urls.jpg} + + {/if} + {#if hasJxl} + + {/if} + + diff --git a/frontend/src/lib/workers/avif-encoder.ts b/frontend/src/lib/workers/avif-encoder.ts new file mode 100644 index 0000000..4389183 --- /dev/null +++ b/frontend/src/lib/workers/avif-encoder.ts @@ -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' }); + } +}; diff --git a/frontend/src/lib/workers/jpeg-encoder.ts b/frontend/src/lib/workers/jpeg-encoder.ts new file mode 100644 index 0000000..bbf4917 --- /dev/null +++ b/frontend/src/lib/workers/jpeg-encoder.ts @@ -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' }); + } +}; diff --git a/frontend/src/lib/workers/jxl-encoder.ts b/frontend/src/lib/workers/jxl-encoder.ts new file mode 100644 index 0000000..64299a1 --- /dev/null +++ b/frontend/src/lib/workers/jxl-encoder.ts @@ -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' }); + } +}; diff --git a/frontend/src/lib/workers/webp-encoder.ts b/frontend/src/lib/workers/webp-encoder.ts new file mode 100644 index 0000000..14a0fe2 --- /dev/null +++ b/frontend/src/lib/workers/webp-encoder.ts @@ -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' }); + } +}; diff --git a/frontend/src/routes/portfolio/+page.svelte b/frontend/src/routes/portfolio/+page.svelte index cfa4a53..a34748d 100644 --- a/frontend/src/routes/portfolio/+page.svelte +++ b/frontend/src/routes/portfolio/+page.svelte @@ -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(null); let showModal = $state(false); - let selectedImage = $state(null); - let selectedThumb = $state(null); + let selectedFullURLs = $state(null); + let selectedThumbURLs = $state(null); let currentIndex = $state(0); let imageLoading = $state(false); let nextButtonRef = $state(undefined); let prevButtonRef = $state(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 @@
- {#each images as img (img.full)} + {#each images as img (img.id)}