🚧 Switch to monorepo with Turbo
This commit is contained in:
91
apps/website/src/routes/(site)/photos/+page.server.ts
Normal file
91
apps/website/src/routes/(site)/photos/+page.server.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
import { PUBLIC_FILTERS_DEFAULT_COUNTRY, PUBLIC_FILTERS_DEFAULT_SORT, PUBLIC_GRID_AMOUNT } from '$env/static/public'
|
||||
|
||||
|
||||
/**
|
||||
* Page Data
|
||||
*/
|
||||
export const load: PageServerLoad = async ({ url, setHeaders }) => {
|
||||
try {
|
||||
// Query parameters
|
||||
const queryCountry = url.searchParams.get('country') || PUBLIC_FILTERS_DEFAULT_COUNTRY
|
||||
const querySort = url.searchParams.get('sort') || PUBLIC_FILTERS_DEFAULT_SORT
|
||||
|
||||
// Query
|
||||
const res = await fetchAPI(`query {
|
||||
photos: photo (
|
||||
filter: {
|
||||
${queryCountry !== 'all' ? `location: { country: { slug: { _eq: "${queryCountry}" }}},` : ''}
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
sort: "${querySort === 'latest' ? '-' : ''}date_created",
|
||||
limit: ${PUBLIC_GRID_AMOUNT},
|
||||
page: 1,
|
||||
) {
|
||||
id
|
||||
title
|
||||
slug
|
||||
image {
|
||||
id
|
||||
title
|
||||
}
|
||||
location {
|
||||
slug
|
||||
name
|
||||
region
|
||||
country {
|
||||
slug
|
||||
name
|
||||
flag { id }
|
||||
}
|
||||
}
|
||||
city
|
||||
date_created
|
||||
}
|
||||
|
||||
country: country (
|
||||
filter: {
|
||||
slug: { _eq: "${queryCountry}" },
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
) {
|
||||
slug
|
||||
}
|
||||
|
||||
# Total
|
||||
total_published: photo_aggregated ${queryCountry !== 'all' ? `(
|
||||
filter: {
|
||||
location: { country: { slug: { _eq: "${queryCountry}" }}},
|
||||
status: { _eq: "published" },
|
||||
}
|
||||
)` : `(
|
||||
filter: {
|
||||
status: { _eq: "published" },
|
||||
}
|
||||
)`} {
|
||||
count { id }
|
||||
}
|
||||
|
||||
settings {
|
||||
seo_image_photos { id }
|
||||
}
|
||||
}`)
|
||||
|
||||
if (res) {
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=86399' })
|
||||
|
||||
const { data } = res
|
||||
|
||||
return {
|
||||
photos: data.photos,
|
||||
filteredCountryExists: data.country.length > 0,
|
||||
totalPhotos: data.total_published[0].count.id,
|
||||
settings: data.settings,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
499
apps/website/src/routes/(site)/photos/+page.svelte
Normal file
499
apps/website/src/routes/(site)/photos/+page.svelte
Normal file
@@ -0,0 +1,499 @@
|
||||
<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/constants'
|
||||
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>
|
||||
{#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>
|
||||
Reference in New Issue
Block a user