- use a page classed div for PageTransition which avoids to make global style for the page - fix the loading spinner that was too short and would come and go before arriving on the page, now fades out when changing page as pageLoading is defined on the PageTransition afterUpdate
499 lines
19 KiB
Svelte
499 lines
19 KiB
Svelte
<style lang="scss">
|
|
@import "../../style/pages/photos";
|
|
</style>
|
|
|
|
<script lang="ts">
|
|
import { page, navigating } from '$app/stores'
|
|
import { goto } from '$app/navigation'
|
|
import type { PageData } from './$types'
|
|
import { getContext, onMount } from 'svelte'
|
|
import { fly } from 'svelte/transition'
|
|
import { quartOut as quartOutSvelte } from 'svelte/easing'
|
|
import dayjs from 'dayjs'
|
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
|
import { stagger, timeline } from 'motion'
|
|
import { DELAY } from '$utils/contants'
|
|
import { map, lerp, throttle } from '$utils/functions'
|
|
import { getAssetUrlKey } from '$utils/api'
|
|
import { quartOut } from '$animations/easings'
|
|
import { PUBLIC_FILTERS_DEFAULT_COUNTRY, PUBLIC_FILTERS_DEFAULT_SORT, PUBLIC_GRID_INCREMENT } from '$env/static/public'
|
|
// Components
|
|
import Metas from '$components/Metas.svelte'
|
|
import PageTransition from '$components/PageTransition.svelte'
|
|
import SplitText from '$components/SplitText.svelte'
|
|
import IconEarth from '$components/atoms/IconEarth.svelte'
|
|
import Button from '$components/atoms/Button.svelte'
|
|
import Image from '$components/atoms/Image.svelte'
|
|
import ScrollingTitle from '$components/atoms/ScrollingTitle.svelte'
|
|
import DiscoverText from '$components/atoms/DiscoverText.svelte'
|
|
import PostCard from '$components/molecules/PostCard.svelte'
|
|
import Select from '$components/molecules/Select.svelte'
|
|
import ShopModule from '$components/organisms/ShopModule.svelte'
|
|
import NewsletterModule from '$components/organisms/NewsletterModule.svelte'
|
|
|
|
export let data: PageData
|
|
|
|
let { photos, totalPhotos }: { photos: any[], totalPhotos: number } = data
|
|
$: ({ photos, totalPhotos } = data)
|
|
const { filteredCountryExists, settings }: { filteredCountryExists: boolean, settings: any } = data
|
|
const { countries, locations }: any = getContext('global')
|
|
|
|
dayjs.extend(relativeTime)
|
|
|
|
let photosContentEl: HTMLElement
|
|
let photosGridEl: HTMLElement
|
|
let observerPhotos: IntersectionObserver
|
|
let mutationPhotos: MutationObserver
|
|
let scrollY: number
|
|
let innerWidth: number, innerHeight: number
|
|
// Filters related
|
|
let scrollDirection = 0
|
|
let lastScrollTop = 0
|
|
let scrolledPastIntro: boolean
|
|
let filtersOver: boolean
|
|
let filtersVisible: boolean
|
|
let filtersTransitioning: boolean
|
|
|
|
|
|
/**
|
|
* Filters
|
|
*/
|
|
const defaultCountry: string = PUBLIC_FILTERS_DEFAULT_COUNTRY
|
|
const defaultSort: string = PUBLIC_FILTERS_DEFAULT_SORT
|
|
const urlFiltersParams = new URLSearchParams()
|
|
let filtered: boolean
|
|
let filterCountry = $page.url.searchParams.get('country') || defaultCountry
|
|
let filterSort = $page.url.searchParams.get('sort') || defaultSort
|
|
let countryFlagId: string
|
|
$: filtered = filterCountry !== defaultCountry || filterSort !== defaultSort
|
|
$: latestPhoto = photos && photos[0]
|
|
$: currentCountry = countries.find((country: any) => country.slug === filterCountry)
|
|
|
|
// Pages related informations
|
|
let currentPage = 1
|
|
let ended: boolean
|
|
let currentPhotosAmount: number
|
|
$: currentPhotosAmount = photos && photos.length
|
|
$: ended = currentPhotosAmount === totalPhotos
|
|
|
|
|
|
/**
|
|
* Container margins
|
|
*/
|
|
let scrollProgress: number
|
|
let sideMargins: number = innerWidth < 1200 ? 16 : 8
|
|
$: viewportScroll = (innerHeight / innerWidth) <= 0.6 ? innerHeight * 1.5 : innerHeight
|
|
|
|
// Define sides margin on scroll
|
|
const setSidesMargin = throttle(() => {
|
|
if (window.innerWidth >= 992) {
|
|
scrollProgress = map(scrollY, 0, viewportScroll, 0, 1, true)
|
|
sideMargins = lerp(innerWidth < 1200 ? 16 : 8, 30, scrollProgress)
|
|
}
|
|
}, 50)
|
|
|
|
|
|
/**
|
|
* Handle URL query params
|
|
*/
|
|
$: countryFlagId = currentCountry ? currentCountry.flag.id : undefined
|
|
|
|
// Update URL filtering params from filter values
|
|
const applyFilters = () => {
|
|
urlFiltersParams.set('country', filterCountry)
|
|
urlFiltersParams.set('sort', filterSort)
|
|
|
|
let path = `${$page.url.pathname}?${urlFiltersParams.toString()}`
|
|
goto(path, { replaceState: true, keepfocus: true, noscroll: true })
|
|
}
|
|
|
|
|
|
/**
|
|
* Define small photo size from index
|
|
* With different grid patterns depending on window width
|
|
*/
|
|
$: isSmall = (index: number) => {
|
|
let modulo = index % 5
|
|
let notOn = [0]
|
|
|
|
// Change pattern on small desktop
|
|
if (innerWidth >= 768 && innerWidth < 1200) {
|
|
modulo = index % 11
|
|
notOn = [0, 7, 10]
|
|
} else if (innerWidth >= 1200) {
|
|
// Disable on larger desktop
|
|
return false
|
|
}
|
|
return !notOn.includes(modulo)
|
|
}
|
|
|
|
|
|
/**
|
|
* Filters change events
|
|
*/
|
|
// Country select
|
|
const handleCountryChange = ({ detail: value }) => {
|
|
filterCountry = value === defaultCountry ? defaultCountry : value
|
|
currentPage = 1
|
|
applyFilters()
|
|
}
|
|
|
|
// Sort select
|
|
const handleSortChange = ({ detail: value }) => {
|
|
filterSort = value === defaultSort ? defaultSort : value
|
|
currentPage = 1
|
|
applyFilters()
|
|
}
|
|
|
|
// Reset filters
|
|
const resetFiltered = () => {
|
|
filterCountry = defaultCountry
|
|
filterSort = defaultSort
|
|
currentPage = 1
|
|
applyFilters()
|
|
}
|
|
|
|
|
|
/**
|
|
* Load photos
|
|
*/
|
|
// [function] Load photos helper
|
|
const loadPhotos = async (page: number) => {
|
|
const res = await fetch('/api/data', {
|
|
method: 'POST',
|
|
body: `query {
|
|
photos: photo (
|
|
filter: {
|
|
${filterCountry !== 'all' ? `location: { country: { slug: { _eq: "${filterCountry}" }} },` : ''}
|
|
status: { _eq: "published" },
|
|
},
|
|
sort: "${filterSort === 'latest' ? '-' : ''}date_created",
|
|
limit: ${PUBLIC_GRID_INCREMENT},
|
|
page: ${page},
|
|
) {
|
|
id
|
|
title
|
|
slug
|
|
image {
|
|
id
|
|
title
|
|
}
|
|
location {
|
|
slug
|
|
name
|
|
region
|
|
country {
|
|
slug
|
|
name
|
|
flag { id }
|
|
}
|
|
}
|
|
city
|
|
}
|
|
}`,
|
|
})
|
|
|
|
const { data: { photos }} = await res.json()
|
|
|
|
if (photos) {
|
|
// Return new photos
|
|
return photos
|
|
} else {
|
|
throw new Error('Error while loading new photos')
|
|
}
|
|
}
|
|
|
|
// Load more photos from CTA
|
|
const loadMorePhotos = async () => {
|
|
// Append more photos from API including options and page
|
|
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_GRID_INCREMENT)) {
|
|
// Increment the current page
|
|
currentPage++
|
|
}
|
|
|
|
// Increment the currently visible amount of photos
|
|
currentPhotosAmount += newPhotos.length
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Scroll detection when entering content
|
|
*/
|
|
$: if (scrollY) {
|
|
// Detect scroll direction
|
|
throttle(() => {
|
|
scrollDirection = scrollY > lastScrollTop ? 1 : -1
|
|
lastScrollTop = scrollY
|
|
|
|
// Show filters bar when scrolling back up
|
|
filtersVisible = scrollDirection < 0
|
|
|
|
// Scrolled past grid of photos
|
|
if (scrollY > photosContentEl.offsetTop) {
|
|
if (!scrolledPastIntro) {
|
|
filtersOver = true
|
|
// Hacky: Set filters as transitioning after a little delay to avoid an transition jump
|
|
setTimeout(() => filtersTransitioning = true, 30)
|
|
}
|
|
scrolledPastIntro = true
|
|
} else {
|
|
if (scrolledPastIntro) {
|
|
filtersOver = false
|
|
filtersTransitioning = false
|
|
}
|
|
scrolledPastIntro = false
|
|
}
|
|
}, 200)()
|
|
}
|
|
|
|
|
|
onMount(() => {
|
|
/**
|
|
* Observers
|
|
*/
|
|
// 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.25,
|
|
rootMargin: '0px 0px 15%'
|
|
})
|
|
|
|
// Photos MutationObserver
|
|
mutationPhotos = new MutationObserver((mutationsList) => {
|
|
for (const mutation of mutationsList) {
|
|
// When adding new childs
|
|
if (mutation.type === 'childList') {
|
|
// Observe new items
|
|
mutation.addedNodes.forEach((item: HTMLElement) => observerPhotos.observe(item))
|
|
}
|
|
}
|
|
})
|
|
mutationPhotos.observe(photosGridEl, {
|
|
childList: true,
|
|
})
|
|
|
|
// Observe existing elements
|
|
const existingPhotos = photosGridEl.querySelectorAll('.photo')
|
|
existingPhotos.forEach(el => observerPhotos.observe(el))
|
|
|
|
|
|
/**
|
|
* Animations
|
|
*/
|
|
const animation = timeline([
|
|
// Reveal text
|
|
['.photos-page__intro .discover, .photos-page__intro .filters__bar', {
|
|
y: [16, 0],
|
|
opacity: [0, 1],
|
|
}, {
|
|
at: 0.5,
|
|
delay: stagger(0.3),
|
|
}]
|
|
], {
|
|
delay: $navigating ? DELAY.PAGE_LOADING / 1000 : 0,
|
|
defaultOptions: {
|
|
duration: 1.6,
|
|
easing: quartOut,
|
|
},
|
|
})
|
|
animation.stop()
|
|
|
|
// Run animation
|
|
requestAnimationFrame(animation.play)
|
|
|
|
|
|
// Destroy
|
|
return () => {
|
|
// Disconnect observers
|
|
observerPhotos && observerPhotos.disconnect()
|
|
mutationPhotos && mutationPhotos.disconnect()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<Metas
|
|
title="{filterCountry === defaultCountry ? `All Photos` : `Photos of ${currentCountry.name}`} - Houses Of"
|
|
description="Discover {totalPhotos} homes from {filterCountry === defaultCountry ? `${locations.length} places of ${countries.length} countries in the World` : currentCountry.name}"
|
|
image={getAssetUrlKey(settings.seo_image_photos.id, 'share-image')}
|
|
/>
|
|
|
|
<svelte:window
|
|
bind:scrollY bind:innerWidth bind:innerHeight
|
|
on:scroll={setSidesMargin}
|
|
/>
|
|
|
|
|
|
<PageTransition>
|
|
<main class="photos-page">
|
|
<section class="photos-page__intro"
|
|
class:is-passed={scrolledPastIntro}
|
|
>
|
|
<ScrollingTitle tag="h1" text="Houses">
|
|
<SplitText text="Houses" mode="chars" />
|
|
</ScrollingTitle>
|
|
|
|
<DiscoverText />
|
|
|
|
<div class="filters"
|
|
class:is-over={filtersOver}
|
|
class:is-transitioning={filtersTransitioning}
|
|
class:is-visible={filtersVisible}
|
|
>
|
|
<div class="filters__bar">
|
|
<span class="text-label filters__label">Filter photos</span>
|
|
<ul>
|
|
<li>
|
|
<Select
|
|
name="country" id="filter_country"
|
|
options={[
|
|
{
|
|
value: defaultCountry,
|
|
name: 'Worldwide',
|
|
default: true,
|
|
selected: filterCountry === defaultCountry,
|
|
},
|
|
...countries.map(({ slug, name }) => ({
|
|
value: slug,
|
|
name,
|
|
selected: filterCountry === slug,
|
|
}))
|
|
]}
|
|
on:change={handleCountryChange}
|
|
value={filterCountry}
|
|
>
|
|
{#if countryFlagId}
|
|
<Image
|
|
class="icon"
|
|
id={countryFlagId}
|
|
sizeKey="square-small"
|
|
width={26} height={26}
|
|
alt="{filterCountry} flag"
|
|
/>
|
|
{:else}
|
|
<IconEarth class="icon" />
|
|
{/if}
|
|
</Select>
|
|
</li>
|
|
<li>
|
|
<Select
|
|
name="sort" id="filter_sort"
|
|
options={[
|
|
{
|
|
value: 'latest',
|
|
name: 'Latest',
|
|
default: true,
|
|
selected: filterSort === defaultSort
|
|
},
|
|
{
|
|
value: 'oldest',
|
|
name: 'Oldest',
|
|
selected: filterSort === 'oldest'
|
|
},
|
|
]}
|
|
on:change={handleSortChange}
|
|
value={filterSort}
|
|
>
|
|
<svg class="icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-label="Sort icon">
|
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.878 15.93h-4.172c-.638 0-1.153.516-1.153 1.154 0 .639.515 1.154 1.153 1.154h4.172c.638 0 1.153-.515 1.153-1.154a1.152 1.152 0 0 0-1.153-1.153Zm3.244-5.396h-7.405c-.639 0-1.154.515-1.154 1.153 0 .639.515 1.154 1.154 1.154h7.405c.639 0 1.154-.515 1.154-1.154a1.145 1.145 0 0 0-1.154-1.153Zm3.244-5.408h-10.65c-.638 0-1.153.515-1.153 1.154 0 .639.515 1.154 1.154 1.154h10.65c.638 0 1.153-.515 1.153-1.154 0-.639-.515-1.154-1.154-1.154ZM7.37 20.679V3.376c0-.145-.03-.289-.082-.433a1.189 1.189 0 0 0-.628-.618 1.197 1.197 0 0 0-.886 0 1.045 1.045 0 0 0-.36.237c-.01 0-.01 0-.021.01L.82 7.145a1.156 1.156 0 0 0 0 1.638 1.156 1.156 0 0 0 1.637 0l2.596-2.596v11.7l-2.596-2.595a1.156 1.156 0 0 0-1.637 0 1.156 1.156 0 0 0 0 1.638l4.573 4.573c.103.103.237.185.37.247.135.062.289.082.433.082h.02c.145 0 .3-.03.433-.093a1.14 1.14 0 0 0 .629-.628.987.987 0 0 0 .092-.432Z" />
|
|
</svg>
|
|
</Select>
|
|
</li>
|
|
</ul>
|
|
|
|
<div class="filters__actions">
|
|
{#if filtered}
|
|
<button class="reset button-link"
|
|
on:click={resetFiltered}
|
|
transition:fly={{ y: 4, duration: 600, easing: quartOutSvelte }}
|
|
>
|
|
Reset
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="photos-page__content" bind:this={photosContentEl} style:--margin-sides="{sideMargins}px">
|
|
<div class="grid container">
|
|
{#if photos}
|
|
<div class="photos-page__grid" bind:this={photosGridEl} data-sveltekit-noscroll data-sveltekit-prefetch>
|
|
{#each photos as { id, image, slug, location, title, city }, index (id)}
|
|
<figure class="photo shadow-photo">
|
|
<a href="/{location.country.slug}/{location.slug}/{slug}" tabindex="0">
|
|
<Image
|
|
id={image.id}
|
|
sizeKey="photo-grid"
|
|
sizes={{
|
|
small: { width: 500 },
|
|
medium: { width: 900 },
|
|
large: { width: 1440 },
|
|
}}
|
|
ratio={1.5}
|
|
alt={image.title}
|
|
/>
|
|
</a>
|
|
<figcaption>
|
|
<PostCard
|
|
street={title}
|
|
location={city ? `${city}, ${location.name}` : location.name}
|
|
region={location.region}
|
|
country={location.country.name}
|
|
flagId={location.country.flag.id}
|
|
size={isSmall(index) ? 'small' : null}
|
|
/>
|
|
</figcaption>
|
|
</figure>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="controls grid">
|
|
<p class="controls__date" title={dayjs(latestPhoto.date_created).format('DD/MM/YYYY, hh:mm')}>
|
|
Last updated: <time datetime={dayjs(latestPhoto.date_created).format('YYYY-MM-DD')}>{dayjs().to(dayjs(latestPhoto.date_created))}</time>
|
|
</p>
|
|
|
|
<Button
|
|
tag="button"
|
|
text={!ended ? 'See more photos' : "You've seen it all!"}
|
|
size="large" color="beige"
|
|
on:click={loadMorePhotos}
|
|
disabled={ended}
|
|
/>
|
|
|
|
<div class="controls__count">
|
|
<span class="current">{currentPhotosAmount}</span>
|
|
<span>/</span>
|
|
<span class="total">{totalPhotos}</span>
|
|
</div>
|
|
</div>
|
|
{:else if !filteredCountryExists}
|
|
<div class="photos-page__message">
|
|
<p>
|
|
<strong>{$page.url.searchParams.get('country').replace(/(^|\s)\S/g, letter => letter.toUpperCase())}</strong> is not available… yet 👀
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="grid-modules">
|
|
<div class="wrap">
|
|
<ShopModule />
|
|
<NewsletterModule theme="light" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</PageTransition> |