🚧 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>
|
||||
104
apps/website/src/routes/(site)/about/+page.server.ts
Normal file
104
apps/website/src/routes/(site)/about/+page.server.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
import { getRandomItems } from '$utils/functions'
|
||||
|
||||
export const load: PageServerLoad = async ({ setHeaders }) => {
|
||||
try {
|
||||
// Get data and total of published photos
|
||||
const res = await fetchAPI(`query {
|
||||
photos: photo (
|
||||
filter: {
|
||||
favorite: { _eq: true },
|
||||
status: { _eq: "published" },
|
||||
},
|
||||
limit: -1,
|
||||
) {
|
||||
id
|
||||
}
|
||||
|
||||
about {
|
||||
description
|
||||
intro_title
|
||||
intro_heading
|
||||
intro_text
|
||||
intro_firstphoto { id, title }
|
||||
intro_firstphoto_caption
|
||||
intro_firstlocation {
|
||||
slug
|
||||
name
|
||||
country {
|
||||
flag { id, title }
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
creation_title
|
||||
creation_heading
|
||||
creation_text
|
||||
creation_portrait { id, title }
|
||||
creation_portrait_caption
|
||||
|
||||
present_image { id, title }
|
||||
present_title
|
||||
present_heading
|
||||
present_text
|
||||
present_conclusion
|
||||
|
||||
image_showcase { id, title }
|
||||
|
||||
process_title
|
||||
process_subtitle
|
||||
process_steps {
|
||||
title
|
||||
text
|
||||
media_type
|
||||
image {
|
||||
id
|
||||
title
|
||||
width, height
|
||||
}
|
||||
video_mp4 { id }
|
||||
video_webm { id }
|
||||
}
|
||||
|
||||
contact_title
|
||||
contact_blocks
|
||||
|
||||
seo_title
|
||||
seo_description
|
||||
seo_image { id }
|
||||
}
|
||||
}`)
|
||||
const { data: { about, photos: photosIds }} = res
|
||||
|
||||
// Get random photos
|
||||
const randomPhotosIds = [...getRandomItems(photosIds, 42)].map(({ id }) => id)
|
||||
|
||||
// Query these random photos from IDs
|
||||
const photosRes = await fetchAPI(`query {
|
||||
photo (filter: { id: { _in: "${randomPhotosIds}" }}) {
|
||||
id
|
||||
title
|
||||
slug
|
||||
image {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if (photosRes) {
|
||||
const { data: { photo: photos }} = photosRes
|
||||
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=604799' })
|
||||
|
||||
return {
|
||||
about,
|
||||
photos,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
402
apps/website/src/routes/(site)/about/+page.svelte
Normal file
402
apps/website/src/routes/(site)/about/+page.svelte
Normal file
@@ -0,0 +1,402 @@
|
||||
<style lang="scss">
|
||||
@import "../../../style/pages/about";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { navigating, page } from '$app/stores'
|
||||
import { onMount, afterUpdate } from 'svelte'
|
||||
import { quartOut as quartOutSvelte } from 'svelte/easing'
|
||||
import { fade, fly } from 'svelte/transition'
|
||||
import type { PageData } from './$types'
|
||||
import { animate, inView, stagger, timeline } from 'motion'
|
||||
import { mailtoClipboard, map } from '$utils/functions'
|
||||
import { getAssetUrlKey } from '$utils/api'
|
||||
import { DELAY } from '$utils/constants'
|
||||
import { quartOut } from '$animations/easings'
|
||||
// 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 AboutGridPhoto from '$components/atoms/AboutGridPhoto.svelte'
|
||||
import ProcessStep from '$components/molecules/ProcessStep.svelte'
|
||||
import Banner from '$components/organisms/Banner.svelte'
|
||||
import { sendEvent } from '$utils/analytics';
|
||||
|
||||
export let data: PageData
|
||||
const { about, photos } = data
|
||||
|
||||
let scrollY: number, innerWidth: number, innerHeight: number
|
||||
let photosGridEl: HTMLElement
|
||||
let photosGridOffset: number = photosGridEl && photosGridEl.offsetTop
|
||||
let currentStep: number = 0
|
||||
let emailCopied: string = null
|
||||
let emailCopiedTimeout: ReturnType<typeof setTimeout> | number
|
||||
|
||||
$: parallaxPhotos = photosGridEl && map(scrollY, photosGridOffset - innerHeight, photosGridOffset + innerHeight / 1.5, 0, innerHeight * 0.15, true)
|
||||
$: fadedPhotosIndexes = innerWidth > 768
|
||||
? [0, 2, 5, 7, 9, 12, 17, 20, 22, 26, 30, 32, 34]
|
||||
: [1, 4, 5, 7, 11, 14, 17, 20, 24, 27, 30, 33, 34, 36, 40, 43]
|
||||
|
||||
|
||||
onMount(() => {
|
||||
/**
|
||||
* Animations
|
||||
*/
|
||||
const animation = timeline([
|
||||
// Banner
|
||||
['.banner picture', {
|
||||
scale: [1.06, 1],
|
||||
opacity: [0, 1],
|
||||
z: 0,
|
||||
}, {
|
||||
at: 0.4,
|
||||
duration: 2.4,
|
||||
}],
|
||||
['.banner h1', {
|
||||
y: [32, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.5,
|
||||
}],
|
||||
['.banner__top > *', {
|
||||
y: [-100, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.4,
|
||||
delay: stagger(0.25),
|
||||
}],
|
||||
|
||||
// Intro elements
|
||||
['.about__introduction .container > *', {
|
||||
y: ['20%', 0],
|
||||
opacity: [0, 1],
|
||||
z: 0,
|
||||
}, {
|
||||
at: 0.75,
|
||||
delay: stagger(0.25),
|
||||
}],
|
||||
['.first-photo', {
|
||||
y: ['10%', 0],
|
||||
opacity: [0, 1],
|
||||
z: 0,
|
||||
}, {
|
||||
at: 1.2,
|
||||
}],
|
||||
['.first-photo img', {
|
||||
scale: [1.06, 1],
|
||||
opacity: [0, 1],
|
||||
z: 0,
|
||||
}, {
|
||||
at: 1.5,
|
||||
duration: 2.4,
|
||||
}],
|
||||
], {
|
||||
delay: $navigating ? DELAY.PAGE_LOADING / 1000 : 0,
|
||||
defaultOptions: {
|
||||
duration: 1.6,
|
||||
easing: quartOut,
|
||||
},
|
||||
})
|
||||
animation.stop()
|
||||
|
||||
// Sections
|
||||
inView('[data-reveal]', ({ target }) => {
|
||||
animate(target, {
|
||||
opacity: [0, 1],
|
||||
y: ['20%', 0],
|
||||
z: 0,
|
||||
}, {
|
||||
delay: 0.2,
|
||||
duration: 1.6,
|
||||
easing: quartOut,
|
||||
})
|
||||
})
|
||||
|
||||
// Global images
|
||||
inView('[data-reveal-image] img', ({ target }) => {
|
||||
animate(target, {
|
||||
scale: [1.06, 1],
|
||||
opacity: [0, 1],
|
||||
z: 0,
|
||||
}, {
|
||||
delay: 0.3,
|
||||
duration: 2.4,
|
||||
easing: quartOut,
|
||||
})
|
||||
})
|
||||
|
||||
// Process
|
||||
const processTimeline = timeline([
|
||||
// Step links
|
||||
['.about__process li a', {
|
||||
y: [16, 0],
|
||||
opacity: [0, 1],
|
||||
z: 0,
|
||||
}, {
|
||||
at: 0,
|
||||
delay: stagger(0.15),
|
||||
}],
|
||||
|
||||
// First step
|
||||
['.about__process .step', {
|
||||
scale: [1.1, 1],
|
||||
opacity: [0, 1],
|
||||
x: [20, 0]
|
||||
}, {
|
||||
at: 0.6,
|
||||
duration: 1,
|
||||
}]
|
||||
], {
|
||||
defaultOptions: {
|
||||
duration: 1.6,
|
||||
easing: quartOut,
|
||||
}
|
||||
})
|
||||
processTimeline.stop()
|
||||
|
||||
inView('.about__process', () => {
|
||||
requestAnimationFrame(processTimeline.play)
|
||||
}, {
|
||||
amount: 0.35,
|
||||
})
|
||||
|
||||
// Run animation
|
||||
requestAnimationFrame(animation.play)
|
||||
})
|
||||
|
||||
|
||||
afterUpdate(() => {
|
||||
// Update photos grid top offset
|
||||
photosGridOffset = photosGridEl.offsetTop
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window bind:scrollY bind:innerWidth bind:innerHeight />
|
||||
|
||||
<Metas
|
||||
title={about.seo_title}
|
||||
description={about.seo_description}
|
||||
image={about.seo_image ? getAssetUrlKey(about.seo_image.id, 'share-image') : null}
|
||||
/>
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="about">
|
||||
<Banner
|
||||
title="About"
|
||||
image={{
|
||||
id: '699b4050-6bbf-4a40-be53-d84aca484f9d',
|
||||
alt: 'Photo caption',
|
||||
}}
|
||||
/>
|
||||
|
||||
<section class="about__introduction">
|
||||
<div class="container grid">
|
||||
<h2 class="title-small">{about.intro_title}</h2>
|
||||
<div class="heading text-big">
|
||||
{@html about.intro_heading}
|
||||
</div>
|
||||
|
||||
<div class="text text-small">
|
||||
{@html about.intro_text}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about__creation">
|
||||
<div class="container grid">
|
||||
<figure class="first-photo">
|
||||
<Image
|
||||
class="picture shadow-box-dark"
|
||||
id={about.intro_firstphoto.id}
|
||||
alt={about.intro_firstphoto.title}
|
||||
sizeKey="photo-list"
|
||||
sizes={{
|
||||
small: { width: 400 },
|
||||
medium: { width: 600 },
|
||||
large: { width: 800 },
|
||||
}}
|
||||
ratio={1.5}
|
||||
/>
|
||||
<figcaption class="text-info">
|
||||
{about.intro_firstphoto_caption}<br>
|
||||
in
|
||||
<a href="/{about.intro_firstlocation.country.slug}/{about.intro_firstlocation.slug}" data-sveltekit-noscroll>
|
||||
<img src="{getAssetUrlKey(about.intro_firstlocation.country.flag.id, 'square-small-jpg')}" width="32" height="32" alt="{about.intro_firstlocation.country.flag.title}">
|
||||
<span>Naarm Australia (Melbourne)</span>
|
||||
</a>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 class="title-small" data-reveal>{about.creation_title}</h2>
|
||||
<div class="heading text-huge" data-reveal>
|
||||
{@html about.creation_heading}
|
||||
</div>
|
||||
|
||||
<div class="text text-small" data-reveal>
|
||||
{@html about.creation_text}
|
||||
</div>
|
||||
|
||||
<figure class="picture portrait-photo" data-reveal-image>
|
||||
<Image
|
||||
class="shadow-box-dark"
|
||||
id={about.creation_portrait.id}
|
||||
alt={about.creation_portrait.title}
|
||||
sizeKey="photo-list"
|
||||
sizes={{
|
||||
small: { width: 400 },
|
||||
medium: { width: 750 },
|
||||
}}
|
||||
ratio={1.425}
|
||||
/>
|
||||
</figure>
|
||||
<span class="portrait-photo__caption text-info">
|
||||
{about.creation_portrait_caption}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about__present">
|
||||
<div class="container grid">
|
||||
<figure class="picture" data-reveal-image>
|
||||
<Image
|
||||
class="shadow-box-dark"
|
||||
id={about.present_image.id}
|
||||
alt={about.present_image.title}
|
||||
sizeKey="photo-list"
|
||||
sizes={{
|
||||
small: { width: 400 },
|
||||
medium: { width: 600 },
|
||||
large: { width: 800 },
|
||||
}}
|
||||
ratio={1.5}
|
||||
/>
|
||||
</figure>
|
||||
|
||||
<h2 class="title-small" data-reveal>{about.present_title}</h2>
|
||||
<div class="text text-small" data-reveal>
|
||||
<p>{about.present_text}</p>
|
||||
</div>
|
||||
|
||||
<div class="heading text-big" data-reveal>
|
||||
{@html about.present_heading}
|
||||
</div>
|
||||
|
||||
<div class="conclusion text-small" data-reveal>
|
||||
<p>{about.present_conclusion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if about.image_showcase}
|
||||
<div class="about__showcase container grid">
|
||||
<Image
|
||||
id={about.image_showcase.id}
|
||||
alt={about.image_showcase.title}
|
||||
sizeKey="showcase"
|
||||
sizes={{
|
||||
small: { width: 400 },
|
||||
medium: { width: 1000 },
|
||||
large: { width: 1800 },
|
||||
}}
|
||||
ratio={1.2}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section class="about__process">
|
||||
<div class="container grid">
|
||||
<aside>
|
||||
<div class="heading">
|
||||
<h2 class="title-medium">{about.process_title}</h2>
|
||||
<p class="text-xsmall">{about.process_subtitle}</p>
|
||||
</div>
|
||||
|
||||
<ol>
|
||||
{#each about.process_steps as { title }, index}
|
||||
<li class:is-active={index === currentStep}>
|
||||
<a href="#step-{index + 1}" class="title-big"
|
||||
on:click|preventDefault={() => {
|
||||
currentStep = index
|
||||
sendEvent('aboutStepSwitch')
|
||||
}}
|
||||
>
|
||||
<span>{title}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</aside>
|
||||
|
||||
<div class="steps">
|
||||
{#each about.process_steps as { text, image, video_mp4, video_webm }, index}
|
||||
{#if index === currentStep}
|
||||
<ProcessStep
|
||||
{index} {text}
|
||||
image={image ?? undefined}
|
||||
video={{
|
||||
mp4: video_mp4?.id,
|
||||
webm: video_webm?.id
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about__photos" bind:this={photosGridEl}>
|
||||
<div class="container-wide">
|
||||
<div class="photos-grid" style:--parallax-y="{parallaxPhotos}px">
|
||||
{#each photos as { image: { id }, title }, index}
|
||||
<AboutGridPhoto class="grid-photo"
|
||||
{id}
|
||||
alt={title}
|
||||
disabled={fadedPhotosIndexes.includes(index)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about__interest container grid">
|
||||
<div class="container grid">
|
||||
<h2 class="title-xl">{about.contact_title}</h2>
|
||||
<div class="blocks">
|
||||
{#each about.contact_blocks as { title, text, link, button }}
|
||||
<div class="block">
|
||||
<h3 class="text-label">{title}</h3>
|
||||
<div class="text text-normal">
|
||||
{@html text}
|
||||
</div>
|
||||
<div class="button-container">
|
||||
{#if link}
|
||||
{#key emailCopied === link}
|
||||
<div class="wrap"
|
||||
in:fly={{ y: 4, duration: 325, easing: quartOutSvelte, delay: 250 }}
|
||||
out:fade={{ duration: 250, easing: quartOutSvelte }}
|
||||
use:mailtoClipboard
|
||||
on:copied={({ detail }) => {
|
||||
emailCopied = detail.email
|
||||
// Clear timeout and add timeout to hide message
|
||||
clearTimeout(emailCopiedTimeout)
|
||||
emailCopiedTimeout = setTimeout(() => emailCopied = null, 2500)
|
||||
}}
|
||||
>
|
||||
{#if emailCopied !== link}
|
||||
<Button size="small" url="mailto:{link}" text={button} />
|
||||
{:else}
|
||||
<span class="clipboard">Email copied in clipboard</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</PageTransition>
|
||||
25
apps/website/src/routes/(site)/api/data/+server.ts
Normal file
25
apps/website/src/routes/(site)/api/data/+server.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { RequestHandler } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
|
||||
|
||||
export const POST: RequestHandler = async ({ request, setHeaders }) => {
|
||||
try {
|
||||
const body = await request.text()
|
||||
|
||||
if (body) {
|
||||
const req = await fetchAPI(body)
|
||||
const res = await req
|
||||
|
||||
if (res) {
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=59' })
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
...res
|
||||
}))
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
43
apps/website/src/routes/(site)/credits/+page.server.ts
Normal file
43
apps/website/src/routes/(site)/credits/+page.server.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
|
||||
export const load: PageServerLoad = async ({ setHeaders }) => {
|
||||
try {
|
||||
const res = await fetchAPI(`query {
|
||||
credits {
|
||||
text
|
||||
list
|
||||
}
|
||||
|
||||
credit (filter: { status: { _eq: "published" }}) {
|
||||
name
|
||||
website
|
||||
location {
|
||||
location_id (filter: { status: { _eq: "published" }}) {
|
||||
name
|
||||
slug
|
||||
country {
|
||||
slug
|
||||
flag {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if (res) {
|
||||
const { data } = res
|
||||
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=604799' })
|
||||
|
||||
return {
|
||||
...data
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
149
apps/website/src/routes/(site)/credits/+page.svelte
Normal file
149
apps/website/src/routes/(site)/credits/+page.svelte
Normal file
@@ -0,0 +1,149 @@
|
||||
<style lang="scss">
|
||||
@import "../../../style/pages/credits";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { navigating } from '$app/stores'
|
||||
import type { PageData } from './$types'
|
||||
import { onMount } from 'svelte'
|
||||
import { stagger, timeline } from 'motion'
|
||||
import { DELAY } from '$utils/constants'
|
||||
import { quartOut } from 'svelte/easing'
|
||||
// Components
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import Image from '$components/atoms/Image.svelte'
|
||||
import Heading from '$components/molecules/Heading.svelte'
|
||||
import InteractiveGlobe from '$components/organisms/InteractiveGlobe.svelte'
|
||||
|
||||
export let data: PageData
|
||||
const { credit } = data
|
||||
|
||||
|
||||
onMount(() => {
|
||||
/**
|
||||
* Animations
|
||||
*/
|
||||
const animation = timeline([
|
||||
// Heading
|
||||
['.heading .text', {
|
||||
y: [24, 0],
|
||||
opacity: [0, 1],
|
||||
}],
|
||||
|
||||
// Categories
|
||||
['.credits__category', {
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0,
|
||||
delay: stagger(0.35, { start: 0.5 }),
|
||||
}],
|
||||
|
||||
// Names
|
||||
['.credits__category > ul > li', {
|
||||
y: [24, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 1.1,
|
||||
delay: stagger(0.35),
|
||||
}],
|
||||
], {
|
||||
delay: $navigating ? DELAY.PAGE_LOADING / 1000 : 0,
|
||||
defaultOptions: {
|
||||
duration: 1.6,
|
||||
easing: quartOut,
|
||||
},
|
||||
})
|
||||
animation.stop()
|
||||
|
||||
// Run animation
|
||||
requestAnimationFrame(animation.play)
|
||||
})
|
||||
</script>
|
||||
|
||||
<Metas
|
||||
title="Credits – Houses Of"
|
||||
description={data.credits.text}
|
||||
/>
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="credits">
|
||||
<Heading
|
||||
text={data.credits.text}
|
||||
/>
|
||||
|
||||
<section class="credits__list">
|
||||
<div class="grid container">
|
||||
{#each data.credits.list as { title, credits }}
|
||||
<div class="credits__category grid">
|
||||
<h2 class="title-small">{title}</h2>
|
||||
<ul>
|
||||
{#each credits as { name, role, website }}
|
||||
<li>
|
||||
<dl>
|
||||
<dt>
|
||||
{#if website}
|
||||
<h3>
|
||||
<a href={website} rel="noopener external" target="_blank" tabindex="0">{name}</a>
|
||||
</h3>
|
||||
{:else}
|
||||
<h3>{name}</h3>
|
||||
{/if}
|
||||
</dt>
|
||||
<dd>
|
||||
{role}
|
||||
</dd>
|
||||
</dl>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="credits__category grid">
|
||||
<h2 class="title-small">Photography</h2>
|
||||
<ul>
|
||||
{#each credit as { name, website, location }}
|
||||
<li>
|
||||
<dl>
|
||||
<dt>
|
||||
{#if website}
|
||||
<h3>
|
||||
<a href={website} rel="noopener external" target="_blank" tabindex="0">{name}</a>
|
||||
</h3>
|
||||
{:else}
|
||||
<h3>{name}</h3>
|
||||
{/if}
|
||||
</dt>
|
||||
<dd>
|
||||
<ul data-sveltekit-noscroll>
|
||||
{#each location as loc}
|
||||
{#if loc.location_id}
|
||||
<li>
|
||||
<a href="/{loc.location_id.country.slug}/{loc.location_id.slug}" tabindex="0">
|
||||
<Image
|
||||
id={loc.location_id.country.flag.id}
|
||||
sizeKey="square-small"
|
||||
width={16}
|
||||
height={16}
|
||||
alt="Flag of {loc.location_id.country.slug}"
|
||||
/>
|
||||
<span>{loc.location_id.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<InteractiveGlobe type="cropped" />
|
||||
</main>
|
||||
</PageTransition>
|
||||
94
apps/website/src/routes/(site)/feed/products.xml/+server.ts
Normal file
94
apps/website/src/routes/(site)/feed/products.xml/+server.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { RequestHandler } from './$types'
|
||||
import { fetchSwell } from '$utils/functions/shopServer'
|
||||
import { fetchAPI, getAssetUrlKey } from '$utils/api'
|
||||
|
||||
const gCategories = [
|
||||
{
|
||||
id: '61851d83cd16416c78a8e5ef',
|
||||
type: 'Posters, Prints, & Visual Artwork',
|
||||
value: 'Home & Garden > Decor > Artwork > Posters, Prints, & Visual Artwork'
|
||||
}
|
||||
]
|
||||
|
||||
export const GET: RequestHandler = async ({ url, setHeaders }) => {
|
||||
try {
|
||||
const products = []
|
||||
|
||||
// Get products from Swell API
|
||||
const shopProducts: any = await fetchSwell(`/products`)
|
||||
|
||||
// Get products from site API
|
||||
const siteProducts = await fetchAPI(`query {
|
||||
products: product (filter: { status: { _eq: "published" }}) {
|
||||
location { slug }
|
||||
name
|
||||
description
|
||||
details
|
||||
product_id
|
||||
photos_product {
|
||||
directus_files_id { id }
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if (shopProducts && siteProducts) {
|
||||
const { data } = siteProducts
|
||||
|
||||
// Loop through shop products
|
||||
shopProducts.results.forEach((product: any) => {
|
||||
// Find matching product from site to platform
|
||||
const siteProduct = data.products.find((p: any) => p.product_id === product.id)
|
||||
const category = gCategories.find(p => p.id === product.category_index.id[0])
|
||||
|
||||
products.push({
|
||||
id: product.id,
|
||||
name: `${product.name} - Poster`,
|
||||
slug: siteProduct.location.slug,
|
||||
description: siteProduct.description,
|
||||
price: product.price,
|
||||
images: siteProduct.photos_product.map(({ directus_files_id: { id }}: any) => getAssetUrlKey(id, `product-large-jpg`)),
|
||||
gCategory: category.value,
|
||||
gType: category.type,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const sitemap = render(url.origin, products)
|
||||
|
||||
setHeaders({
|
||||
'Content-Type': 'application/xml',
|
||||
'Cache-Control': 'max-age=0, s-max-age=600',
|
||||
})
|
||||
|
||||
return new Response(sitemap)
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const render = (origin: string, products: any[]) => {
|
||||
return `<?xml version="1.0"?>
|
||||
<rss version="2.0" xmlns:g="http://base.google.com/ns/1.0">
|
||||
<channel>
|
||||
${products.map((product) => `<item>
|
||||
<g:id>${product.id}</g:id>
|
||||
<title>${product.name}</title>
|
||||
<description>${product.description}</description>
|
||||
<g:product_type>${product.gType}</g:product_type>
|
||||
<g:google_product_category>${product.gCategory}</g:google_product_category>
|
||||
<link>${origin}/shop/poster-${product.slug}</link>
|
||||
<g:image_link>${product.images[0]}</g:image_link>
|
||||
<g:condition>New</g:condition>
|
||||
<g:availability>In Stock</g:availability>
|
||||
<g:price>${product.price} EUR</g:price>
|
||||
<g:brand>Houses Of</g:brand>
|
||||
<g:identifier_exists>FALSE</g:identifier_exists>
|
||||
${product.images.slice(1).map((image: any) => `
|
||||
<g:additional_image_link>${image}</g:additional_image_link>
|
||||
`).join('')}
|
||||
</item>
|
||||
`).join('')}
|
||||
</channel>
|
||||
</rss>`
|
||||
}
|
||||
42
apps/website/src/routes/(site)/locations/+page.svelte
Normal file
42
apps/website/src/routes/(site)/locations/+page.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<style lang="scss">
|
||||
@import "../../../style/pages/explore";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
// Components
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import InteractiveGlobe from '$components/organisms/InteractiveGlobe.svelte'
|
||||
import Locations from '$components/organisms/Locations.svelte'
|
||||
import ShopModule from '$components/organisms/ShopModule.svelte'
|
||||
import NewsletterModule from '$components/organisms/NewsletterModule.svelte'
|
||||
import Heading from '$components/molecules/Heading.svelte'
|
||||
|
||||
const { locations }: any = getContext('global')
|
||||
const text = "Explore the globe to discover unique locations across the world"
|
||||
</script>
|
||||
|
||||
<Metas
|
||||
title="Locations – Houses Of"
|
||||
description={text}
|
||||
/>
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="explore">
|
||||
<Heading {text} />
|
||||
|
||||
<section class="explore__locations">
|
||||
<InteractiveGlobe />
|
||||
<Locations {locations} />
|
||||
</section>
|
||||
|
||||
<section class="grid-modules is-spaced grid">
|
||||
<div class="wrap">
|
||||
<ShopModule />
|
||||
<NewsletterModule />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</PageTransition>
|
||||
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>
|
||||
42
apps/website/src/routes/(site)/subscribe/+page.server.ts
Normal file
42
apps/website/src/routes/(site)/subscribe/+page.server.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
|
||||
|
||||
/**
|
||||
* Page Data
|
||||
*/
|
||||
export const load: PageServerLoad = async ({ setHeaders }) => {
|
||||
try {
|
||||
const res = await fetchAPI(`query {
|
||||
settings {
|
||||
newsletter_page_text
|
||||
}
|
||||
|
||||
newsletter (
|
||||
limit: -1,
|
||||
sort: "-issue",
|
||||
filter: { status: { _eq: "published" }},
|
||||
) {
|
||||
issue
|
||||
title
|
||||
date_sent
|
||||
link
|
||||
thumbnail { id }
|
||||
}
|
||||
}`)
|
||||
|
||||
if (res) {
|
||||
const { data } = res
|
||||
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=86399' })
|
||||
|
||||
return {
|
||||
...data.settings,
|
||||
issues: data.newsletter,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
99
apps/website/src/routes/(site)/subscribe/+page.svelte
Normal file
99
apps/website/src/routes/(site)/subscribe/+page.svelte
Normal file
@@ -0,0 +1,99 @@
|
||||
<style lang="scss">
|
||||
@import "../../../style/pages/subscribe";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { navigating } from '$app/stores'
|
||||
import type { PageData } from './$types'
|
||||
import { onMount } from 'svelte'
|
||||
import { stagger, timeline } from 'motion'
|
||||
import { DELAY } from '$utils/constants'
|
||||
import { quartOut } from '$animations/easings'
|
||||
// Components
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import Heading from '$components/molecules/Heading.svelte'
|
||||
import EmailForm from '$components/molecules/EmailForm.svelte'
|
||||
import NewsletterIssue from '$components/molecules/NewsletterIssue.svelte'
|
||||
import InteractiveGlobe from '$components/organisms/InteractiveGlobe.svelte'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
const { issues } = data
|
||||
const latestIssue = issues[0]
|
||||
|
||||
|
||||
onMount(() => {
|
||||
/**
|
||||
* Animations
|
||||
*/
|
||||
const animation = timeline([
|
||||
// Elements
|
||||
['.heading .text, .subscribe__top .newsletter-form', {
|
||||
y: [24, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
at: 0.5,
|
||||
delay: stagger(0.35),
|
||||
}],
|
||||
|
||||
// Reveal each issue
|
||||
['.subscribe__issues h2, .subscribe__issues > .issue-container, .subscribe__issues > ul', {
|
||||
y: [16, 0],
|
||||
opacity: [0, 1],
|
||||
}, {
|
||||
duration: 1,
|
||||
at: 1.5,
|
||||
delay: stagger(0.15),
|
||||
}],
|
||||
], {
|
||||
delay: $navigating ? DELAY.PAGE_LOADING / 1000 : 0,
|
||||
defaultOptions: {
|
||||
duration: 1.6,
|
||||
easing: quartOut,
|
||||
},
|
||||
})
|
||||
animation.stop()
|
||||
|
||||
// Run animation
|
||||
requestAnimationFrame(animation.play)
|
||||
})
|
||||
</script>
|
||||
|
||||
<Metas
|
||||
title="Subscribe – Houses Of"
|
||||
description="Subscribe to the Houses Of newsletter to be notified when new photos or locations are added to the site and when more prints are available on our shop"
|
||||
/>
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="subscribe">
|
||||
<div class="subscribe__top">
|
||||
<Heading
|
||||
text={data.newsletter_page_text}
|
||||
/>
|
||||
|
||||
<EmailForm />
|
||||
</div>
|
||||
|
||||
<section class="subscribe__issues">
|
||||
<h2 class="title-small">Latest Issue</h2>
|
||||
<div class="issue-container">
|
||||
<NewsletterIssue size="large" date={latestIssue.date_sent} {...latestIssue} />
|
||||
</div>
|
||||
|
||||
{#if issues.length > 1}
|
||||
<h2 class="title-small">Past Issues</h2>
|
||||
<ul>
|
||||
{#each issues.slice(1) as { issue, title, date_sent: date, link, thumbnail }}
|
||||
<li class="issue-container">
|
||||
<NewsletterIssue {issue} {title} {link} {thumbnail} {date} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<InteractiveGlobe type="cropped" />
|
||||
</main>
|
||||
</PageTransition>
|
||||
26
apps/website/src/routes/(site)/terms/+page.server.ts
Normal file
26
apps/website/src/routes/(site)/terms/+page.server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { fetchAPI } from '$utils/api'
|
||||
|
||||
export const load: PageServerLoad = async ({ setHeaders }) => {
|
||||
try {
|
||||
const res = await fetchAPI(`query {
|
||||
legal {
|
||||
terms
|
||||
date_updated
|
||||
}
|
||||
}`)
|
||||
|
||||
if (res) {
|
||||
const { data } = res
|
||||
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=1, stale-while-revalidate=604799' })
|
||||
|
||||
return {
|
||||
...data
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw error(500, err.message)
|
||||
}
|
||||
}
|
||||
49
apps/website/src/routes/(site)/terms/+page.svelte
Normal file
49
apps/website/src/routes/(site)/terms/+page.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<style lang="scss">
|
||||
@import "../../../style/pages/terms";
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
// Components
|
||||
import PageTransition from '$components/PageTransition.svelte'
|
||||
import Metas from '$components/Metas.svelte'
|
||||
import Heading from '$components/molecules/Heading.svelte'
|
||||
|
||||
export let data: PageData
|
||||
const { legal } = data
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
</script>
|
||||
|
||||
<Metas
|
||||
title="Terms and Conditions – Houses Of"
|
||||
description="Everything you need to know about using our website or buying our products"
|
||||
/>
|
||||
|
||||
|
||||
<PageTransition>
|
||||
<main class="terms">
|
||||
<Heading text="Everything you need to know about using our website or buying our products" />
|
||||
|
||||
<section class="terms__categories">
|
||||
<div class="container grid">
|
||||
{#each legal.terms as { title, text }, index}
|
||||
<article class="terms__section grid">
|
||||
<h2 class="title-small">{index + 1}. {title}</h2>
|
||||
<div class="text text-info">
|
||||
{@html text}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
|
||||
<footer>
|
||||
<p class="text-info">
|
||||
Updated: <time datetime={dayjs(legal.date_updated).format('YYYY-MM-DD')}>{dayjs().to(dayjs(legal.date_updated))}</time>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</PageTransition>
|
||||
Reference in New Issue
Block a user