portfolio frontend
This commit is contained in:
@@ -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}
|
||||||
Reference in New Issue
Block a user