1036 lines
30 KiB
Svelte
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}
|