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