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
6 changes: 4 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,22 @@ import { getEventsSafe } from '@/lib/data/event';
import { getClubsSafe } from '@/lib/data/club';
import { getHubsSafe } from '@/lib/data/hub';
import { getGalleriesSafe } from '@/lib/data/gallery';
import { getBlogsSafe } from '@/lib/data/blog';
import type { Metadata } from 'next';

export const metadata: Metadata = {
alternates: { canonical: '/' },
};

const Home = async () => {
const [hero, potw, events, clubs, hubs, galleries] = await Promise.all([
const [hero, potw, events, clubs, hubs, galleries, blogs] = await Promise.all([
getPageHero('home'),
getPersonalityOfTheWeek(),
getEventsSafe(),
getClubsSafe(),
getHubsSafe(),
getGalleriesSafe(),
getBlogsSafe(),
]);

return (
Expand All @@ -47,7 +49,7 @@ const Home = async () => {
<HomeHubsPreview initialHubs={hubs} />
<HomeClubsPreview initialClubs={clubs} />
<RecentEvent initialEvents={events} />
<Contribute />
<Contribute initialBlogs={blogs} />
<Gallery initialGalleries={galleries} />
</div>
)
Expand Down
6 changes: 3 additions & 3 deletions components/home/Contribute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import Container from '../custom/Container';
import { Button } from '../ui/button';
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
import useBlogCollection from '@/hooks/useBlogCollection';
import useBlogCollection, { type Blog } from '@/hooks/useBlogCollection';
import BlogCard from '../blog/BlogCard';
import FetchError from '../custom/FetchError';

import { motion } from "framer-motion";

const Contribute = () => {
const Contribute = ({ initialBlogs }: { initialBlogs?: Blog[] }) => {

const { data: blogs, error } = useBlogCollection()
const { data: blogs, error } = useBlogCollection(initialBlogs)

if (error) {
return <FetchError />
Expand Down
32 changes: 28 additions & 4 deletions components/home/HeroSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const BAND = {
curve: 80,
fade: 18,
fill: 0.72,
sizes: '(max-width: 768px) 55vw, 30vw',
};

function arc(bend: number) {
Expand Down Expand Up @@ -261,13 +262,30 @@ const HeroSection = ({
};
}, [variant, bend]);

const readyRef = useRef(false);

const reveal = (node: HTMLImageElement | null) => {
if (!node || !readyRef.current) return;
if (!node.complete || node.naturalWidth === 0) return;
node.setAttribute('data-shown', '');
};

useEffect(() => {
readyRef.current = ready;
if (!ready) return;
for (const row of shots.current.values()) {
for (const node of row) reveal(node);
}
}, [ready]);

const band = layout ?? measureBand(1200, 560, 200, 560, false, bend);
const cycles = tiles.length > band.columns;

useEffect(() => {
if (variant !== 'band') return;
for (const tile of tiles) {
const preload = new window.Image();
preload.sizes = BAND.sizes;
if (tile.srcSet) preload.srcset = tile.srcSet;
preload.src = tile.src;
preload.decode?.().catch(() => {});
Expand All @@ -294,6 +312,7 @@ const HeroSection = ({
const orbit = `hero-orbit-${bandId}`;
const peek = `hero-peek-${bandId}`;
const tileClass = `hero-tile-${bandId}`;
const shotClass = `hero-shot-${bandId}`;
const radiusPct = 100 * arc(bend);
const unitOf = (n: number) => `calc(${+n.toFixed(4)} * var(--band-tile))`;
const vanish = +(((band.sweep - band.limit) / (2 * band.sweep || 1)) * 100).toFixed(1);
Expand All @@ -307,7 +326,9 @@ const HeroSection = ({
`@keyframes ${orbit}{` +
`from{transform:translateZ(var(--band-r)) rotateY(var(--band-from)) translateZ(calc(-1 * var(--band-r)))}` +
`to{transform:translateZ(var(--band-r)) rotateY(var(--band-to)) translateZ(calc(-1 * var(--band-r)))}}` +
`@media(prefers-reduced-motion:reduce){.${tileClass}{animation-play-state:paused}}`;
`@media(prefers-reduced-motion:reduce){.${tileClass}{animation-play-state:paused}}` +
`.${shotClass}{opacity:0;transition:opacity 700ms ease}` +
`.${shotClass}[data-shown]{opacity:1}`;

const peekCss =
`@keyframes ${peek}{` +
Expand Down Expand Up @@ -341,7 +362,7 @@ const HeroSection = ({
className='pointer-events-none absolute inset-0 transition-opacity duration-500'
style={{
...bandVars,
opacity: layout && ready ? 1 : 0,
opacity: layout ? 1 : 0,
perspective: `${+(radiusPct * CAMERA).toFixed(3)}cqw`,
perspectiveOrigin: `50% ${band.axis}%`,
maskImage: bandMask,
Expand Down Expand Up @@ -392,6 +413,7 @@ const HeroSection = ({
maskImage: sliceMask,
WebkitMaskImage: sliceMask,
backfaceVisibility: 'hidden',
backgroundColor: '#e9ecef',
left: unitOf((BAND.aspect - share) / 2),
width:
k === band.slices - 1
Expand All @@ -410,14 +432,16 @@ const HeroSection = ({
const row = shots.current.get(t) ?? [];
if (node) row[k] = node;
shots.current.set(t, row);
reveal(node);
}}
onLoad={(event) => reveal(event.currentTarget)}
src={tile.src}
srcSet={tile.srcSet || undefined}
sizes='(max-width: 768px) 55vw, 30vw'
sizes={BAND.sizes}
alt=''
draggable={false}
decoding='async'
className='absolute top-0 max-w-none object-cover'
className={`${shotClass} absolute top-0 max-w-none object-cover`}
style={{
left: unitOf(-k * share),
width: unitOf(BAND.aspect),
Expand Down
32 changes: 30 additions & 2 deletions components/home/RecentEvent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import Image from "next/image";
import { ctfSrc } from '@/lib/contentful-src';
import { format } from "date-fns";
import { useEffect, useRef, useState } from "react";
import { ArrowRight, CalendarDays, Clock } from "lucide-react";
import Link from "next/link";
import useEventCollection, { type EventItem } from "@/hooks/useEventCollection";
Expand All @@ -12,6 +13,31 @@ import * as Tabs from "@radix-ui/react-tabs";
const RecentEvent = ({ initialEvents }: { initialEvents?: EventItem[] }) => {
const { data: events, isLoading, error } = useEventCollection(initialEvents);

const sectionRef = useRef<HTMLElement>(null);
const [showVideo, setShowVideo] = useState(false);

useEffect(() => {
const node = sectionRef.current;
if (!node || showVideo) return;
if (typeof IntersectionObserver === 'undefined') {
setShowVideo(true);
return;
}

const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setShowVideo(true);
observer.disconnect();
}
},
{ rootMargin: '600px 0px' }
);

observer.observe(node);
return () => observer.disconnect();
}, [showVideo]);

const now = new Date();

const upcoming = events
Expand Down Expand Up @@ -39,10 +65,12 @@ const RecentEvent = ({ initialEvents }: { initialEvents?: EventItem[] }) => {
}

return (
<section className="px-4 sm:px-page-sx md:px-page-x py-12 lg:py-24 font-header bg-background/50 overflow-hidden relative">
<section ref={sectionRef} className="px-4 sm:px-page-sx md:px-page-x py-12 lg:py-24 font-header bg-background/50 overflow-hidden relative">
<video
className="absolute top-0 left-0 w-full h-full object-cover z-0"
src="/videos/coenight.mp4"
{...(showVideo ? { src: "/videos/coenight.mp4" } : {})}
poster="/videos/coenight-poster.jpg"
preload="none"
autoPlay
loop
muted
Expand Down
36 changes: 7 additions & 29 deletions lib/contentful-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,25 @@ if (!CONTENTFUL_SPACE_ID || !CONTENTFUL_ACCESS_TOKEN) {
});
}

// How long (seconds) a cached Contentful response is served before revalidation.
// Because this cache lives on the server, one refresh serves every visitor, so
// the number of origin calls stays roughly constant regardless of traffic.
export const CONTENTFUL_REVALIDATE_SECONDS = 3600;

export const CONTENTFUL_CACHE_TAG = "contentful";

// `next` is a Next.js fetch extension not covered by graphql-request's types.
const CONTENTFUL_TIMEOUT_MS = 8000;

type GraphQLClientConfig = ConstructorParameters<typeof GraphQLClient>[1];
type NextRequestConfig = GraphQLClientConfig & {
next?: { revalidate?: number | false; tags?: string[] };
};

const requestConfig: NextRequestConfig = {
// GET (rather than POST) lets responses flow through Next's Data Cache / CDN.
method: "GET",
headers: CONTENTFUL_ACCESS_TOKEN
? { Authorization: `Bearer ${CONTENTFUL_ACCESS_TOKEN}` }
: {},
next: { revalidate: CONTENTFUL_REVALIDATE_SECONDS, tags: [CONTENTFUL_CACHE_TAG] },
};

/**
* Direct client to the Contentful GraphQL API. Intended for server-side use
* (Server Components, route handlers) where the `next.revalidate` Data Cache
* is shared across all requests/users.
*/
export const contentfulDirect = new GraphQLClient(
`https://graphql.contentful.com/content/v1/spaces/${CONTENTFUL_SPACE_ID ?? "undefined"}`,
requestConfig
Expand All @@ -47,15 +39,13 @@ interface PartialErrorShape {
response?: { data?: unknown; errors?: { message?: string }[] };
}

/**
* Contentful answers HTTP 200 with usable `data` alongside non-fatal errors
* such as UNRESOLVABLE_LINK (a published entry pointing at an unpublished
* asset). graphql-request throws on any `errors`, which would take a whole page
* down over one missing image, so partial data is served and logged instead.
*/
export async function requestTolerant<T>(query: string, variables?: Variables): Promise<T> {
try {
return await contentfulDirect.request<T>(query, variables);
return await contentfulDirect.request<T>({
document: query,
variables,
signal: AbortSignal.timeout(CONTENTFUL_TIMEOUT_MS),
});
} catch (error) {
const partial = error as PartialErrorShape;
const data = partial.response?.data;
Expand All @@ -73,11 +63,6 @@ export async function requestTolerant<T>(query: string, variables?: Variables):
}

async function request<T>(query: string, variables?: Variables): Promise<T> {
// On the server, hit Contentful directly so we benefit from the shared
// Data Cache. In the browser, proxy through our own route handler so the
// response is served from that same server-side cache — this keeps origin
// calls flat no matter how many people are online, and keeps the access
// token off the client.
if (typeof window === "undefined") {
return requestTolerant<T>(query, variables);
}
Expand All @@ -87,9 +72,6 @@ async function request<T>(query: string, variables?: Variables): Promise<T> {
params.set("variables", JSON.stringify(variables));
}

// The page carries the time content last changed. Putting it in the URL means
// a publish gives every browser a fresh address, so nobody is served an hour
// old copy from the CDN, while unchanged content still hits the cache.
const version = (globalThis as { __contentVersion?: string }).__contentVersion;
if (version) params.set("v", version);

Expand All @@ -111,8 +93,4 @@ async function request<T>(query: string, variables?: Variables): Promise<T> {
return res.json() as Promise<T>;
}

/**
* Isomorphic Contentful client. Mirrors `graphql-request`'s `.request` API so
* existing call sites stay unchanged while transparently caching on the server.
*/
export const contentfulClient = { request };
11 changes: 10 additions & 1 deletion providers/ReactQueryProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useState } from "react";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import dynamic from "next/dynamic";

const ReactQueryDevtools =
process.env.NODE_ENV === "development"
? dynamic(
() =>
import("@tanstack/react-query-devtools").then((m) => m.ReactQueryDevtools),
{ ssr: false }
)
: () => null;

export default function ReactQueryProvider({
children,
Expand Down
Binary file added public/videos/coenight-poster.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading