🚧 Switch to monorepo with Turbo
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { PUBLIC_LIST_AMOUNT } from '$env/static/public'
|
||||
import { fetchAPI, photoFields } from '$utils/api'
|
||||
|
||||
|
||||
/**
|
||||
* Page Data
|
||||
*/
|
||||
export const load: PageServerLoad = async ({ params, setHeaders }) => {
|
||||
try {
|
||||
const { location: slug } = params
|
||||
|
||||
// Query
|
||||
const res = await fetchAPI(`query {
|
||||
location (
|
||||
filter: {
|
||||
slug: { _eq: "${slug}" },
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
description
|
||||
date_updated
|
||||
illustration_desktop { id }
|
||||
illustration_desktop_2x { id }
|
||||
illustration_mobile { id }
|
||||
credits {
|
||||
credit_id {
|
||||
name
|
||||
website
|
||||
}
|
||||
}
|
||||
country {
|
||||
name
|
||||
slug
|
||||
flag { id }
|
||||
}
|
||||
has_poster
|
||||
acknowledgement
|
||||
}
|
||||
|
||||
photos: photo (
|
||||
filter: {
|
||||
location: { slug: { _eq: "${slug}" }},
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
sort: "-date_created",
|
||||
limit: ${PUBLIC_LIST_AMOUNT},
|
||||
page: 1,
|
||||
) {
|
||||
${photoFields}
|
||||
}
|
||||
|
||||
# Total
|
||||
total_published: photo_aggregated (filter: { location: { slug: { _eq: "${slug}" }}}) {
|
||||
count { location }
|
||||
}
|
||||
|
||||
# Shop product
|
||||
product (
|
||||
filter: {
|
||||
location: { slug: { _eq: "${slug}" }},
|
||||
status: { _eq: "published" },
|
||||
}
|
||||
) {
|
||||
photos_product {
|
||||
directus_files_id {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
const { data: { location: location, photos, total_published, product }} = res
|
||||
|
||||
if (!location.length || location.length && params.country !== location[0].country.slug) {
|
||||
throw error(404, "This location is not available… yet!")
|
||||
}
|
||||
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=604799' })
|
||||
|
||||
return {
|
||||
location: location[0],
|
||||
photos,
|
||||
totalPhotos: photos.length ? total_published[0].count.location : 0,
|
||||
product: product[0],
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
368
apps/website/src/routes/(site)/[country]/[location]/+page.svelte
Normal file
368
apps/website/src/routes/(site)/[country]/[location]/+page.svelte
Normal file
@@ -0,0 +1,368 @@
|
||||
<style lang="scss">
|
||||
@import "../../../../style/pages/location";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { page, navigating } from '$app/stores'
|
||||
import type { PageData } from './$types'
|
||||
import { onMount } from 'svelte'
|
||||
import { stagger, timeline } from 'motion'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { quartOut } from '$animations/easings'
|
||||
import { getAssetUrlKey } from '$utils/api'
|
||||
import { DELAY } from '$utils/constants'
|
||||
import { seenLocations } from '$utils/stores'
|
||||
import { photoFields } from '$utils/api'
|
||||
import { PUBLIC_LIST_INCREMENT } from '$env/static/public'
|
||||
// Components
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import Image from '$components/atoms/Image.svelte'
|
||||
import Button from '$components/atoms/Button.svelte'
|
||||
import IconEarth from '$components/atoms/IconEarth.svelte'
|
||||
import House from '$components/molecules/House.svelte'
|
||||
import Pagination from '$components/molecules/Pagination.svelte'
|
||||
import NewsletterModule from '$components/organisms/NewsletterModule.svelte'
|
||||
import ShopModule from '$components/organisms/ShopModule.svelte'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
let { photos, totalPhotos }: { photos: any[], totalPhotos: number } = data
|
||||
$: ({ photos, totalPhotos } = data)
|
||||
const { location, product = undefined }: { location: any, totalPhotos: number, product: any } = data
|
||||
const { params } = $page
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
let introEl: HTMLElement
|
||||
let photosListEl: HTMLElement
|
||||
let scrollY: number
|
||||
let observerPhotos: IntersectionObserver
|
||||
let mutationPhotos: MutationObserver
|
||||
let currentPage = 1
|
||||
let ended: boolean
|
||||
let currentPhotosAmount: number
|
||||
let illustrationOffsetY = 0
|
||||
|
||||
$: latestPhoto = photos[0]
|
||||
$: currentPhotosAmount = photos.length
|
||||
$: ended = currentPhotosAmount === totalPhotos
|
||||
$: hasIllustration = location.illustration_desktop && location.illustration_desktop_2x && location.illustration_mobile
|
||||
|
||||
|
||||
/**
|
||||
* Load photos
|
||||
*/
|
||||
// Load more photos from CTA
|
||||
const loadMorePhotos = async () => {
|
||||
// Append more photos from API
|
||||
const newPhotos: any = await loadPhotos(currentPage + 1)
|
||||
|
||||
if (newPhotos) {
|
||||
photos = [...photos, ...newPhotos]
|
||||
|
||||
// Define actions if the number of new photos is the expected ones
|
||||
if (newPhotos.length === Number(PUBLIC_LIST_INCREMENT)) {
|
||||
// Increment the current page
|
||||
currentPage++
|
||||
}
|
||||
|
||||
// Increment the currently visible amount of photos
|
||||
currentPhotosAmount += newPhotos.length
|
||||
}
|
||||
}
|
||||
|
||||
// Load photos helper
|
||||
const loadPhotos = async (page?: number) => {
|
||||
const res = await fetch('/api/data', {
|
||||
method: 'POST',
|
||||
body: `query {
|
||||
photos: photo (
|
||||
filter: {
|
||||
location: { slug: { _eq: "${params.location}" }},
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
sort: "-date_created",
|
||||
limit: ${PUBLIC_LIST_INCREMENT},
|
||||
page: ${page},
|
||||
) {
|
||||
${photoFields}
|
||||
}
|
||||
}`,
|
||||
})
|
||||
const { data: { photos }} = await res.json()
|
||||
|
||||
if (photos) {
|
||||
// Return new photos
|
||||
return photos
|
||||
} else {
|
||||
throw new Error('Error while loading new photos')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add parallax on illustration when scrolling
|
||||
*/
|
||||
$: if (scrollY && scrollY < introEl.offsetHeight) {
|
||||
illustrationOffsetY = scrollY * 0.1
|
||||
}
|
||||
|
||||
|
||||
onMount(() => {
|
||||
// Define location's last seen state
|
||||
$seenLocations = JSON.stringify({
|
||||
// Add existing values
|
||||
...JSON.parse($seenLocations),
|
||||
// Add location ID with current time
|
||||
[location.id]: new Date(),
|
||||
})
|
||||
|
||||
// Photos IntersectionObserver
|
||||
observerPhotos = new IntersectionObserver(entries => {
|
||||
entries.forEach(({ isIntersecting, target }: IntersectionObserverEntry) => {
|
||||
target.classList.toggle('is-visible', isIntersecting)
|
||||
|
||||
// Run effect once
|
||||
isIntersecting && observerPhotos.unobserve(target)
|
||||
})
|
||||
}, { threshold: 0.3 })
|
||||
|
||||
// Photos MutationObserver
|
||||
if (photos.length) {
|
||||
mutationPhotos = new MutationObserver((mutationsList) => {
|
||||
// When adding new childs
|
||||
for (const mutation of mutationsList) {
|
||||
if (mutation.type === 'childList') {
|
||||
// Observe new items
|
||||
Array.from(mutation.addedNodes)
|
||||
.filter(item => item.nodeType === Node.ELEMENT_NODE)
|
||||
.forEach((item: HTMLElement) => observerPhotos.observe(item))
|
||||
}
|
||||
}
|
||||
})
|
||||
mutationPhotos.observe(photosListEl, {
|
||||
childList: true,
|
||||
})
|
||||
|
||||
// Observe existing elements
|
||||
const existingPhotos = photosListEl.querySelectorAll('.house')
|
||||
existingPhotos.forEach(el => observerPhotos.observe(el))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Animations
|
||||
*/
|
||||
const animation = timeline([
|
||||
// Title word
|
||||
['.location-page__intro .word', {
|
||||
y: ['110%', 0],
|
||||
}, {
|
||||
at: 0.2,
|
||||
delay: stagger(0.4)
|
||||
}],
|
||||
|
||||
// Illustration
|
||||
['.location-page__illustration', {
|
||||
scale: [1.06, 1],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.4,
|
||||
duration: 2.4,
|
||||
}],
|
||||
|
||||
// Title of
|
||||
['.location-page__intro .of', {
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.95,
|
||||
duration: 1.2,
|
||||
}],
|
||||
|
||||
// Description
|
||||
['.location-page__description', {
|
||||
y: ['10%', 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.9,
|
||||
duration: 1.2,
|
||||
}]
|
||||
], {
|
||||
delay: $navigating ? DELAY.PAGE_LOADING / 1000 : 0,
|
||||
defaultOptions: {
|
||||
duration: 1.6,
|
||||
easing: quartOut,
|
||||
},
|
||||
})
|
||||
animation.stop()
|
||||
|
||||
// Run animation
|
||||
requestAnimationFrame(animation.play)
|
||||
|
||||
|
||||
// Destroy
|
||||
return () => {
|
||||
observerPhotos && observerPhotos.disconnect()
|
||||
mutationPhotos && mutationPhotos.disconnect()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window bind:scrollY />
|
||||
|
||||
<Metas
|
||||
title="Houses Of {location.name}"
|
||||
description="Discover {totalPhotos} beautiful homes from {location.name}, {location.country.name}"
|
||||
image={latestPhoto ? getAssetUrlKey(latestPhoto.image.id, 'share-image') : null}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="location-page">
|
||||
<section class="location-page__intro grid" bind:this={introEl}>
|
||||
<h1 class="title" class:is-short={location.name.length <= 4}>
|
||||
<span class="housesof mask">
|
||||
<strong class="word">Houses</strong>
|
||||
<span class="of">of</span>
|
||||
</span>
|
||||
<strong class="city mask">
|
||||
<span class="word">{location.name}</span>
|
||||
</strong>
|
||||
</h1>
|
||||
|
||||
<div class="location-page__description grid">
|
||||
<div class="wrap">
|
||||
<div class="text-medium">
|
||||
Houses of {location.name} {location.description ?? 'has no description yet'}
|
||||
</div>
|
||||
<div class="info">
|
||||
<p class="text-label">
|
||||
Photos by
|
||||
{#each location.credits as { credit_id: { name, website }}}
|
||||
{#if website}
|
||||
<a href={website} target="_blank" rel="noopener external">
|
||||
{name}
|
||||
</a>
|
||||
{:else}
|
||||
<span>{name}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
{#if latestPhoto}
|
||||
·
|
||||
<p class="text-label" title={dayjs(latestPhoto.date_created).format('DD/MM/YYYY, hh:mm')}>
|
||||
Updated <time datetime={dayjs(latestPhoto.date_created).format('YYYY-MM-DD')}>
|
||||
{dayjs().to(dayjs(latestPhoto.date_created))}
|
||||
</time>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="ctas">
|
||||
<Button url="/locations" text="Change location" class="shadow-small">
|
||||
<IconEarth />
|
||||
</Button>
|
||||
|
||||
{#if location.has_poster}
|
||||
<Button url="/shop/poster-{location.slug}" text="Buy the poster" color="pinklight" class="shadow-small">
|
||||
<!-- <IconEarth /> -->
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if hasIllustration}
|
||||
<picture class="location-page__illustration" style:--parallax-y="{illustrationOffsetY}px">
|
||||
<source media="(min-width: 1200px)" srcset={getAssetUrlKey(location.illustration_desktop_2x.id, 'illustration-desktop-2x')}>
|
||||
<source media="(min-width: 768px)" srcset={getAssetUrlKey(location.illustration_desktop.id, 'illustration-desktop-1x')}>
|
||||
<img
|
||||
src={getAssetUrlKey(location.illustration_mobile.id, 'illustration-mobile')}
|
||||
width={320}
|
||||
height={824}
|
||||
alt="Illustration for {location.name}"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if photos.length}
|
||||
<section class="location-page__houses" bind:this={photosListEl} data-sveltekit-noscroll>
|
||||
{#each photos as { title, image: { id, title: alt, width, height }, slug, city, date_taken }, index}
|
||||
<House
|
||||
{title}
|
||||
photoId={id}
|
||||
photoAlt={alt}
|
||||
url="/{params.country}/{params.location}/{slug}"
|
||||
{city}
|
||||
location={location.name}
|
||||
ratio={width / height}
|
||||
date={date_taken}
|
||||
index={(totalPhotos - index < 10) ? '0' : ''}{totalPhotos - index}
|
||||
/>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="location-page__next container">
|
||||
<Pagination
|
||||
ended={ended}
|
||||
current={currentPhotosAmount}
|
||||
total={totalPhotos}
|
||||
on:click={!ended && loadMorePhotos}
|
||||
>
|
||||
{#if !ended}
|
||||
<p class="more">See more photos</p>
|
||||
{:else}
|
||||
<p>You've seen it all!</p>
|
||||
{/if}
|
||||
</Pagination>
|
||||
|
||||
{#if ended}
|
||||
<div class="grid-modules">
|
||||
<div class="container grid">
|
||||
<div class="wrap">
|
||||
{#if location.has_poster}
|
||||
<ShopModule
|
||||
title="Poster available"
|
||||
text="Houses of {location.name} is available as a poster on our shop."
|
||||
images={product.photos_product}
|
||||
textBottom={null}
|
||||
buttonText="Buy"
|
||||
url="/shop/poster-{location.slug}"
|
||||
/>
|
||||
{:else}
|
||||
<ShopModule />
|
||||
{/if}
|
||||
<NewsletterModule theme="light" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if location.acknowledgement}
|
||||
<div class="acknowledgement">
|
||||
<Image
|
||||
class="flag"
|
||||
id={location.country.flag.id}
|
||||
sizeKey="square-small"
|
||||
width={32} height={32}
|
||||
alt="Flag of {location.country.name}"
|
||||
/>
|
||||
<p>{location.acknowledgement}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<div class="location-page__message">
|
||||
<p>
|
||||
No photos available for {location.name}.<br>
|
||||
Come back later!
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</PageTransition>
|
||||
@@ -0,0 +1,91 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
|
||||
export const load: PageServerLoad = async ({ params, setHeaders }) => {
|
||||
try {
|
||||
// Get the first photo ID
|
||||
const firstPhoto = await fetchAPI(`query {
|
||||
photo (search: "${params.photo}") {
|
||||
id
|
||||
}
|
||||
}`)
|
||||
const firstPhotoId = firstPhoto?.data?.photo[0]?.id
|
||||
|
||||
// TODO: use same request for both queries (photo.id)
|
||||
const photosBeforeFirst = await fetchAPI(`query {
|
||||
count: photo_aggregated (
|
||||
filter: {
|
||||
id: { _gt: ${firstPhotoId} },
|
||||
location: { slug: { _eq: "${params.location}" }},
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
sort: "-id",
|
||||
) {
|
||||
count {
|
||||
id
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
// Define offset from the current count
|
||||
const offset = Math.max(photosBeforeFirst?.data?.count[0]?.count.id - 5, 0)
|
||||
const limit = 10
|
||||
|
||||
const res = await fetchAPI(`query {
|
||||
photos: photo (
|
||||
filter: {
|
||||
location: { slug: { _eq: "${params.location}" }}
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
sort: "-date_created",
|
||||
limit: ${limit},
|
||||
offset: ${offset},
|
||||
) {
|
||||
id
|
||||
title
|
||||
slug
|
||||
date_taken
|
||||
image {
|
||||
id
|
||||
title
|
||||
width, height
|
||||
}
|
||||
city
|
||||
}
|
||||
|
||||
location (filter: { slug: { _eq: "${params.location}" }}) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
country {
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
total_published: photo_aggregated (filter: { location: { slug: { _eq: "${params.location}" }}}) {
|
||||
count { location }
|
||||
}
|
||||
}`)
|
||||
|
||||
const { data } = res
|
||||
|
||||
if (data) {
|
||||
const currentIndex = data.photos.findIndex((photo: any) => photo.slug === params.photo)
|
||||
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=604799' })
|
||||
|
||||
return {
|
||||
photos: data.photos,
|
||||
location: data.location[0],
|
||||
currentIndex,
|
||||
countPhotos: data.total_published[0].count.location,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
<style lang="scss">
|
||||
@import "../../../../../style/pages/viewer";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment'
|
||||
import { page, navigating } from '$app/stores'
|
||||
import { goto } from '$app/navigation'
|
||||
import type { PageData } from './$types'
|
||||
import { onMount, tick } from 'svelte'
|
||||
import { fade, scale } from 'svelte/transition'
|
||||
import { quartOut } from 'svelte/easing'
|
||||
import dayjs from 'dayjs'
|
||||
import { stagger, timeline } from 'motion'
|
||||
import { getAssetUrlKey } from '$utils/api'
|
||||
import { previousPage } from '$utils/stores'
|
||||
import { DELAY } from '$utils/constants'
|
||||
import { throttle } from '$utils/functions'
|
||||
import { swipe } from '$utils/interactions/swipe'
|
||||
// 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 data: PageData
|
||||
|
||||
let { photos, currentIndex }: { photos: any[], currentIndex: number } = data
|
||||
const { location, countPhotos, limit, offset }: { location: any, countPhotos: number, limit: number, offset: number } = data
|
||||
|
||||
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 (browser && currentIndex === 0 && hasPrev) {
|
||||
loadPhotos(photos[0].id)
|
||||
}
|
||||
// Load next photos
|
||||
$: if (browser && 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 ? $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 fetch('/api/data', {
|
||||
method: 'POST',
|
||||
body: `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 }} = await res.json()
|
||||
|
||||
// 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
|
||||
*/
|
||||
const animation = timeline([
|
||||
// First photo
|
||||
['.photo-page__picture.is-1', {
|
||||
y: [24, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
duration: 0.9,
|
||||
}],
|
||||
// Other photos
|
||||
['.photo-page__picture:not(.is-1)', {
|
||||
x: ['-150%', 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.4,
|
||||
delay: stagger(0.1),
|
||||
opacity: { duration: 0.25 },
|
||||
}],
|
||||
|
||||
// Prev/Next buttons
|
||||
['.photo-page__controls .prev', {
|
||||
x: [-16, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.45,
|
||||
}],
|
||||
['.photo-page__controls .next', {
|
||||
x: [16, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.45,
|
||||
}],
|
||||
|
||||
// Infos
|
||||
['.photo-page__info > *', {
|
||||
y: [24, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.4,
|
||||
delay: stagger(0.3)
|
||||
}],
|
||||
|
||||
// Index
|
||||
['.photo-page__index', {
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.6,
|
||||
delay: stagger(0.2),
|
||||
duration: 0.9,
|
||||
}],
|
||||
// Fly each number
|
||||
['.photo-page__index .char', {
|
||||
y: ['300%', 0],
|
||||
}, {
|
||||
at: 1.1,
|
||||
delay: stagger(0.2),
|
||||
duration: 1,
|
||||
}],
|
||||
], {
|
||||
delay: $navigating ? DELAY.PAGE_LOADING / 1000 : 0,
|
||||
defaultOptions: {
|
||||
duration: 1.6,
|
||||
easing: quartOut,
|
||||
},
|
||||
})
|
||||
animation.stop()
|
||||
|
||||
// Run animation
|
||||
requestAnimationFrame(animation.play)
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth on:keydown={handleKeydown} />
|
||||
|
||||
{#if currentPhoto}
|
||||
<Metas
|
||||
title="{currentPhoto.title} - Houses Of {location.name}"
|
||||
description="Photo of a beautiful home from {location.name}, {location.country.name}"
|
||||
image={getAssetUrlKey(currentPhoto.image.id, 'share')}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="photo-page">
|
||||
<div class="container grid">
|
||||
<p class="photo-page__notice text-label">Tap for fullscreen</p>
|
||||
|
||||
<ButtonCircle
|
||||
tag="a"
|
||||
url={previousUrl}
|
||||
color="purple"
|
||||
class="close shadow-box-dark"
|
||||
label="Close"
|
||||
>
|
||||
<svg width="12" height="12">
|
||||
<use xlink:href="#cross">
|
||||
</svg>
|
||||
</ButtonCircle>
|
||||
|
||||
<div class="photo-page__carousel">
|
||||
<div class="photo-page__images" use:swipe on:swipe={handleSwipe} on:tap={toggleFullscreen}>
|
||||
{#each visiblePhotos as { id, image, title }, index (id)}
|
||||
<div class="photo-page__picture is-{currentIndex === 0 ? index + 1 : index}">
|
||||
<Image
|
||||
class="photo {image.width / image.height < 1.475 ? 'not-landscape' : ''}"
|
||||
id={image.id}
|
||||
alt={title}
|
||||
sizeKey="photo-list"
|
||||
sizes={{
|
||||
small: { width: 500 },
|
||||
medium: { width: 850 },
|
||||
large: { width: 1280 },
|
||||
}}
|
||||
ratio={1.5}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="photo-page__controls">
|
||||
<ButtonCircle class="prev shadow-box-dark" label="Previous" disabled={!canGoNext} clone={true} on:click={goToPrevious}>
|
||||
<IconArrow color="pink" flip={true} />
|
||||
</ButtonCircle>
|
||||
<ButtonCircle class="next shadow-box-dark" label="Next" disabled={!canGoPrev} clone={true} on:click={goToNext}>
|
||||
<IconArrow color="pink" />
|
||||
</ButtonCircle>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="photo-page__index title-index">
|
||||
<SplitText text="{(currentPhotoIndex < 10) ? '0' : ''}{currentPhotoIndex}" mode="chars" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="photo-page__info">
|
||||
<h1 class="title-medium">{currentPhoto.title}</h1>
|
||||
|
||||
<div class="detail text-info">
|
||||
<a href="/{location.country.slug}/{location.slug}" data-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>
|
||||
{#if currentPhoto.date_taken}
|
||||
<span class="sep">·</span>
|
||||
<time datetime={dayjs(currentPhoto.date_taken).format('YYYY-MM-DD')}>{dayjs(currentPhoto.date_taken).format('MMMM YYYY')}</time>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isFullscreen}
|
||||
<div class="photo-page__fullscreen" bind:this={fullscreenEl} on:click={toggleFullscreen}
|
||||
in:fade={{ easing: quartOut, duration: 1000 }}
|
||||
out:fade={{ easing: quartOut, duration: 1000, delay: 300 }}
|
||||
>
|
||||
<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}
|
||||
</main>
|
||||
</PageTransition>
|
||||
Reference in New Issue
Block a user