505 lines
18 KiB
Svelte
505 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { browser } from '$app/env'
|
|
import { page } from '$app/stores'
|
|
import { goto } from '$app/navigation'
|
|
import { getContext, onMount } from 'svelte'
|
|
import { fly } from 'svelte/transition'
|
|
import { quartOut } from 'svelte/easing'
|
|
import dayjs from 'dayjs'
|
|
import relativeTime from 'dayjs/plugin/relativeTime.js'
|
|
import anime from 'animejs'
|
|
import type { AnimeTimelineInstance } from 'animejs'
|
|
import { fetchAPI } from '$utils/api'
|
|
import { map, lerp, throttle } from '$utils/functions'
|
|
// 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 Shop from '$components/organisms/Shop.svelte'
|
|
import Newsletter from '$components/organisms/Newsletter.svelte'
|
|
|
|
export let photos: any[]
|
|
export let totalPhotos: number
|
|
export let filteredCountryExists: boolean
|
|
|
|
dayjs.extend(relativeTime)
|
|
|
|
const { countries }: any = getContext('global')
|
|
|
|
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 = import.meta.env.VITE_FILTERS_DEFAULT_COUNTRY
|
|
const defaultSort: string = import.meta.env.VITE_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 = fetchAPI(`
|
|
query {
|
|
photos: photo (
|
|
filter: {
|
|
${filterCountry !== 'all' ? `location: { country: { slug: { _eq: "${filterCountry}" }} },` : ''}
|
|
status: { _eq: "published" },
|
|
},
|
|
sort: "${filterSort === 'latest' ? '-' : ''}date_created",
|
|
limit: ${import.meta.env.VITE_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
|
|
|
|
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(import.meta.env.VITE_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)()
|
|
}
|
|
|
|
|
|
/**
|
|
* Transition: Anime timeline
|
|
*/
|
|
let timeline: AnimeTimelineInstance
|
|
|
|
if (browser) {
|
|
requestAnimationFrame(() => {
|
|
// Setup animations
|
|
timeline = anime.timeline({
|
|
duration: 1600,
|
|
easing: 'easeOutQuart',
|
|
autoplay: false,
|
|
})
|
|
|
|
// Reveal text
|
|
timeline.add({
|
|
targets: '.photos__intro .discover',
|
|
translateY: [16, 0],
|
|
opacity: [0, 1],
|
|
}, 900)
|
|
|
|
// Filters
|
|
timeline.add({
|
|
targets: '.photos__intro .filter',
|
|
translateY: [16, 0],
|
|
opacity: [0, 1],
|
|
complete ({ animatables }) {
|
|
const element = animatables[0].target
|
|
// Remove style to not interfere with CSS when scrolling back up over photos
|
|
element.removeAttribute('style')
|
|
}
|
|
}, 1300)
|
|
})
|
|
}
|
|
|
|
|
|
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 10%'
|
|
})
|
|
|
|
// Photos MutationObserver
|
|
mutationPhotos = new MutationObserver((mutationsList, observer) => {
|
|
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))
|
|
|
|
// Transition in
|
|
requestAnimationFrame(() => {
|
|
timeline.play()
|
|
})
|
|
|
|
|
|
// Destroy
|
|
return () => {
|
|
// Disconnect observers
|
|
observerPhotos && observerPhotos.disconnect()
|
|
mutationPhotos && mutationPhotos.disconnect()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<Metas
|
|
title="Houses Of - {filterCountry === defaultCountry ? `Photos` : `Photos of ${currentCountry.name}`}"
|
|
description=""
|
|
image=""
|
|
/>
|
|
|
|
<svelte:window
|
|
bind:scrollY bind:innerWidth bind:innerHeight
|
|
on:scroll={setSidesMargin}
|
|
/>
|
|
|
|
|
|
<PageTransition name="photos">
|
|
<section class="photos__intro"
|
|
class:is-passed={scrolledPastIntro}
|
|
>
|
|
<ScrollingTitle tag="h1" text="Houses">
|
|
<SplitText text="Houses" mode="chars" />
|
|
</ScrollingTitle>
|
|
|
|
<DiscoverText />
|
|
|
|
<div class="filter"
|
|
class:is-over={filtersOver}
|
|
class:is-transitioning={filtersTransitioning}
|
|
class:is-visible={filtersVisible}
|
|
>
|
|
<span class="text-label filter__label">Filter photos</span>
|
|
|
|
<div class="filter__bar">
|
|
<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 photos',
|
|
default: true,
|
|
selected: filterSort === defaultSort
|
|
},
|
|
{
|
|
value: 'oldest',
|
|
name: 'Oldest photos',
|
|
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>
|
|
|
|
<div class="filter__actions">
|
|
{#if filtered}
|
|
<button class="reset button-link"
|
|
on:click={resetFiltered}
|
|
transition:fly={{ y: 4, duration: 600, easing: quartOut }}
|
|
>
|
|
Reset
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="photos__content" style="--margin-sides: {sideMargins}px;" bind:this={photosContentEl}>
|
|
<div class="grid container">
|
|
{#if photos}
|
|
<div class="photos__grid" bind:this={photosGridEl}>
|
|
{#each photos as { id, image, slug, location, title, city }, index (id)}
|
|
<figure class="photo shadow-photo">
|
|
<a href="/{location.country.slug}/{location.slug}/{slug}" sveltekit:prefetch sveltekit:noscroll 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__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">
|
|
<Shop />
|
|
<Newsletter theme="light" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</PageTransition> |