Files
Crussell/frontend/src/routes/portfolio/+page.svelte
T

1036 lines
30 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
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: FullURLs;
thumb: ThumbURLs;
tag_names: string[];
created_at: string;
}
interface FilterCategory {
category: string;
values: FilterValue[];
}
interface FilterValue {
value: string;
count: number;
}
let images = $state<PortfolioImage[]>([]);
let loading = $state(true);
let loadingMore = $state(false);
let error = $state(false);
let selectedTag = $state('');
let selectedTags = $state<string[]>([]);
let hasMore = $state(true);
let cursor = $state('');
const limit = 20;
let searchQuery = $state('');
let filterCategories = $state<FilterCategory[]>([]);
let selectedFilters = $state<Record<string, string>>({});
let openDropdowns = $state<Record<string, boolean>>({});
let dropdownPosition = $state<{ x: number; y: number } | null>(null);
const DROPDOWN_WIDTH = 192; // w-48 = 12rem = 192px
function toggleDropdown(category: string, event: MouseEvent) {
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
if (!openDropdowns[category]) {
// Clamp dropdown position to stay within viewport
let x = rect.left;
const maxX = window.innerWidth - DROPDOWN_WIDTH;
if (x > maxX) {
x = maxX;
}
if (x < 0) {
x = 0;
}
dropdownPosition = { x, y: rect.bottom };
openDropdowns = { [category]: true };
} else {
openDropdowns = {};
dropdownPosition = null;
}
}
function closeAllDropdowns() {
openDropdowns = {};
dropdownPosition = null;
}
function handleWindowClick(e: MouseEvent) {
const target = e.target as HTMLElement;
// Don't close if clicking anywhere inside a filter dropdown (button or its children)
if (target.closest('.filter-dropdown')) {
return;
}
// Close all dropdowns when clicking outside
closeAllDropdowns();
}
function buildFilterUrl(): string {
const parts: string[] = [];
if (selectedTags.length > 0) {
parts.push(`tags=${encodeURIComponent(selectedTags.join(','))}`);
} else if (selectedTag) {
parts.push(`tag=${encodeURIComponent(selectedTag)}`);
}
for (const [category, value] of Object.entries(selectedFilters)) {
if (value) {
parts.push(`filter[${encodeURIComponent(category)}]=${encodeURIComponent(value)}`);
}
}
return parts.length > 0 ? '?' + parts.join('&') : '';
}
async function fetchFilters() {
try {
const params = buildFilterUrl();
const url = params ? `/api/portfolio/filters${params}` : '/api/portfolio/filters';
const response = await fetch(url);
if (!response.ok) {
console.error('Filter fetch failed:', response.status, response.statusText);
return;
}
const data = await response.json();
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);
return countB - countA;
});
} catch (e) {
console.error('Failed to fetch filters:', e);
}
}
function buildImageUrl(): string {
const parts: string[] = [`limit=${limit}`];
if (cursor) {
parts.push(`cursor=${encodeURIComponent(cursor)}`);
}
if (selectedTags.length > 0) {
parts.push(`tags=${encodeURIComponent(selectedTags.join(','))}`);
} else if (selectedTag) {
parts.push(`tag=${encodeURIComponent(selectedTag)}`);
}
for (const [category, value] of Object.entries(selectedFilters)) {
if (value) {
parts.push(`filter[${encodeURIComponent(category)}]=${encodeURIComponent(value)}`);
}
}
return '?' + parts.join('&');
}
async function fetchImages(append = false) {
if (append) {
loadingMore = true;
} else {
loading = true;
error = false;
}
try {
const url = '/api/portfolio/images' + buildImageUrl();
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
const data = await response.json();
const imagesList = data.images ?? data;
const newImages = imagesList.map(
(img: {
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.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
})
);
if (append) {
images = [...images, ...newImages];
} else {
images = newImages;
}
cursor = data.next_cursor ?? '';
hasMore = newImages.length === limit;
} catch (e) {
error = true;
console.error('Failed to load portfolio:', e);
} finally {
loading = false;
loadingMore = false;
}
}
async function fetchImageById(id: string): Promise<PortfolioImage | null> {
try {
const response = await fetch(`/api/portfolio/images/${id}`);
if (!response.ok) return null;
const img = await response.json();
return {
id: img.id,
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
};
} catch (e) {
console.error('Failed to fetch image by ID:', e);
return null;
}
}
function loadMore() {
if (loadingMore || !hasMore || !cursor) return;
fetchImages(true);
}
function applySearch() {
cursor = '';
if (searchQuery.includes(',')) {
selectedTags = searchQuery
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean);
selectedTag = '';
} else if (searchQuery.trim()) {
selectedTag = searchQuery.trim().toLowerCase();
selectedTags = [];
} else {
selectedTag = '';
selectedTags = [];
}
// Build URL with reactive page state
const url = new URL(page.url);
if (selectedTags.length > 0) {
url.searchParams.set('tags', selectedTags.join(','));
} else if (selectedTag) {
url.searchParams.set('tag', selectedTag);
} else {
url.searchParams.delete('tag');
url.searchParams.delete('tags');
}
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
fetchImages(false);
fetchFilters();
}
function selectFilter(category: string, value: string) {
cursor = '';
closeAllDropdowns();
if (value === '' || value === selectedFilters[category]) {
delete selectedFilters[category];
selectedFilters = { ...selectedFilters };
} else {
selectedFilters = { ...selectedFilters, [category]: value };
}
// Build URL with reactive page state
const url = new URL(page.url);
// Remove all existing filter params first to avoid stale params
const keysToDelete = Array.from(url.searchParams.keys()).filter((k) => k.startsWith('filter['));
keysToDelete.forEach((k) => url.searchParams.delete(k));
// Add current filters
for (const [cat, val] of Object.entries(selectedFilters)) {
if (val) {
url.searchParams.set(`filter[${cat}]`, val);
}
}
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
fetchImages(false);
fetchFilters();
}
function clearFilters() {
selectedFilters = {};
cursor = '';
// Build URL with reactive page state
const url = new URL(page.url);
const keysToDelete = Array.from(url.searchParams.keys()).filter((k) => k.startsWith('filter['));
keysToDelete.forEach((k) => url.searchParams.delete(k));
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
fetchImages(false);
fetchFilters();
}
function clearSearch() {
selectedTag = '';
selectedTags = [];
searchQuery = '';
cursor = '';
// Build URL with reactive page state
const url = new URL(page.url);
url.searchParams.delete('tag');
url.searchParams.delete('tags');
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
fetchImages(false);
}
let sentinelRef: HTMLDivElement | undefined = $state(undefined);
onMount(() => {
const tagParam = page.url.searchParams.get('tag');
const tagsParam = page.url.searchParams.get('tags');
const imgParam = page.url.searchParams.get('img');
selectedTag = tagParam && tagParam.length <= 100 ? tagParam.slice(0, 100) : '';
selectedTags =
tagsParam && tagsParam.length <= 500
? tagsParam
.split(',')
.map((t: string) => t.trim())
.filter(Boolean)
.slice(0, 20)
: [];
if (selectedTag) {
searchQuery = selectedTag;
} else if (selectedTags.length > 0) {
searchQuery = selectedTags.join(', ');
}
for (const [key, value] of page.url.searchParams.entries()) {
const match = key.match(/^filter\[(.+)\]$/);
if (match) {
const filterKey = match[1].slice(0, 50);
const filterValue = value.slice(0, 200);
if (/^[a-zA-Z0-9_-]+$/.test(filterKey)) {
selectedFilters[filterKey] = filterValue;
}
}
}
fetchFilters();
fetchImages(false);
// Always fetch the image by ID - this bypasses filters and pagination
// ensuring the featured image always loads
if (imgParam) {
const targetId = imgParam.trim();
// Validate: only allow alphanumeric and dash
if (!/^[a-zA-Z0-9_-]+$/.test(targetId)) {
console.warn('Invalid img parameter, ignoring');
} else {
fetchImageById(targetId).then((img) => {
if (img) {
featuredImage = img;
openModal(img);
}
});
}
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore && !loading) {
loadMore();
}
},
{ rootMargin: '200px' }
);
if (sentinelRef) {
observer.observe(sentinelRef);
}
return () => observer.disconnect();
});
let featuredImage = $state<PortfolioImage | null>(null);
let showModal = $state(false);
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);
let closeBtnRef = $state<HTMLButtonElement | undefined>(undefined);
let touchStartX = $state(0);
let swiping = $state(false);
let trackAnimating = $state(false);
let modalContentRef = $state<HTMLDivElement | undefined>(undefined);
let trackOffset = $state('0px');
let trackTransition = $state('none');
let prevFullURLs = $state<FullURLs | null>(null);
let nextFullURLs = $state<FullURLs | null>(null);
let centerSlideRef = $state<HTMLDivElement | undefined>(undefined);
const SLIDE_MS = 250;
const BTN_FLIP_MS = 150;
const BUTTON_EASE = 'cubic-bezier(0.4, 0, 0.2, 1)';
/** Old button rect captured before a data swap, used to FLIP the close button
* once the new image has loaded and has its final layout dimensions. */
let pendingBtnRect: DOMRect | null = null;
/** Position the close button at the top-right of the current image.
* Uses `getBoundingClientRect` for accuracy (works after the image has loaded
* and the browser has laid out the final constrained size).
* If `pendingBtnRect` is set, it plays a FLIP animation from that old position. */
function positionCloseBtn() {
if (!closeBtnRef || !centerSlideRef) return;
const isSm = window.innerWidth >= 640;
const offset = isSm ? 16 : 8;
// Find the image inside the center slide
const img = centerSlideRef.querySelector('img');
if (!img) return;
// Use getBoundingClientRect to get the actual rendered image bounds
const imgRect = img.getBoundingClientRect();
const slideRect = centerSlideRef.getBoundingClientRect();
const imgTopRel = imgRect.top - slideRect.top; // image top relative to the slide
// Position button so its top edge is `offset` px below the image top edge.
// Same offset as the right edge (`right-2` / `sm:right-4`), so the circle
// sits equally inside both edges.
closeBtnRef.style.top = imgTopRel + offset + 'px';
// FLIP from the old position if we have one
if (pendingBtnRect) {
const newRect = closeBtnRef.getBoundingClientRect();
const dy = pendingBtnRect.top - newRect.top;
pendingBtnRect = null;
if (Math.abs(dy) > 0.5) {
closeBtnRef.animate(
[{ transform: `translateY(${dy}px)` }, { transform: 'translateY(0)' }],
{ duration: BTN_FLIP_MS, easing: BUTTON_EASE }
);
}
}
}
function updateUrlForIndex(index: number) {
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);
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
}
function finishSlideTransition(index: number) {
trackAnimating = false;
// Capture old button position BEFORE swapping data.
// The FLIP animation will play once the new image has loaded
// (in handleFullImageLoad) and has correct layout dimensions.
pendingBtnRect = closeBtnRef?.getBoundingClientRect() || null;
// Update data — the slide content changes and the track snaps back.
const target = images[index];
selectedFullURLs = target.full;
selectedThumbURLs = target.thumb;
currentIndex = index;
updateUrlForIndex(index);
updateAdjacentSlides(index);
trackTransition = 'none';
trackOffset = 'calc(-100% / 3)';
}
function navigateNext() {
slideToNext();
}
function navigatePrev() {
slideToPrev();
}
function handleTouchStart(e: TouchEvent) {
if (trackAnimating) return;
// Ignore touches on nav buttons — they use onclick handlers
if ((e.target as HTMLElement).closest('button')) return;
touchStartX = e.touches[0].clientX;
swiping = true;
}
function handleTouchMove(e: TouchEvent) {
if (!swiping || trackAnimating) return;
e.preventDefault();
const delta = e.touches[0].clientX - touchStartX;
trackOffset = `calc(-100% / 3 + ${delta}px)`;
trackTransition = 'none';
}
function handleTouchEnd(e: TouchEvent) {
if (!swiping) return;
swiping = false;
const deltaX = e.changedTouches[0].clientX - touchStartX;
const threshold = 50;
const canGoNext = currentIndex < images.length - 1;
const canGoPrev = currentIndex > 0;
if (Math.abs(deltaX) > threshold && !trackAnimating) {
if (deltaX < 0 && canGoNext) {
slideToNext();
} else if (deltaX > 0 && canGoPrev) {
slideToPrev();
} else {
animateBounceBack();
}
} else {
animateBounceBack();
}
}
function animateBounceBack() {
trackAnimating = true;
trackTransition = `transform ${SLIDE_MS}ms ${BUTTON_EASE}`;
trackOffset = 'calc(-100% / 3)';
setTimeout(() => {
trackAnimating = false;
trackTransition = 'none';
}, SLIDE_MS + 50);
}
function openModal(img: PortfolioImage) {
const idx = images.findIndex((i) => i.id === img.id);
if (idx !== -1) {
openModalByIndex(idx);
} else {
selectedFullURLs = img.full;
selectedThumbURLs = img.thumb;
imageLoading = true;
showModal = true;
}
}
function openModalByIndex(index: number) {
currentIndex = index;
selectedFullURLs = images[index].full;
selectedThumbURLs = images[index].thumb;
imageLoading = true;
showModal = true;
trackOffset = 'calc(-100% / 3)';
trackTransition = 'none';
updateAdjacentSlides(index);
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);
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
requestAnimationFrame(() => {
// Position close button (no pendingBtnRect → no FLIP animation)
positionCloseBtn();
const hasNext = currentIndex < images.length - 1;
const hasPrev = currentIndex > 0;
if (hasNext && nextButtonRef) {
nextButtonRef.focus();
} else if (hasPrev && prevButtonRef) {
prevButtonRef.focus();
}
});
}
function handleFullImageLoad() {
imageLoading = false;
// Wait a frame so the browser has laid out the image with its final
// constrained size, then position the button and FLIP from the old position.
requestAnimationFrame(() => positionCloseBtn());
}
function updateAdjacentSlides(index: number) {
const nextIdx = index + 1;
nextFullURLs = nextIdx < images.length ? images[nextIdx].full : null;
const prevIdx = index - 1;
prevFullURLs = prevIdx >= 0 ? images[prevIdx].full : null;
}
// -- carousel -- //
function slideToNext() {
if (trackAnimating || currentIndex >= images.length - 1) return;
trackAnimating = true;
trackTransition = `transform ${SLIDE_MS}ms ${BUTTON_EASE}`;
trackOffset = 'calc(-200% / 3)';
setTimeout(() => {
finishSlideTransition(currentIndex + 1);
}, SLIDE_MS + 50);
}
function slideToPrev() {
if (trackAnimating || currentIndex <= 0) return;
trackAnimating = true;
trackTransition = `transform ${SLIDE_MS}ms ${BUTTON_EASE}`;
trackOffset = 'calc(0%)';
setTimeout(() => {
finishSlideTransition(currentIndex - 1);
}, SLIDE_MS + 50);
}
function handleKeydown(e: KeyboardEvent) {
if (!showModal) return;
if (e.key === 'ArrowRight') {
e.preventDefault();
navigateNext();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
navigatePrev();
} else if (e.key === 'Escape') {
e.preventDefault();
closeModal();
}
}
function handleImageLoad(e: Event) {
const target = e.target as HTMLImageElement;
if (target) {
target.style.opacity = '1';
}
}
function handleImageError(e: Event) {
const target = e.target as HTMLImageElement;
if (target) {
target.style.display = 'none';
}
}
function closeModal() {
showModal = false;
if (featuredImage) {
featuredImage = null;
}
// Remove img param from URL
const url = new URL(page.url);
url.searchParams.delete('img');
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
}
</script>
<svelte:window onkeydown={handleKeydown} onclick={handleWindowClick} onscroll={closeAllDropdowns} />
{#if loading}
<div class="flex min-h-screen items-center justify-center p-4">
<div class="text-center">
<div
class="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-gray-900"
></div>
<p class="text-gray-600">Loading portfolio...</p>
</div>
</div>
{:else if error}
<div class="flex min-h-screen items-center justify-center p-4">
<div class="max-w-md text-center">
<svg
class="mx-auto mb-4 h-16 w-16 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<h2 class="mb-2 text-xl font-semibold text-gray-900">Unable to Load Portfolio</h2>
<p class="text-gray-600">We couldn't load the portfolio images. Please try again later.</p>
</div>
</div>
{:else}
<div class="p-2">
<div class="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="w-full">
<div
class="scrollbar-hide flex max-w-full items-center gap-2 overflow-x-auto pb-1"
onscroll={closeAllDropdowns}
>
{#if filterCategories.length > 0}
{@const hasActiveFilters = Object.values(selectedFilters).some((v) => v && v !== '')}
{#if hasActiveFilters || selectedTag || selectedTags.length > 0}
<button
type="button"
class="filter-dropdown flex shrink-0 items-center justify-center"
onclick={clearFilters}
aria-label="Clear all filters"
>
<div
class="flex h-6 w-6 items-center justify-center rounded-full bg-red-100 text-red-600 hover:bg-red-200"
>
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
</button>
{/if}
{#each filterCategories as filter (filter.category)}
{@const selectedValue = selectedFilters[filter.category]}
{@const hasActiveFilter = selectedValue && selectedValue !== ''}
<div class="filter-dropdown relative shrink-0">
<button
type="button"
class="flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm transition-colors hover:bg-gray-50 {hasActiveFilter
? 'border-primary bg-primary/10'
: ''}"
onclick={(e) => {
e.stopPropagation();
toggleDropdown(filter.category, e);
}}
>
<span class="capitalize">{filter.category}:</span>
<span class="font-medium">{selectedValue || 'Any'}</span>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</div>
{/each}
{/if}
</div>
{#each filterCategories as filter (filter.category)}
{@const selectedValue = selectedFilters[filter.category]}
{#if openDropdowns[filter.category]}
<div
class="filter-dropdown absolute z-50 mt-1 max-h-60 w-48 overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg"
style="position: fixed; left: {dropdownPosition?.x}px; top: {dropdownPosition?.y}px;"
>
<button
type="button"
class="filter-dropdown flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors hover:bg-fuchsia-50 {selectedValue ===
''
? 'bg-fuchsia-100 font-medium'
: ''}"
onclick={(e) => {
e.stopPropagation();
selectFilter(filter.category, '');
}}
>
<span class="text-gray-500">Any</span>
</button>
{#each filter.values as value (value.value)}
<button
type="button"
class="filter-dropdown flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors hover:bg-fuchsia-50 {selectedValue ===
value.value
? 'bg-fuchsia-100 font-medium'
: ''}"
onclick={(e) => {
e.stopPropagation();
selectFilter(filter.category, value.value);
}}
>
<span class="capitalize">{value.value}</span>
<span class="text-xs text-gray-400">({value.count})</span>
</button>
{/each}
</div>
{/if}
{/each}
</div>
<div class="flex gap-2 lg:border-l lg:pl-4">
<Input
placeholder="Search tags"
maxlength={256}
bind:value={searchQuery}
onkeydown={(e) => {
if ((e as KeyboardEvent).key === 'Enter') applySearch();
}}
/>
<Button onclick={applySearch}>
{loading ? 'Searching...' : 'Search'}
</Button>
{#if selectedTag || selectedTags.length > 0}
<button
type="button"
class="flex shrink-0 items-center justify-center"
onclick={clearSearch}
aria-label="Clear search tags"
>
<div
class="flex h-6 w-6 items-center justify-center rounded-full bg-red-100 text-red-600 hover:bg-red-200"
>
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
</button>
{/if}
</div>
</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">
{#each images as img (img.id)}
<button
type="button"
class="relative aspect-square overflow-hidden rounded-xs transition-opacity hover:opacity-90 active:opacity-75"
onclick={() => openModal(img)}
aria-label="View full size portfolio item"
>
<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}
onerror={handleImageError}
/>
</button>
{/each}
</div>
<div bind:this={sentinelRef} class="h-4 w-full">
{#if loadingMore}
<div class="flex justify-center py-4">
<div
class="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-gray-900"
></div>
</div>
{:else if !hasMore && images.length > 0}
<p class="text-center text-sm text-gray-500">No more images to load</p>
{/if}
</div>
{/if}
{#if showModal}
<Dialog
open={showModal}
onOpenChange={(v) => {
if (!v) closeModal();
else showModal = v;
}}
>
<DialogOverlay class="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm" />
<DialogContent
hideClose={true}
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
bind:this={modalContentRef}
class="relative flex max-h-[95vh] max-w-[95vw] items-center justify-center sm:max-h-[90vh] sm:max-w-[90vw]"
role="presentation"
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
>
<div class="overflow-hidden rounded-sm">
<div
class="flex w-[300%]"
style="transform: translateX({trackOffset}); transition: {trackTransition}"
>
<div
class="flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]"
style="width: calc(100% / 3)"
>
{#if prevFullURLs}
<ImageVariant
urls={prevFullURLs}
type="full"
alt=""
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain sm:max-h-[90vh] sm:max-w-[90vw]"
/>
{/if}
</div>
<div
bind:this={centerSlideRef}
class="relative flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]"
style="width: calc(100% / 3)"
>
{#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]"
/>
<div class="absolute inset-0 flex items-center justify-center">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-white/30 border-t-white"
></div>
</div>
{/if}
{#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}
</div>
<div
class="flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]"
style="width: calc(100% / 3)"
>
{#if nextFullURLs}
<ImageVariant
urls={nextFullURLs}
type="full"
alt=""
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain sm:max-h-[90vh] sm:max-w-[90vw]"
/>
{/if}
</div>
</div>
</div>
<button
type="button"
bind:this={closeBtnRef}
class="absolute right-2 z-[60] rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70 sm:right-4"
onclick={closeModal}
aria-label="Close modal"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{#if currentIndex > 0}
<button
type="button"
bind:this={prevButtonRef}
class="absolute top-1/2 left-2 z-[60] -translate-y-1/2 rounded-full bg-black/50 p-3 text-white transition-colors hover:bg-black/70 sm:left-4"
onclick={navigatePrev}
aria-label="Previous image"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
{/if}
{#if currentIndex < images.length - 1}
<button
type="button"
bind:this={nextButtonRef}
class="absolute top-1/2 right-2 z-[60] -translate-y-1/2 rounded-full bg-black/50 p-3 text-white transition-colors hover:bg-black/70 sm:right-4"
onclick={navigateNext}
aria-label="Next image"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{/if}
</div>
</DialogContent>
</Dialog>
{/if}