Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
980 changes: 581 additions & 399 deletions apps/frontend/sanity.types.ts

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions apps/frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Metadata } from 'next';
import { defineQuery, PortableText } from 'next-sanity';
import PostsList from '../components/posts-list';
import Section from '../components/section';
import { postListFields } from '../utils/sanity-data';
import { homeArtworkListFields, postListFields } from '../utils/sanity-data';
import { fetch } from '../utils/sanity-fetch';

export async function generateMetadata(): Promise<Metadata> {
Expand Down Expand Up @@ -39,7 +39,7 @@ export default async function IndexPage() {

const homeArtworksQuery = defineQuery(`
*[_type == "post" && type == "artwork"][0...16] | order(meta.date desc, _createdAt desc) {
${postListFields}
${homeArtworkListFields}
}
`);

Expand Down
35 changes: 35 additions & 0 deletions apps/frontend/src/components/body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import cx from '../utils/cx';
import { isExternal } from '../utils/url';
import ExternalLink from './external-link';
import Image from './image';
import Video from './video';
import VideoEmbed from './video-embed';

type BodyProps = {
Expand Down Expand Up @@ -101,6 +102,40 @@ const components: PortableTextComponents = {
</div>
);
},
video: ({ value }: any) => {
if (!value.url) {
return null;
}

let className = 'my-12 md:my-16 max-sm:-mx-4';

if (value.layout === 'text-width') {
className += ' ' + centerClassName;
}

const video = (
<Video
url={value.url}
poster={value.poster}
alt={value.alt}
autoplay={value.autoplay}
loading={value.loading}
/>
);

if (value.caption) {
return (
<figure className={className}>
{video}
<figcaption className="mt-2 max-sm:mx-4 text-light-gray">
{value.caption}
</figcaption>
</figure>
);
}

return <div className={className}>{video}</div>;
},
code: ({ value }: any) => {
let code = value.code;
const language = value.language;
Expand Down
36 changes: 24 additions & 12 deletions apps/frontend/src/components/posts-list/post-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Link from 'next/link';
import React from 'react';
import Heading from '../heading';
import Image from '../image';
import ThumbnailVideo from './thumbnail-video';
import type { PostItem as PostItemType } from './types';

type PostItemProps = {
Expand All @@ -11,23 +12,34 @@ type PostItemProps = {
};

const PostItem: React.FC<PostItemProps> = ({ post, loading = 'lazy' }) => {
const sizes = `(max-width: 1024px) 100vw, ${
post.type === 'project' ? '33vw' : '50vw'
}`;

return (
<Link
href={`/${post.slug?.current}`}
className="flex flex-col gap-3 max-sm:-mx-4"
>
{post.image?.asset && (
<Image
image={post.image}
loading={loading}
width={3200}
height={2400}
sizes={`(max-width: 1024px) 100vw, ${
post.type === 'project' ? '33vw' : '50vw'
}`}
className="aspect-[4/3] object-cover"
/>
)}
{post.image?.asset &&
(post.thumbnailVideoUrl ? (
<ThumbnailVideo
url={post.thumbnailVideoUrl}
image={post.image}
loading={loading}
sizes={sizes}
className="aspect-[4/3] object-cover"
/>
) : (
<Image
image={post.image}
loading={loading}
width={3200}
height={2400}
sizes={sizes}
className="aspect-[4/3] object-cover"
/>
))}
<div className="max-sm:mx-4">
<Heading as="h3" className="underline underline-offset-4">
{post.title}
Expand Down
66 changes: 66 additions & 0 deletions apps/frontend/src/components/posts-list/thumbnail-video.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
'use client';

import { createImageUrlBuilder as imageUrlBuilder } from '@sanity/image-url';
import { type FC, useRef } from 'react';
import type { ImageFields } from '../../types';
import cx from '../../utils/cx';
import { client } from '../../utils/sanity-client';
import { usePlayInView } from '../../utils/use-play-in-view';
import { useReducedMotion } from '../../utils/use-reduced-motion';
import Image from '../image';

const builder = imageUrlBuilder(client);

Comment on lines +3 to +13
type ThumbnailVideoProps = {
url: string;
image: ImageFields;
loading?: 'lazy' | 'eager';
sizes: string;
className?: string;
};

const ThumbnailVideo: FC<ThumbnailVideoProps> = ({
url,
image,
loading,
sizes,
className,
}) => {
const ref = useRef<HTMLVideoElement>(null);
const reducedMotion = useReducedMotion();

usePlayInView(ref, !reducedMotion);

if (reducedMotion) {
return (
<Image
image={image}
loading={loading}
width={3200}
height={2400}
sizes={sizes}
className={className}
/>
);
}
Comment on lines +34 to +45

const posterUrl = image.asset
? builder.image(image.asset).width(800).url()
: undefined;

return (
<video
ref={ref}
className={cx(className, 'w-full')}
src={url}
poster={posterUrl}
muted
loop
playsInline
aria-hidden="true"
preload={loading === 'eager' ? 'auto' : 'metadata'}
/>
);
};

export default ThumbnailVideo;
1 change: 1 addition & 0 deletions apps/frontend/src/components/posts-list/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ImageFields } from '../../types';
export type PostItem = {
_id: string;
image?: ImageFields | null;
thumbnailVideoUrl?: string | null;
type?: string | null;
slug?: Slug;
title?: string;
Expand Down
63 changes: 63 additions & 0 deletions apps/frontend/src/components/video.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
'use client';

import { createImageUrlBuilder as imageUrlBuilder } from '@sanity/image-url';
import { type FC, useRef } from 'react';
import type { VideoFields } from '../types';
import cx from '../utils/cx';
import { client } from '../utils/sanity-client';
import { usePlayInView } from '../utils/use-play-in-view';
import { useReducedMotion } from '../utils/use-reduced-motion';

const builder = imageUrlBuilder(client);
Comment on lines +3 to +11

type VideoProps = {
url: string;
poster?: VideoFields['poster'];
alt?: string | null;
autoplay?: boolean;
loading?: 'lazy' | 'eager';
className?: string;
};

const Video: FC<VideoProps> = ({
url,
poster,
alt,
autoplay = true,
loading = 'lazy',
className = '',
}) => {
const ref = useRef<HTMLVideoElement>(null);
const reducedMotion = useReducedMotion();

const aspectRatio =
poster?.width && poster?.height
? `${poster.width} / ${poster.height}`
: '16 / 9';

const posterUrl = poster?.asset
? builder.image(poster.asset).width(1280).url()
: undefined;

const shouldAutoplay = autoplay && !reducedMotion;

usePlayInView(ref, shouldAutoplay);

return (
<video
ref={ref}
className={cx('w-full h-auto', className)}
style={{ aspectRatio }}
src={url}
poster={posterUrl}
aria-label={alt || undefined}
muted={shouldAutoplay}
loop={shouldAutoplay}
playsInline
controls={!shouldAutoplay}
preload={loading === 'eager' ? 'auto' : 'metadata'}
/>
);
};

export default Video;
13 changes: 13 additions & 0 deletions apps/frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,16 @@ export type ImageFields = {
loading?: 'lazy' | 'eager' | null;
color?: string | null;
};

/** Fields corresponding to the video block projection in sanity-data.ts */
export type VideoFields = {
url?: string | null;
poster?: {
asset?: SanityReference | SanityAsset | null;
width?: number | null;
height?: number | null;
} | null;
alt?: string | null;
autoplay?: boolean | null;
loading?: 'lazy' | 'eager' | null;
};
14 changes: 14 additions & 0 deletions apps/frontend/src/utils/sanity-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ export const postFields = `
"height": asset->metadata.dimensions.height,
"color": asset->metadata.palette.dominant.background,
},
_type == 'video' => {
...,
"url": file.asset->url,
poster {
asset,
"width": asset->metadata.dimensions.width,
"height": asset->metadata.dimensions.height,
},
},
}
`;

Expand All @@ -28,3 +37,8 @@ export const postListFields = `
${imageFields}
},
`;

export const homeArtworkListFields = `
${postListFields}
"thumbnailVideoUrl": thumbnailVideo.asset->url,
`;
45 changes: 45 additions & 0 deletions apps/frontend/src/utils/use-play-in-view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { type RefObject, useEffect } from 'react';

/**
* Plays the referenced video while it is in the viewport and pauses it when it
* scrolls out. Pauses and skips observing while `enabled` is false so callers
* can gate playback on reduced-motion or an autoplay setting.
*/
export function usePlayInView(
ref: RefObject<HTMLVideoElement | null>,
enabled: boolean,
) {
useEffect(() => {
const video = ref.current;

if (!video) {
return;
}

if (!enabled) {
video.pause();
return;
}

video.muted = true;

const observer = new IntersectionObserver(
([entry]) => {
if (!entry) {
return;
}

if (entry.isIntersecting) {
video.play().catch(() => {});
} else {
video.pause();
}
},
{ threshold: 0 },
);

Comment on lines +24 to +40
observer.observe(video);

return () => observer.disconnect();
}, [ref, enabled]);
}
18 changes: 18 additions & 0 deletions apps/frontend/src/utils/use-reduced-motion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';

export function useReducedMotion() {
const [reducedMotion, setReducedMotion] = useState(false);

useEffect(() => {
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
setReducedMotion(query.matches);

const onChange = (event: MediaQueryListEvent) =>
setReducedMotion(event.matches);
query.addEventListener('change', onChange);

return () => query.removeEventListener('change', onChange);
}, []);

return reducedMotion;
}
Comment on lines +1 to +18
Loading