393 lines
13 KiB
Svelte
393 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { browser } from '$app/env'
|
|
import { page } from '$app/stores'
|
|
import { goto } from '$app/navigation'
|
|
import { onMount, tick } from 'svelte'
|
|
import { scale } from 'svelte/transition'
|
|
import { quartOut } from 'svelte/easing'
|
|
import { getAssetUrlKey } from '$utils/helpers'
|
|
import { fetchAPI } from '$utils/api'
|
|
import { previousPage } from '$utils/stores'
|
|
import { throttle } from '$utils/functions'
|
|
import { swipe } from '$utils/interactions/swipe'
|
|
import dayjs from 'dayjs'
|
|
import anime from 'animejs'
|
|
import type { AnimeTimelineInstance } from 'animejs'
|
|
// Components
|
|
import Metas from '$components/Metas.svelte'
|
|
import SplitText from '$components/SplitText.svelte'
|
|
import PageTransition from '$components/PageTransition.svelte'
|
|
import Image from '$components/atoms/Image.svelte'
|
|
import Icon from '$components/atoms/Icon.svelte'
|
|
import IconArrow from '$components/atoms/IconArrow.svelte'
|
|
import ButtonCircle from '$components/atoms/ButtonCircle.svelte'
|
|
|
|
export let photos: any[]
|
|
export let location: any
|
|
export let currentIndex: number
|
|
export let countPhotos: number
|
|
export let limit: number
|
|
export let offset: number
|
|
|
|
enum directions { PREV, NEXT }
|
|
|
|
let innerWidth: number
|
|
let fullscreenEl: HTMLElement
|
|
let globalOffset = offset
|
|
let isLoading = false
|
|
let isFullscreen = false
|
|
let hasNext = offset + limit < countPhotos
|
|
let hasPrev = offset > 0
|
|
|
|
// Define if we can navigate depending on loading state, existing photos and index being first or last
|
|
$: canGoPrev = !isLoading && (hasNext || currentIndex !== photos.length - 1)
|
|
$: canGoNext = !isLoading && (hasPrev || currentIndex !== 0)
|
|
// Define current photo
|
|
$: currentPhoto = photos[currentIndex]
|
|
$: currentPhotoIndex = globalOffset + currentIndex + 1
|
|
|
|
// Take 7 photos in the global photos array (5 for current, 1 before first and 1 after last)
|
|
// Start one index before the current image since the first one will be invisible
|
|
$: sliceStart = Math.max(currentIndex - 1, 0)
|
|
$: visiblePhotos = photos.slice(sliceStart, sliceStart + 7)
|
|
|
|
// Load previous photos
|
|
$: if (currentIndex === 0 && hasPrev) {
|
|
loadPhotos(photos[0].id)
|
|
}
|
|
// Load next photos
|
|
$: if (currentIndex === photos.length - 5 && hasNext) {
|
|
loadPhotos(photos[photos.length - 1].id, directions.NEXT)
|
|
}
|
|
|
|
// Change URL to current photo slug
|
|
$: if (browser && currentPhoto) {
|
|
window.history.replaceState(null, '', $page.url.pathname.replace($page.params.photo, currentPhoto.slug))
|
|
}
|
|
|
|
// Define previous URL
|
|
$: previousUrl = $previousPage ?? `/${location.country.slug}/${location.slug}`
|
|
|
|
|
|
/**
|
|
* Photo navigation
|
|
*/
|
|
// Go to next photo
|
|
const goToNext = throttle(() => {
|
|
canGoPrev && currentIndex++
|
|
}, 200)
|
|
|
|
// Go to previous photo
|
|
const goToPrevious = throttle(() => {
|
|
canGoNext && (currentIndex = Math.max(currentIndex - 1, 0))
|
|
}, 200)
|
|
|
|
// Close viewer and go to previous page
|
|
const closeViewer = () => {
|
|
goto(previousUrl, { replaceState: false, noscroll: true, keepfocus: true })
|
|
}
|
|
|
|
// Enable navigation with keyboard
|
|
const handleKeydown = ({ key, defaultPrevented }: KeyboardEvent) => {
|
|
if (defaultPrevented) return
|
|
switch (key) {
|
|
case 'ArrowLeft': goToPrevious(); break;
|
|
case 'ArrowRight': goToNext(); break;
|
|
case 'Escape': closeViewer(); break;
|
|
default: return;
|
|
}
|
|
}
|
|
|
|
// Enable swipe gestures
|
|
const handleSwipe = ({ detail }: CustomEvent<string>) => {
|
|
// Swipe up and down on mobile/small screens
|
|
if (innerWidth < 992) {
|
|
switch (detail) {
|
|
case '-y': goToNext(); break;
|
|
case 'y': goToPrevious(); break;
|
|
}
|
|
}
|
|
// Swipe left and right on larger screens
|
|
else {
|
|
switch (detail) {
|
|
case '-x': goToNext(); break;
|
|
case 'x': goToPrevious(); break;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Fullscreen for mobile
|
|
*/
|
|
const toggleFullscreen = async () => {
|
|
if (innerWidth < 992) {
|
|
isFullscreen = !isFullscreen
|
|
|
|
// Scroll at middle of photo
|
|
if (isFullscreen) {
|
|
// Wait for fullscreen children to be mounted
|
|
await tick()
|
|
const picture = fullscreenEl.querySelector('picture')
|
|
const image = fullscreenEl.querySelector('img')
|
|
picture.scrollTo((image.offsetWidth - innerWidth) / 2, 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Load photos
|
|
*/
|
|
const loadPhotos = async (id: number, direction: directions = directions.PREV) => {
|
|
// Block loading new photos if already loading
|
|
if (isLoading) return
|
|
|
|
// Set state to loading
|
|
isLoading = true
|
|
|
|
// Load new prev or next photos
|
|
const isPrev = direction === directions.PREV
|
|
const res = await fetchAPI(`
|
|
query {
|
|
photos: photo (
|
|
filter: {
|
|
location: { slug: { _eq: "${location.slug}" }},
|
|
id: { _${isPrev ? 'gt' : 'lt'}: ${id} },
|
|
status: { _eq: "published" },
|
|
},
|
|
sort: "${isPrev ? '' : '-'}id",
|
|
limit: ${limit},
|
|
) {
|
|
id
|
|
title
|
|
slug
|
|
date_taken
|
|
image {
|
|
id
|
|
title
|
|
}
|
|
city
|
|
}
|
|
}
|
|
`)
|
|
const { data: { photos: newPhotos }} = res
|
|
|
|
// Not loading anymore
|
|
isLoading = false
|
|
|
|
if (newPhotos) {
|
|
// Direction: Previous
|
|
if (direction === directions.PREV) {
|
|
// Append new photos
|
|
photos = [
|
|
...newPhotos.reverse(), // Reverse array from isPrev sort
|
|
...photos
|
|
]
|
|
// Increment current index by new amount of photos
|
|
currentIndex += newPhotos.length
|
|
// Decrement global offset by new amount of photos
|
|
globalOffset -= newPhotos.length
|
|
|
|
// No more prev photos available
|
|
if (newPhotos.length === 0) {
|
|
hasPrev = false
|
|
}
|
|
}
|
|
// Direction: Next
|
|
else {
|
|
// Append new photos
|
|
photos = [
|
|
...photos,
|
|
...newPhotos
|
|
]
|
|
|
|
// No more next photos available
|
|
if (newPhotos.length === 0) {
|
|
hasNext = false
|
|
}
|
|
}
|
|
} else {
|
|
throw new Error('Error while loading new photos')
|
|
}
|
|
}
|
|
|
|
|
|
onMount(() => {
|
|
/**
|
|
* Animations
|
|
*/
|
|
// Setup animations
|
|
const timeline: AnimeTimelineInstance = anime.timeline({
|
|
duration: 1600,
|
|
easing: 'easeOutQuart',
|
|
})
|
|
|
|
anime.set('.viewer__picture', {
|
|
opacity: 0,
|
|
})
|
|
anime.set('.viewer__picture.is-1', {
|
|
translateY: 24,
|
|
})
|
|
|
|
// Photos
|
|
timeline.add({
|
|
targets: '.viewer__picture.is-1',
|
|
opacity: 1,
|
|
translateY: 0,
|
|
duration: 900,
|
|
}, 250)
|
|
timeline.add({
|
|
targets: '.viewer__picture:not(.is-0):not(.is-1)',
|
|
opacity: 1,
|
|
translateX (element: HTMLElement, index: number) {
|
|
const x = getComputedStyle(element).getPropertyValue('--offset-x').trim()
|
|
return [`-${x}`, 0]
|
|
},
|
|
delay: anime.stagger(55)
|
|
}, 500)
|
|
|
|
// Prev/Next buttons
|
|
timeline.add({
|
|
targets: '.viewer__controls button',
|
|
translateX (item: HTMLElement) {
|
|
let direction = item.classList.contains('prev') ? -1 : 1
|
|
return [16 * direction, 0]
|
|
},
|
|
opacity: [0, 1],
|
|
}, 450)
|
|
|
|
|
|
// Infos
|
|
timeline.add({
|
|
targets: '.viewer__info > *',
|
|
translateY: [24, 0],
|
|
opacity: [0, 1],
|
|
delay: anime.stagger(200)
|
|
}, 400)
|
|
|
|
|
|
anime.set('.viewer__index', {
|
|
opacity: 0
|
|
})
|
|
// Index
|
|
timeline.add({
|
|
targets: '.viewer__index',
|
|
opacity: 1,
|
|
duration: 900,
|
|
}, 600)
|
|
// Fly each number
|
|
timeline.add({
|
|
targets: '.viewer__index .char',
|
|
translateY: ['100%', 0],
|
|
delay: anime.stagger(200),
|
|
duration: 1000,
|
|
}, 700)
|
|
|
|
// Transition in
|
|
requestAnimationFrame(timeline.play)
|
|
})
|
|
</script>
|
|
|
|
<svelte:window
|
|
bind:innerWidth
|
|
on:keydown={handleKeydown}
|
|
/>
|
|
|
|
{#if currentPhoto}
|
|
<Metas
|
|
title="{currentPhoto.title} - Houses Of {location.name}"
|
|
description=""
|
|
image={getAssetUrlKey(currentPhoto.image.id, 'share')}
|
|
/>
|
|
{/if}
|
|
|
|
|
|
<PageTransition name="viewer">
|
|
<div class="container grid">
|
|
<p class="viewer__notice text-label">Tap for fullscreen</p>
|
|
|
|
<ButtonCircle
|
|
tag="a"
|
|
url={previousUrl}
|
|
color="purple"
|
|
class="viewer__close shadow-box-dark"
|
|
>
|
|
<svg width="12" height="12">
|
|
<use xlink:href="#cross">
|
|
</svg>
|
|
</ButtonCircle>
|
|
|
|
<div class="viewer__carousel">
|
|
<div class="viewer__images" use:swipe on:swipe={handleSwipe} on:tap={toggleFullscreen}>
|
|
{#each visiblePhotos as photo, index (photo.id)}
|
|
<div class="viewer__picture is-{currentIndex === 0 ? index + 1 : index}">
|
|
<Image
|
|
class="photo"
|
|
id={photo.image.id}
|
|
alt={photo.title}
|
|
sizeKey="photo-list"
|
|
sizes={{
|
|
small: { width: 500 },
|
|
medium: { width: 850 },
|
|
large: { width: 1280 },
|
|
}}
|
|
ratio={1.5}
|
|
/>
|
|
</div>
|
|
{/each}
|
|
|
|
<div class="viewer__controls">
|
|
<ButtonCircle class="prev shadow-box-dark" disabled={!canGoNext} clone={true} on:click={goToPrevious}>
|
|
<IconArrow color="pink" flip={true} />
|
|
</ButtonCircle>
|
|
<ButtonCircle class="next shadow-box-dark" disabled={!canGoPrev} clone={true} on:click={goToNext}>
|
|
<IconArrow color="pink" />
|
|
</ButtonCircle>
|
|
</div>
|
|
|
|
|
|
<div class="viewer__index title-index">
|
|
<SplitText text="{(currentPhotoIndex < 10) ? '0' : ''}{currentPhotoIndex}" mode="chars" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="viewer__info">
|
|
<h1 class="title-medium">{currentPhoto.title}</h1>
|
|
|
|
<div class="detail text-info">
|
|
<a href="/{location.country.slug}/{location.slug}" sveltekit:prefetch sveltekit:noscroll>
|
|
<Icon class="icon" icon="map-pin" label="Map pin" />
|
|
<span>
|
|
{#if currentPhoto.city}
|
|
{currentPhoto.city}, {location.name}, {location.country.name}
|
|
{:else}
|
|
{location.name}, {location.country.name}
|
|
{/if}
|
|
</span>
|
|
</a>
|
|
<span class="sep">·</span> <time datetime={dayjs(currentPhoto.date_taken).format('YYYY-MM-DD')}>{dayjs(currentPhoto.date_taken).format('MMMM YYYY')}</time>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if isFullscreen}
|
|
<div class="viewer__fullscreen" bind:this={fullscreenEl} on:click={toggleFullscreen}>
|
|
<div class="inner" transition:scale={{ easing: quartOut, start: 1.1, duration: 1000 }}>
|
|
<Image
|
|
id={currentPhoto.image.id}
|
|
sizeKey="photo-grid-large"
|
|
width={1266}
|
|
height={844}
|
|
alt={currentPhoto.title}
|
|
/>
|
|
<ButtonCircle color="gray-medium" class="close">
|
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="#fff" xmlns="http://www.w3.org/2000/svg">
|
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.751 0c4.274 0 7.752 3.477 7.752 7.751 0 1.846-.65 3.543-1.73 4.875l3.99 3.991a.81.81 0 1 1-1.146 1.146l-3.99-3.991a7.714 7.714 0 0 1-4.876 1.73C3.477 15.503 0 12.027 0 7.753 0 3.476 3.477 0 7.751 0Zm0 1.62a6.138 6.138 0 0 0-6.13 6.131 6.138 6.138 0 0 0 6.13 6.132 6.138 6.138 0 0 0 6.131-6.132c0-3.38-2.75-6.13-6.13-6.13Zm2.38 5.321a.81.81 0 1 1 0 1.62h-4.76a.81.81 0 1 1 0-1.62h4.76Z" />
|
|
</svg>
|
|
</ButtonCircle>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</PageTransition> |