Portfolio: add filtering, URL sharing, and improved tag input

- Add category filters with dynamic counts that reduce as filters
  applied
- Add ?filter[category]=value URL params for filterable links
- Add ?img= timestamp param that bypasses filters to show specific image
- Update URL when opening/navigating/closing modal for shareable links
- Backend: add /api/portfolio/filters endpoint with filter logic
- Backend: add timestamp lookup fallback for GetImage endpoint
  Frontend:
- Portfolio page: filter dropdowns, keyboard nav, mobile improvements
- ImageUpload: live tag suggestions from API, arrow/Tab navigation,
  confirmation modal before upload, mobile-optimized touch targets
- Add scrollbar-hide utility and fix filter dropdown overflow
- Move Clear all button, add vertical separator on desktop
This commit is contained in:
2026-02-20 00:32:09 +00:00
parent dfd552b02f
commit 9259de9393
15 changed files with 1560 additions and 290 deletions
+8
View File
@@ -127,4 +127,12 @@
[data-calendar] {
z-index: 49 !important;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
@@ -4,7 +4,6 @@
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Separator } from '$lib/components/ui/separator';
interface Props {
open: boolean;
@@ -253,7 +253,6 @@
users = (data.users || []).filter(
(user: { account_role: string }) => !excludedRoles.includes(user.account_role)
);
console.log(users);
}
} catch (err) {
console.error('Failed to fetch users', err);
@@ -2,6 +2,8 @@
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import FileDropZone from '$lib/components/ui/file-drop-zone.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
// =============== Image Upload ===============
let uploading = $state(false);
@@ -80,18 +82,21 @@
});
}
/** Create a 250×250 thumbnail (square, center-cropped). */
/** Create a 250×250 thumbnail. First scale down so short side is 250px, then center-crop to 250×250 square. */
function createThumbnail(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const targetShortSide = 250;
const thumbSize = 250;
const { width, height } = img;
let { width, height } = img;
// Scale up *or* down so that the image covers 250×250
const scale = Math.max(thumbSize / width, thumbSize / height);
const scaledW = Math.round(width * scale);
const scaledH = Math.round(height * scale);
const shortSide = Math.min(width, height);
if (shortSide > targetShortSide) {
const scale = targetShortSide / shortSide;
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const canvas = document.createElement('canvas');
canvas.width = thumbSize;
@@ -99,18 +104,12 @@
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('2D context not available'));
// Draw the scaled image, then crop the center 250×250
ctx.drawImage(
img,
(scaledW - thumbSize) / -2, // offset to center
(scaledH - thumbSize) / -2,
scaledW,
scaledH,
0,
0,
thumbSize,
thumbSize
);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
const destX = (thumbSize - width) / 2;
const destY = (thumbSize - height) / 2;
ctx.drawImage(img, 0, 0, img.width, img.height, destX, destY, width, height);
canvas.toBlob(
(blob) => {
@@ -126,32 +125,42 @@
});
}
const knownTags = [
'portfolio',
'gel',
'acrylic',
'french',
'ombre',
'summer',
'wedding',
'holiday',
'pink',
'red',
'style:french',
'style:minimal',
'colour:red',
'colour:pink',
'season:summer'
];
let availableTags = $state<string[]>([]);
let loadingTags = $state(true);
let isMobile = $state(false);
async function fetchTags() {
try {
const response = await fetch('/api/portfolio/tags');
if (response.ok) {
const data = await response.json();
availableTags = data.map((t: { name: string }) => t.name);
}
} catch (e) {
console.error('Failed to fetch tags:', e);
} finally {
loadingTags = false;
}
}
// Check for mobile on mount
if (typeof window !== 'undefined') {
isMobile = window.matchMedia('(pointer: coarse)').matches;
}
fetchTags();
let tags = $state<string[]>([]);
let input = $state('');
let selectedSuggestionIndex = $state(-1);
let inputRef = $state<HTMLInputElement | undefined>(undefined);
let showConfirmUploadAlert = $state(false);
const suggestions = $derived.by(() => {
const q = input.trim().toLowerCase();
if (!q) return [];
return knownTags
return availableTags
.map((t) => t.toLowerCase())
.filter((t) => t.startsWith(q) && !tags.includes(t))
.slice(0, 6);
@@ -164,6 +173,16 @@
addTag(value);
input = '';
}
selectedSuggestionIndex = -1;
}
function handlePaste(e: ClipboardEvent) {
const value = e.clipboardData?.getData('text') || '';
if (value.includes(',')) {
e.preventDefault();
addTag(value);
input = '';
}
}
function isSemantic(tag: string) {
@@ -172,16 +191,93 @@
function addTag(raw: string) {
raw.split(',').forEach((p) => {
const t = p.trim().toLowerCase();
if (t && !tags.includes(t)) tags = [...tags, t];
let t = p.trim().toLowerCase();
if (!t) return;
t = t.replace(/^colour:/, 'color:');
if (!tags.includes(t)) tags = [...tags, t];
});
}
function handleKey(e: KeyboardEvent) {
if (e.key === 'Enter') {
// Prevent tab from moving focus away from this input
if (e.key === 'Tab') {
e.preventDefault();
addTag(input);
input = '';
// Allow arrow key navigation when tab is pressed
if (e.shiftKey) {
// Shift+Tab - go to previous suggestion (wrap to end)
if (suggestions.length > 0) {
if (selectedSuggestionIndex <= 0) {
selectedSuggestionIndex = suggestions.length - 1;
} else {
selectedSuggestionIndex = selectedSuggestionIndex - 1;
}
}
} else {
// Tab - go to next suggestion (wrap to start)
if (suggestions.length > 0) {
if (selectedSuggestionIndex >= suggestions.length - 1) {
selectedSuggestionIndex = 0;
} else {
selectedSuggestionIndex = selectedSuggestionIndex + 1;
}
}
}
return;
}
// Handle mobile keyboard action keys
const isActionKey = ['Enter', 'Done', 'Go'].includes(e.key);
if (isActionKey) {
e.preventDefault();
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (suggestions.length > 0) {
// Wrap to start if at end
if (selectedSuggestionIndex >= suggestions.length - 1) {
selectedSuggestionIndex = 0;
} else {
selectedSuggestionIndex = Math.min(selectedSuggestionIndex + 1, suggestions.length - 1);
}
}
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (suggestions.length > 0) {
// Wrap to end if at start
if (selectedSuggestionIndex <= 0) {
selectedSuggestionIndex = suggestions.length - 1;
} else {
selectedSuggestionIndex = Math.max(selectedSuggestionIndex - 1, -1);
}
}
return;
}
if (e.key === 'Enter' || e.key === 'Done' || e.key === 'Go') {
// If there's a highlighted suggestion, select it
if (selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]) {
selectSuggestion(suggestions[selectedSuggestionIndex]);
return;
}
// If input has text, add it as tag
if (input.trim()) {
addTag(input);
input = '';
return;
}
// If input is empty but we have tags, show confirmation to upload
if (tags.length > 0) {
showConfirmUploadAlert = true;
return;
}
return;
}
@@ -193,6 +289,7 @@
function selectSuggestion(tag: string) {
addTag(tag);
input = '';
selectedSuggestionIndex = -1;
}
function removeTag(tag: string) {
@@ -201,6 +298,12 @@
/** Core upload function now processes the images before sending. */
async function uploadOneOrMany() {
// Add any pending input as tag before uploading
if (input.trim()) {
addTag(input);
input = '';
}
if (!uploadFiles.length) return;
uploading = true;
uploadResults = [];
@@ -226,21 +329,33 @@
const thumbName = `${ts}_thumb.jpg`;
/* -------- 4. Attach to FormData -------------------------------- */
fd.append('file', resizedBlob, baseName); // this will be the "original"
fd.append('file', thumbBlob, thumbName); // the thumbnail
fd.append('file', resizedBlob, baseName);
fd.append('thumbnail', thumbBlob, thumbName);
/* -------- 5. Mock the API call --------------------------------- */
await new Promise((r) => setTimeout(r, 500)); // Simulate network delay
if (file.name.toLowerCase().includes('fail')) {
if (tags.length > 0) {
fd.append('tags', tags.join(','));
}
/* -------- 5. Call the API ------------------------------------- */
const response = await fetch('/api/portfolio/images', {
method: 'POST',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
},
body: fd
});
if (!response.ok) {
const errText = await response.text();
uploadResults.push({
name: file.name,
error: 'Mocked API error'
error: errText || `Server error: ${response.status}`
});
} else {
// In a real app you would `await fetch('/api/upload', {method:'POST', body:fd})`
const result = await response.json();
uploadResults.push({
name: file.name,
url: `/images/${baseName}` // pretend this is the returned URL
url: result.url
});
}
} catch (err: unknown) {
@@ -312,7 +427,12 @@
? 'bg-red-100 text-red-800'
: 'bg-emerald-100 text-emerald-800'}"
>
{result.name}: {result.error ? `Failed: ${result.error}` : `Success: ${result.url}`}
{result.name}: {result.error ? `Failed: ${result.error}` : `Success: `}<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-emerald-600">{result.url}</a
>
</div>
{/each}
</div>
@@ -324,25 +444,22 @@
<div class="relative">
<div
class="flex min-h-[38px] w-full flex-wrap gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:outline-none"
class="flex min-h-[44px] w-full flex-wrap gap-1.5 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:outline-none"
>
{#each tags as tag (tag)}
<span
class="flex items-center gap-1 rounded-full px-2 py-0.5 text-xs
class="flex items-center gap-1 rounded-full px-2 py-1 text-xs
{isSemantic(tag) ? 'bg-indigo-100 text-indigo-800' : 'bg-emerald-100 text-emerald-800'}"
>
{tag}
<button
type="button"
class="ml-1 leading-none
class="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full leading-none
{isSemantic(tag)
? 'text-indigo-700 hover:text-indigo-900'
: 'text-emerald-700 hover:text-emerald-900'}"
onmousedown={(e) => {
e.preventDefault();
removeTag(tag);
}}
? 'text-indigo-700 hover:bg-indigo-200'
: 'text-emerald-700 hover:bg-emerald-200'}"
onclick={() => removeTag(tag)}
aria-label={`Remove ${tag}`}
>
×
@@ -351,23 +468,31 @@
{/each}
<input
class="min-w-[120px] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
type="search"
enterkeyhint="done"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
class="min-w-[100px] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
bind:this={inputRef}
bind:value={input}
onkeydown={handleKey}
oninput={handleTagInput}
onpaste={handlePaste}
placeholder={tags.length ? '' : 'Add tags…'}
/>
</div>
{#if input.length && suggestions.length}
<div class="absolute right-0 left-0 z-10 mt-1 rounded-md border bg-white shadow">
{#each suggestions as s (s)}
<div class="absolute right-0 left-0 z-10 mt-1 rounded-md border bg-white shadow-md">
{#each suggestions as s, i (s)}
{@const isHighlighted = isMobile ? i === 0 : i === selectedSuggestionIndex}
<div
class="cursor-pointer px-3 py-2 text-sm hover:bg-gray-100"
onmousedown={(e) => {
e.preventDefault();
selectSuggestion(s);
}}
class="cursor-pointer px-3 py-3 text-sm touch-manipulation {isHighlighted
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-gray-100'}"
onclick={() => selectSuggestion(s)}
>
{s}
</div>
@@ -390,3 +515,20 @@
</div>
</Card.Content>
</Card.Root>
<AlertDialog.Root bind:open={showConfirmUploadAlert}>
<AlertDialog.Content class="z-[60]">
<AlertDialog.Header>
<AlertDialog.Title>Upload with {tags.length} tag{tags.length === 1 ? '' : 's'}?</AlertDialog.Title>
<AlertDialog.Description>
You have {tags.length} tag{tags.length === 1 ? '' : 's'} selected:
<span class="font-medium">{tags.join(', ')}</span>.
Ready to upload {uploadFiles.length} file{uploadFiles.length === 1 ? '' : 's'}?
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={uploadOneOrMany}>Upload</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
+15 -3
View File
@@ -14,13 +14,25 @@ async function proxyRequest(request: Request, path: string) {
try {
const headers = new Headers(request.headers);
headers.delete('host');
headers.delete('content-length');
let body: BodyInit | undefined;
let fetchOptions: RequestInit = {};
if (!['GET', 'HEAD'].includes(request.method)) {
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('multipart/form-data')) {
body = request.body;
fetchOptions.duplex = 'half';
} else {
body = await request.text();
}
}
const backendRes = await fetch(url, {
method: request.method,
headers,
body: ['GET', 'HEAD'].includes(request.method)
? undefined
: await request.text()
body,
...fetchOptions
});
// Forward everything transparently
+532 -136
View File
@@ -1,150 +1,396 @@
<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';
interface PortfolioImage {
id: string;
full: string;
thumb: string;
timestamp: number;
tag_names: string[];
created_at: string;
}
let images: PortfolioImage[] = [];
let loading = true;
let error = false;
interface FilterCategory {
category: string;
values: FilterValue[];
}
// Hardcoded list of images for prototyping
const imageFilenames = [
'1764273996.JPG',
'_DSC3194.JPG',
'_DSC3195.JPG',
'_DSC3196.JPG',
'_DSC3197.JPG',
'_DSC3198.JPG',
'_DSC3200.JPG',
'_DSC3201.JPG',
'_DSC3202.JPG',
'_DSC3203.JPG',
'_DSC3204.JPG',
'_DSC3205.JPG',
'_DSC3206.JPG',
'_DSC3207.JPG',
'_DSC3208.JPG',
'_DSC3209.JPG',
'_DSC3210.JPG',
'_DSC3211.JPG',
'_DSC3213.JPG',
'_DSC3214.JPG',
'_DSC3216.JPG',
'_DSC3219.JPG',
'DSC_3223.JPG',
'DSC_3224.JPG',
'_DSC3225.JPG',
'_DSC3226.JPG',
'_DSC3227.JPG',
'_DSC3228.JPG',
'_DSC3229.JPG',
'_DSC3230.JPG',
'_DSC3231.JPG',
'_DSC3232.JPG',
'_DSC3233.JPG',
'_DSC3234.JPG',
'_DSC3235.JPG',
'_DSC3236.JPG',
'_DSC3237.JPG',
'_DSC3238.JPG',
'_DSC3239.JPG',
'_DSC3240.JPG',
'_DSC3241.JPG',
'_DSC3242.JPG',
'_DSC3243.JPG',
'_DSC3244.JPG',
'_DSC3245.JPG',
'_DSC3250.JPG',
'_DSC3251.JPG',
'_DSC3259.JPG',
'_DSC3260.JPG',
'_DSC3261.JPG',
'_DSC3262.JPG',
'_DSC3263.JPG',
'_DSC3264.JPG',
'_DSC3265.JPG',
'_DSC3266.JPG',
'_DSC3267.JPG',
'_DSC3268.JPG',
'_DSC3269.JPG',
'_DSC3270.JPG',
'_DSC3271.JPG',
'_DSC3272.JPG',
'_DSC3273.JPG',
'_DSC3274.JPG',
'_DSC3275.JPG',
'_DSC3276.JPG',
'_DSC3277.JPG',
'_DSC3278.JPG',
'_DSC3279.JPG',
'_DSC3280.JPG',
'_DSC3281.JPG',
'_DSC3282.JPG',
'_DSC3283.JPG',
'_DSC3284.JPG',
'_DSC3285.JPG',
'_DSC3288.JPG',
'_DSC3289.JPG',
'_DSC3290.JPG',
'_DSC3291.JPG',
'_DSC3292.JPG',
'_DSC3293.JPG',
'_DSC3294.JPG',
'_DSC3295.JPG',
'_DSC3296.JPG',
'_DSC3297.JPG',
'_DSC3298.JPG',
'_DSC3299.JPG',
'_DSC3300.JPG',
'_DSC3301.JPG',
'_DSC3302.JPG',
'_DSC3303.JPG',
'_DSC3304.JPG',
'_DSC3305.JPG',
'_DSC3306.JPG',
'_DSC3308.JPG'
];
interface FilterValue {
value: string;
count: number;
}
onMount(() => {
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 offset = $state(0);
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 {
images = imageFilenames
.map((file) => {
const base = file.replace(/\.JPG$/i, '');
// Use a simple hash of the filename as timestamp for sorting
const timestamp = base.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
return {
full: `/portfolio/${file}`,
thumb: `/portfolio/${base}_thumb.jpg`,
timestamp
};
})
.sort((a, b) => b.timestamp - a.timestamp);
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;
}
loading = false;
const data = await response.json();
// Sort by total count (most images first)
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}`, `offset=${offset}`];
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 newImages = data.map(
(img: {
id: string;
url: string;
thumbnail_url: string;
tag_names: string[];
created_at: string;
}) => ({
id: img.id,
full: img.url,
thumb: img.thumbnail_url,
tag_names: img.tag_names || [],
created_at: img.created_at
})
);
if (append) {
images = [...images, ...newImages];
} else {
images = newImages;
}
hasMore = newImages.length === limit;
} catch (e) {
error = true;
loading = false;
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.url,
thumb: 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) return;
offset += limit;
fetchImages(true);
}
function applySearch() {
offset = 0;
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) {
offset = 0;
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);
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 = {};
selectedTag = '';
selectedTags = [];
searchQuery = '';
offset = 0;
// Build URL with reactive page state
const url = new URL(page.url);
url.searchParams.delete('tag');
url.searchParams.delete('tags');
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();
}
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 || '';
selectedTags = tagsParam
? tagsParam
.split(',')
.map((t: string) => t.trim())
.filter(Boolean)
: [];
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) {
selectedFilters[match[1]] = value;
}
}
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();
fetchImageById(targetId).then((img) => {
if (img) {
featuredImage = img;
openModal(img.full, img.thumb);
}
});
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore && !loading) {
loadMore();
}
},
{ rootMargin: '200px' }
);
if (sentinelRef) {
observer.observe(sentinelRef);
}
return () => observer.disconnect();
});
let showModal = false;
let selectedImage: string | null = null;
let selectedThumb: string | null = null;
let currentIndex = 0;
let imageLoading = false;
let featuredImage = $state<PortfolioImage | null>(null);
let showModal = $state(false);
let selectedImage = $state<string | null>(null);
let selectedThumb = $state<string | 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) {
currentIndex = images.findIndex((i) => i.full === img);
selectedImage = img;
selectedThumb = thumb;
const idx = images.findIndex((i) => i.full === img);
if (idx !== -1) {
openModalByIndex(idx);
} else {
selectedImage = img;
selectedThumb = thumb;
imageLoading = true;
showModal = true;
}
}
function openModalByIndex(index: number) {
currentIndex = index;
selectedImage = images[index].full;
selectedThumb = 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 url = new URL(page.url);
url.searchParams.set('img', timestamp);
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
requestAnimationFrame(() => {
const hasNext = currentIndex < images.length - 1;
const hasPrev = currentIndex > 0;
if (hasNext && nextButtonRef) {
nextButtonRef.focus();
} else if (hasPrev && prevButtonRef) {
prevButtonRef.focus();
}
});
}
function navigateNext() {
@@ -153,6 +399,12 @@
selectedImage = images[currentIndex].full;
selectedThumb = 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 url = new URL(page.url);
url.searchParams.set('img', timestamp);
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
}
}
@@ -162,6 +414,12 @@
selectedImage = images[currentIndex].full;
selectedThumb = 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 url = new URL(page.url);
url.searchParams.set('img', timestamp);
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
}
}
@@ -197,9 +455,20 @@
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');
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
}
</script>
<svelte:window on:keydown={handleKeydown} />
<svelte:window onkeydown={handleKeydown} onclick={handleWindowClick} onscroll={closeAllDropdowns} />
{#if loading}
<div class="flex min-h-screen items-center justify-center p-4">
@@ -231,30 +500,154 @@
</div>
</div>
{:else}
<div class="grid grid-cols-3 gap-1 p-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8">
<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
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
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
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
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"
bind:value={searchQuery}
onkeydown={(e) => {
if ((e as KeyboardEvent).key === 'Enter') applySearch();
}}
/>
<Button onclick={applySearch}>
{loading ? 'Searching...' : 'Search'}
</Button>
</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.full)}
<button
class="relative aspect-square overflow-hidden rounded-xs transition-opacity hover:opacity-90 active:opacity-75"
on:click={() => openModal(img.full, img.thumb)}
onclick={() => openModal(img.full, img.thumb)}
aria-label="View full size portfolio item"
>
<img
loading="lazy"
src={img.thumb}
alt="Portfolio thumbnail {img.timestamp}"
alt="Portfolio thumbnail {img.id}"
class="h-full w-full object-cover opacity-0 transition-opacity duration-300"
on:load={handleImageLoad}
on:error={handleImageError}
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) => (showModal = v)}>
<DialogOverlay class="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm" />
<DialogContent
showCloseButton={false}
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">
@@ -274,14 +667,15 @@
<img
src={selectedImage ?? ''}
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]"
class:opacity-0={imageLoading}
on:load={handleFullImageLoad}
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}
/>
<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"
on:click={() => (showModal = false)}
onclick={closeModal}
aria-label="Close modal"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -296,8 +690,9 @@
{#if currentIndex > 0}
<button
bind:this={prevButtonRef}
class="absolute top-1/2 left-2 -translate-y-1/2 rounded-full bg-black/50 p-3 text-white transition-colors hover:bg-black/70 sm:left-4"
on:click={navigatePrev}
onclick={navigatePrev}
aria-label="Previous image"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -313,8 +708,9 @@
{#if currentIndex < images.length - 1}
<button
bind:this={nextButtonRef}
class="absolute top-1/2 right-2 -translate-y-1/2 rounded-full bg-black/50 p-3 text-white transition-colors hover:bg-black/70 sm:right-4"
on:click={navigateNext}
onclick={navigateNext}
aria-label="Next image"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">