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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user