feat(gallery): fullscreen carousel modal

This commit is contained in:
2025-12-31 01:26:16 +01:00
parent ee25e7e61c
commit 400a35021f

View File

@@ -1,19 +1,157 @@
<script setup lang="ts"> <script setup lang="ts">
const images = [ type ImageMetadata = {
"https://picsum.photos/640/640?random=1", url: string;
"https://picsum.photos/640/800?random=2", date: string;
"https://picsum.photos/800/640?random=3", camera: string;
"https://picsum.photos/1000/640?random=4", lens: string;
"https://picsum.photos/640/400?random=5", settings?: {
"https://picsum.photos/640/800?random=6", aperture?: string;
"https://picsum.photos/810/640?random=7", shutter?: string;
"https://picsum.photos/760/640?random=8", iso?: string;
"https://picsum.photos/640/900?random=9", focalLength?: string;
"https://picsum.photos/1000/640?random=10", };
"https://picsum.photos/640/600?random=11", };
const images: ImageMetadata[] = [
{
url: "https://picsum.photos/640/640?random=1",
date: "March 7, 2025",
camera: "Olympus EPL-7",
lens: "Olympus M.Zuiko ED 60mm f/2.8 Macro",
settings: {
aperture: "f/10",
shutter: "1/250s",
iso: "500",
focalLength: "60mm",
},
},
{
url: "https://picsum.photos/640/800?random=2",
date: "March 8, 2025",
camera: "Olympus EPL-7",
lens: "Olympus M.Zuiko ED 60mm f/2.8 Macro",
settings: {
aperture: "f/10",
shutter: "1/250s",
iso: "500",
focalLength: "60mm",
},
},
{
url: "https://picsum.photos/800/640?random=3",
date: "March 10, 2025",
camera: "Olympus EPL-7",
lens: "Olympus M.Zuiko ED 60mm f/2.8 Macro",
settings: {
aperture: "f/10",
shutter: "1/250s",
iso: "500",
focalLength: "60mm",
},
},
{
url: "https://picsum.photos/1000/640?random=4",
date: "March 12, 2025",
camera: "Olympus EPL-7",
lens: "Olympus M.Zuiko ED 60mm f/2.8 Macro",
settings: {
aperture: "f/10",
shutter: "1/250s",
iso: "500",
focalLength: "60mm",
},
},
{
url: "https://picsum.photos/640/400?random=5",
date: "March 15, 2025",
camera: "Olympus EPL-7",
lens: "Olympus M.Zuiko ED 60mm f/2.8 Macro",
settings: {
aperture: "f/10",
shutter: "1/250s",
iso: "500",
focalLength: "60mm",
},
},
{
url: "https://picsum.photos/640/800?random=6",
date: "March 18, 2025",
camera: "Olympus EPL-7",
lens: "Olympus M.Zuiko ED 60mm f/2.8 Macro",
settings: {
aperture: "f/10",
shutter: "1/250s",
iso: "500",
focalLength: "60mm",
},
},
{
url: "https://picsum.photos/810/640?random=7",
date: "March 20, 2025",
camera: "Olympus EPL-7",
lens: "Olympus M.Zuiko ED 60mm f/2.8 Macro",
settings: {
aperture: "f/10",
shutter: "1/250s",
iso: "500",
focalLength: "60mm",
},
},
{
url: "https://picsum.photos/760/640?random=8",
date: "March 22, 2025",
camera: "Olympus EPL-7",
lens: "Olympus M.Zuiko ED 60mm f/2.8 Macro",
settings: {
aperture: "f/10",
shutter: "1/250s",
iso: "500",
focalLength: "60mm",
},
},
{
url: "https://picsum.photos/640/900?random=9",
date: "March 25, 2025",
camera: "Olympus EPL-7",
lens: "Olympus M.Zuiko ED 60mm f/2.8 Macro",
settings: {
aperture: "f/10",
shutter: "1/250s",
iso: "500",
focalLength: "60mm",
},
},
{
url: "https://picsum.photos/1000/640?random=10",
date: "March 28, 2025",
camera: "Olympus EPL-7",
lens: "Olympus M.Zuiko ED 60mm f/2.8 Macro",
settings: {
aperture: "f/10",
shutter: "1/250s",
iso: "500",
focalLength: "60mm",
},
},
{
url: "https://picsum.photos/640/600?random=11",
date: "March 30, 2025",
camera: "Olympus EPL-7",
lens: "Olympus M.Zuiko ED 60mm f/2.8 Macro",
settings: {
aperture: "f/10",
shutter: "1/250s",
iso: "500",
focalLength: "60mm",
},
},
]; ];
const columnCount = ref(3); const columnCount = ref(3);
const isModalOpen = ref(false);
const currentImageIndex = ref(0);
const currentImage = computed(() => images[currentImageIndex.value]!);
const getColumnImages = (columnNumber: number) => const getColumnImages = (columnNumber: number) =>
images.filter((_, index) => index % columnCount.value === columnNumber - 1); images.filter((_, index) => index % columnCount.value === columnNumber - 1);
@@ -28,19 +166,54 @@ const updateColumns = () => {
} }
}; };
const openModal = (image: ImageMetadata) => {
const index = images.indexOf(image);
if (index !== -1) {
currentImageIndex.value = index;
isModalOpen.value = true;
}
};
const closeModal = () => {
isModalOpen.value = false;
};
const nextImage = () => {
currentImageIndex.value = (currentImageIndex.value + 1) % images.length;
};
const prevImage = () => {
currentImageIndex.value =
(currentImageIndex.value - 1 + images.length) % images.length;
};
const handleKeydown = (event: KeyboardEvent) => {
if (!isModalOpen.value) return;
if (event.key === "Escape") {
closeModal();
} else if (event.key === "ArrowRight") {
nextImage();
} else if (event.key === "ArrowLeft") {
prevImage();
}
};
onMounted(() => { onMounted(() => {
updateColumns(); updateColumns();
window.addEventListener("resize", updateColumns); window.addEventListener("resize", updateColumns);
window.addEventListener("keydown", handleKeydown);
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener("resize", updateColumns); window.removeEventListener("resize", updateColumns);
window.removeEventListener("keydown", handleKeydown);
}); });
</script> </script>
<template> <template>
<div <div
class="min-h-screen bg-[#0a0a0a] text-white px-12 py-12 font-[JetBrains_Mono,monospace]" class="min-h-screen bg-[#0a0a0a] text-white px-12 py-12 font-[JetBrains_Mono]"
> >
<div class="flex gap-6"> <div class="flex gap-6">
<div <div
@@ -70,14 +243,115 @@ onUnmounted(() => {
<div <div
v-for="(image, idx) in getColumnImages(col)" v-for="(image, idx) in getColumnImages(col)"
:key="idx" :key="idx"
class="relative overflow-hidden rounded-sm bg-black cursor-pointer transition-transform" class="relative overflow-hidden rounded-sm bg-black cursor-pointer"
@click="openModal(image)"
> >
<img <img
:src="image" :src="image.url"
class="w-full h-auto block brightness-80 hover:brightness-100 transition-all" class="w-full h-auto block brightness-80 hover:brightness-100 transition-all"
/> />
</div> </div>
</div> </div>
</div> </div>
<Teleport to="body">
<Transition name="modal">
<div
v-if="isModalOpen"
class="fixed inset-0 z-50 flex items-center font-[JetBrains_Mono] justify-center bg-black/95 backdrop-blur-sm"
@click="closeModal"
>
<!-- close -->
<UButton
icon="i-lucide-x"
color="neutral"
variant="link"
:ui="{ leadingIcon: 'size-8' }"
class="absolute top-6 right-6 z-10"
aria-label="Close modal"
@click="closeModal"
/>
<!-- previous -->
<UButton
icon="i-lucide-chevron-left"
color="neutral"
variant="link"
:ui="{ leadingIcon: 'size-8' }"
class="absolute left-6 z-10 hidden md:block"
aria-label="Previous image"
@click.stop="prevImage"
/>
<div
class="flex flex-col items-center justify-center gap-3 max-w-[95vw] max-h-[90vh] px-4 md:px-20"
@click.stop
>
<img
:key="currentImageIndex"
:src="currentImage.url"
class="max-w-[90vw] h-[80vh] object-contain rounded-sm"
alt="Gallery image"
/>
<!-- metadata -->
<div
class="bg-black px-4 py-2 text-sm text-white flex flex-wrap items-center justify-center gap-2"
>
<span>{{ currentImage.date }}</span>
<span class="text-white/50">|</span>
<span>{{ currentImage.camera }}</span>
<span class="text-white/50">|</span>
<span>{{ currentImage.lens }}</span>
<template v-if="currentImage.settings">
<span class="text-white/50">|</span>
<span v-if="currentImage.settings.aperture">{{
currentImage.settings.aperture
}}</span>
<span v-if="currentImage.settings.shutter">{{
currentImage.settings.shutter
}}</span>
<span v-if="currentImage.settings.iso"
>ISO {{ currentImage.settings.iso }}</span
>
<span v-if="currentImage.settings.focalLength">{{
currentImage.settings.focalLength
}}</span>
</template>
</div>
</div>
<!-- next -->
<UButton
icon="i-lucide-chevron-right"
color="neutral"
variant="link"
:ui="{ leadingIcon: 'size-8' }"
class="absolute right-6 z-10 hidden md:block"
aria-label="Next image"
@click.stop="nextImage"
/>
<!-- TODO: mobile navigation (use md:hidden) -->
<div
class="absolute bottom-6 left-1/2 transform -translate-x-1/2 text-white/70 text-sm"
>
{{ currentImageIndex + 1 }} / {{ images.length }}
</div>
</div>
</Transition>
</Teleport>
</div> </div>
</template> </template>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>