portfolio frontend

This commit is contained in:
2025-11-28 18:03:15 +00:00
parent 7bc2129422
commit 08e4e12a73
+333
View File
@@ -0,0 +1,333 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Dialog, DialogContent, DialogOverlay } from '$lib/components/ui/dialog';
interface PortfolioImage {
full: string;
thumb: string;
timestamp: number;
}
let images: PortfolioImage[] = [];
let loading = true;
let error = false;
// 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'
];
onMount(() => {
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);
loading = false;
} catch (e) {
error = true;
loading = false;
console.error('Failed to load portfolio:', e);
}
});
let showModal = false;
let selectedImage: string | null = null;
let selectedThumb: string | null = null;
let currentIndex = 0;
let imageLoading = false;
function openModal(img: string, thumb: string) {
currentIndex = images.findIndex((i) => i.full === img);
selectedImage = img;
selectedThumb = thumb;
imageLoading = true;
showModal = true;
}
function navigateNext() {
if (currentIndex < images.length - 1) {
currentIndex++;
selectedImage = images[currentIndex].full;
selectedThumb = images[currentIndex].thumb;
imageLoading = true;
}
}
function navigatePrev() {
if (currentIndex > 0) {
currentIndex--;
selectedImage = images[currentIndex].full;
selectedThumb = images[currentIndex].thumb;
imageLoading = true;
}
}
function handleFullImageLoad() {
imageLoading = false;
}
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();
showModal = false;
}
}
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';
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#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="grid grid-cols-3 gap-1 p-2 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)}
aria-label="View full size portfolio item"
>
<img
loading="lazy"
src={img.thumb}
alt="Portfolio thumbnail {img.timestamp}"
class="h-full w-full object-cover opacity-0 transition-opacity duration-300"
on:load={handleImageLoad}
on:error={handleImageError}
/>
</button>
{/each}
</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
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">
{#if imageLoading}
<img
src={selectedThumb ?? ''}
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">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-white/30 border-t-white"
></div>
</div>
{/if}
<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}
/>
<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)}
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
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}
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
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}
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}