🚧 Switch to monorepo with Turbo

This commit is contained in:
2023-01-10 12:53:42 +01:00
parent dd8715bb34
commit 25bb949a13
205 changed files with 14975 additions and 347 deletions

View File

@@ -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)
}
}

View 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}
&middot;
<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>

View File

@@ -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)
}
}

View File

@@ -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">&middot;</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>