diff --git a/public/assets/papers/maia1.jpg b/public/assets/papers/maia1.jpg index 526cf988..28dbcff3 100644 Binary files a/public/assets/papers/maia1.jpg and b/public/assets/papers/maia1.jpg differ diff --git a/public/assets/papers/maia2.jpg b/public/assets/papers/maia2.jpg index 99ceb163..2c63d50b 100644 Binary files a/public/assets/papers/maia2.jpg and b/public/assets/papers/maia2.jpg differ diff --git a/public/assets/papers/maia3.png b/public/assets/papers/maia3.png index 3115be0a..ebdb75b5 100644 Binary files a/public/assets/papers/maia3.png and b/public/assets/papers/maia3.png differ diff --git a/public/assets/team/arthur.png b/public/assets/team/arthur.png index 97abc865..63121fbc 100644 Binary files a/public/assets/team/arthur.png and b/public/assets/team/arthur.png differ diff --git a/public/assets/team/ashton.jpeg b/public/assets/team/ashton.jpeg index 7e9b01e2..411401f5 100644 Binary files a/public/assets/team/ashton.jpeg and b/public/assets/team/ashton.jpeg differ diff --git a/public/assets/team/daniel.png b/public/assets/team/daniel.png index ff3e0d08..c179ba15 100644 Binary files a/public/assets/team/daniel.png and b/public/assets/team/daniel.png differ diff --git a/public/assets/team/dmitriy.jpg b/public/assets/team/dmitriy.jpg index de0b7d31..69605f04 100644 Binary files a/public/assets/team/dmitriy.jpg and b/public/assets/team/dmitriy.jpg differ diff --git a/public/assets/team/george.png b/public/assets/team/george.png index b6711160..1ee4d98f 100644 Binary files a/public/assets/team/george.png and b/public/assets/team/george.png differ diff --git a/public/assets/team/isaac.jpg b/public/assets/team/isaac.jpg index 5928029f..906ef5f8 100644 Binary files a/public/assets/team/isaac.jpg and b/public/assets/team/isaac.jpg differ diff --git a/public/assets/team/jon.jpg b/public/assets/team/jon.jpg index 4604dc11..9b33d8d2 100644 Binary files a/public/assets/team/jon.jpg and b/public/assets/team/jon.jpg differ diff --git a/public/assets/team/joseph.jpg b/public/assets/team/joseph.jpg index e7c9d2c6..9ff91069 100644 Binary files a/public/assets/team/joseph.jpg and b/public/assets/team/joseph.jpg differ diff --git a/public/assets/team/kevin.jpg b/public/assets/team/kevin.jpg index 918c5a7a..ef1101c8 100644 Binary files a/public/assets/team/kevin.jpg and b/public/assets/team/kevin.jpg differ diff --git a/public/assets/team/reid.jpg b/public/assets/team/reid.jpg index e038cbe8..588a8834 100644 Binary files a/public/assets/team/reid.jpg and b/public/assets/team/reid.jpg differ diff --git a/public/assets/team/sid.jpeg b/public/assets/team/sid.jpeg index 80c2524b..5fd26850 100644 Binary files a/public/assets/team/sid.jpeg and b/public/assets/team/sid.jpeg differ diff --git a/public/favicon.png b/public/favicon.png index 5eafecf7..c50047dd 100644 Binary files a/public/favicon.png and b/public/favicon.png differ diff --git a/scripts/optimize-images.mjs b/scripts/optimize-images.mjs new file mode 100644 index 00000000..c546c566 --- /dev/null +++ b/scripts/optimize-images.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +/** + * Resize oversized homepage source images in place. + * + * These assets (team avatars, paper-card previews) are only rendered on the + * homepage at small sizes via next/image, but the source files ship at up to + * 2673px / 1.6MB. We downscale them to ~2x their largest display size so the + * repo stays lean and the Next image optimizer has less work to do. Final + * delivery format/size is still handled by next/image at request time. + * + * Usage: node scripts/optimize-images.mjs [--dry] + */ +import { readdir, stat } from 'node:fs/promises' +import path from 'node:path' +import sharp from 'sharp' + +const DRY = process.argv.includes('--dry') +const ROOT = path.resolve(import.meta.dirname, '..') + +// dir -> max edge (px). 2x the largest rendered size. +const TARGETS = [ + { dir: 'public/assets/team', maxEdge: 320 }, + { dir: 'public/assets/papers', maxEdge: 900 }, +] + +const EXEC = { '.jpg': 'jpeg', '.jpeg': 'jpeg', '.png': 'png' } + +const kb = (n) => `${(n / 1024).toFixed(0)}KB` + +let beforeTotal = 0 +let afterTotal = 0 + +for (const { dir, maxEdge } of TARGETS) { + const abs = path.join(ROOT, dir) + let files + try { + files = await readdir(abs) + } catch { + console.warn(`skip missing dir ${dir}`) + continue + } + for (const file of files) { + const ext = path.extname(file).toLowerCase() + const fmt = EXEC[ext] + if (!fmt) continue + const fp = path.join(abs, file) + const before = (await stat(fp)).size + const img = sharp(fp) + const meta = await img.metadata() + const longest = Math.max(meta.width || 0, meta.height || 0) + + let pipeline = sharp(fp).rotate() // respect EXIF orientation + if (longest > maxEdge) { + pipeline = pipeline.resize({ + width: meta.width >= meta.height ? maxEdge : null, + height: meta.height > meta.width ? maxEdge : null, + withoutEnlargement: true, + }) + } + pipeline = + fmt === 'jpeg' + ? pipeline.jpeg({ quality: 82, mozjpeg: true }) + : pipeline.png({ compressionLevel: 9, palette: true }) + + const buf = await pipeline.toBuffer() + beforeTotal += before + // Only rewrite if we actually save bytes. + if (buf.length < before) { + afterTotal += buf.length + if (!DRY) await sharp(buf).toFile(fp) + console.log( + `${file.padEnd(16)} ${String(longest).padStart(4)}px ${kb(before).padStart(7)} -> ${kb(buf.length).padStart(7)} ${DRY ? '(dry)' : ''}`, + ) + } else { + afterTotal += before + console.log(`${file.padEnd(16)} already optimal (${kb(before)})`) + } + } +} + +console.log( + `\nTotal: ${kb(beforeTotal)} -> ${kb(afterTotal)} (${(((beforeTotal - afterTotal) / beforeTotal) * 100).toFixed(0)}% smaller)`, +) diff --git a/src/components/Common/ErrorBoundary.tsx b/src/components/Common/ErrorBoundary.tsx index 2d83ccf3..bbc45909 100644 --- a/src/components/Common/ErrorBoundary.tsx +++ b/src/components/Common/ErrorBoundary.tsx @@ -1,11 +1,16 @@ import Link from 'next/link' import Head from 'next/head' +import dynamic from 'next/dynamic' import { Component } from 'react' -import Chessground from '@react-chess/chessground' import { Header } from './Header' import { Footer } from './Footer' import { trackErrorEncountered } from 'src/lib/analytics' +const ErrorChessground = dynamic(() => import('@react-chess/chessground'), { + ssr: false, + loading: () =>
, +}) + interface Props { children: React.ReactNode } @@ -109,7 +114,7 @@ export class ErrorBoundary extends Component {
- { const { user } = useContext(AuthContext) diff --git a/src/components/Common/PlaySetupModal.tsx b/src/components/Common/PlaySetupModal.tsx index 0d394594..c209f0f9 100644 --- a/src/components/Common/PlaySetupModal.tsx +++ b/src/components/Common/PlaySetupModal.tsx @@ -12,7 +12,7 @@ import { TimeControlOptionNames, TimeControlOptions, } from 'src/types' -import { ModalContext } from 'src/contexts' +import { ModalContext } from 'src/contexts/ModalContext' import { ModalContainer } from './ModalContainer' const maiaOptions = [ @@ -72,7 +72,7 @@ function OptionSelect({ ) } -interface Props { +export interface PlaySetupModalProps { playType: PlayType player?: Color timeControl?: TimeControl @@ -89,7 +89,9 @@ interface Props { modalSubtitle?: string } -export const PlaySetupModal: React.FC = (props: Props) => { +export const PlaySetupModal: React.FC = ( + props: PlaySetupModalProps, +) => { const { setPlaySetupModalProps } = useContext(ModalContext) const router = useRouter() const { push } = router diff --git a/src/components/Home/AboutMaia.tsx b/src/components/Home/AboutMaia.tsx index b0d8c400..14d24bbf 100644 --- a/src/components/Home/AboutMaia.tsx +++ b/src/components/Home/AboutMaia.tsx @@ -1,3 +1,4 @@ +import Image from 'next/image' import { motion } from 'framer-motion' import { TeamMember } from './TeamMember' import { useInView } from 'react-intersection-observer' @@ -177,11 +178,13 @@ const PaperCard = ({
{featured && ( -
- + {`${paper.title}
)} diff --git a/src/components/Home/GameCarousel.tsx b/src/components/Home/GameCarousel.tsx index 09968885..e115b688 100644 --- a/src/components/Home/GameCarousel.tsx +++ b/src/components/Home/GameCarousel.tsx @@ -181,7 +181,10 @@ export const GameCarousel: React.FC = () => { const router = useRouter() const [games, setGames] = useState(SAMPLE_GAMES) const [isPaused, setIsPaused] = useState(false) + const [isCarouselVisible, setIsCarouselVisible] = useState(false) + const [shouldLoadLiveGames, setShouldLoadLiveGames] = useState(false) const abortController = useRef(null) + const sectionRef = useRef(null) const carouselRef = useRef(null) const handleGameStart = useCallback((gameData: StreamedGame) => { @@ -300,7 +303,7 @@ export const GameCarousel: React.FC = () => { }, []) useEffect(() => { - if (isPaused || !carouselRef.current) return + if (isPaused || !isCarouselVisible || !carouselRef.current) return const scroll = () => { if (carouselRef.current) { @@ -317,18 +320,74 @@ export const GameCarousel: React.FC = () => { const interval = setInterval(scroll, 20) return () => clearInterval(interval) - }, [isPaused]) + }, [isPaused, isCarouselVisible]) useEffect(() => { - fetchNewGame() - fetchBroadcast() + const section = sectionRef.current + + if (!section || typeof IntersectionObserver === 'undefined') { + setIsCarouselVisible(true) + setShouldLoadLiveGames(true) + return + } + + const observer = new IntersectionObserver( + ([entry]) => { + setIsCarouselVisible(entry.isIntersecting) + + if (entry.isIntersecting) { + setShouldLoadLiveGames(true) + } + }, + { rootMargin: '300px 0px' }, + ) + + observer.observe(section) + + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!shouldLoadLiveGames) return + + const startLiveFeeds = () => { + fetchNewGame() + fetchBroadcast() + } + + const windowWithIdleCallback = window as Window & { + requestIdleCallback?: ( + callback: IdleRequestCallback, + options?: IdleRequestOptions, + ) => number + cancelIdleCallback?: (handle: number) => void + } + + if (windowWithIdleCallback.requestIdleCallback) { + const idleHandle = windowWithIdleCallback.requestIdleCallback( + startLiveFeeds, + { + timeout: 2500, + }, + ) + + return () => { + windowWithIdleCallback.cancelIdleCallback?.(idleHandle) + if (abortController.current) { + abortController.current.abort() + } + } + } + + const timeoutId = window.setTimeout(startLiveFeeds, 1000) return () => { + window.clearTimeout(timeoutId) if (abortController.current) { abortController.current.abort() } } - }, [fetchNewGame, fetchBroadcast]) + }, [fetchNewGame, fetchBroadcast, shouldLoadLiveGames]) const handleGameClick = useCallback( (game: GameData) => { @@ -340,7 +399,10 @@ export const GameCarousel: React.FC = () => { ) return ( -
+
= ({ scrollHandler }: Props) => { // Fetch global stats and set up periodic updates useEffect(() => { const fetchGlobalStats = async () => { - const data = await getGlobalStats() - setGlobalStats(data) + try { + const data = await getGlobalStats() + setGlobalStats(data) + } catch (error) { + console.error('Error fetching global stats:', error) + } + } + + const windowWithIdleCallback = window as Window & { + requestIdleCallback?: ( + callback: IdleRequestCallback, + options?: IdleRequestOptions, + ) => number + cancelIdleCallback?: (handle: number) => void } - // Fetch immediately - fetchGlobalStats() + const idleHandle = windowWithIdleCallback.requestIdleCallback?.( + fetchGlobalStats, + { + timeout: 2000, + }, + ) + const timeoutId = + idleHandle === undefined + ? window.setTimeout(fetchGlobalStats, 800) + : undefined // Update every 5 minutes const interval = setInterval(fetchGlobalStats, 5 * 60 * 1000) - return () => clearInterval(interval) + return () => { + if (idleHandle !== undefined) { + windowWithIdleCallback.cancelIdleCallback?.(idleHandle) + } + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId) + } + clearInterval(interval) + } }, []) return ( diff --git a/src/components/Home/Sections/AdditionalFeaturesSection.tsx b/src/components/Home/Sections/AdditionalFeaturesSection.tsx index 4c0b6526..0aa4e532 100644 --- a/src/components/Home/Sections/AdditionalFeaturesSection.tsx +++ b/src/components/Home/Sections/AdditionalFeaturesSection.tsx @@ -3,7 +3,7 @@ import { useContext, ReactNode } from 'react' import { motion } from 'framer-motion' import { useInView } from 'react-intersection-observer' -import { ModalContext } from 'src/contexts' +import { ModalContext } from 'src/contexts/ModalContext' import { PlayType } from 'src/types' import { StarIcon, BrainIcon, BotOrNotIcon } from 'src/components/Common/Icons' diff --git a/src/components/Home/Sections/PlaySection.tsx b/src/components/Home/Sections/PlaySection.tsx index cb751c57..1f769d04 100644 --- a/src/components/Home/Sections/PlaySection.tsx +++ b/src/components/Home/Sections/PlaySection.tsx @@ -6,7 +6,8 @@ import type { DrawShape } from 'chessground/draw' import Chessground from '@react-chess/chessground' import { useInView } from 'react-intersection-observer' import { useContext, useState, useEffect } from 'react' -import { ModalContext, AuthContext } from 'src/contexts' +import { AuthContext } from 'src/contexts/AuthContext' +import { ModalContext } from 'src/contexts/ModalContext' const DEMO_POSITION = { fen: 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R b KQkq - 0 1', diff --git a/src/components/Home/TeamMember.tsx b/src/components/Home/TeamMember.tsx index 1ec948f8..30d52b0c 100644 --- a/src/components/Home/TeamMember.tsx +++ b/src/components/Home/TeamMember.tsx @@ -1,3 +1,4 @@ +import Image from 'next/image' import { motion } from 'framer-motion' import { GithubIcon } from 'src/components/Common/Icons' interface TeamMemberProps { @@ -31,9 +32,12 @@ export const TeamMember = ({ ease: 'easeOut', }} > - {name}
diff --git a/src/contexts/ModalContext.tsx b/src/contexts/ModalContext.tsx index 4e910297..856c7b62 100644 --- a/src/contexts/ModalContext.tsx +++ b/src/contexts/ModalContext.tsx @@ -1,13 +1,22 @@ -import { PlaySetupModal } from 'src/components' -import React, { ComponentProps, ReactNode, useState } from 'react' +import dynamic from 'next/dynamic' +import React, { ReactNode, useState } from 'react' +import type { PlaySetupModalProps } from 'src/components/Common/PlaySetupModal' + +const PlaySetupModal = dynamic( + () => + import('src/components/Common/PlaySetupModal').then( + (mod) => mod.PlaySetupModal, + ), + { ssr: false }, +) const fn = () => { throw new Error('poorly provided ModalContext') } interface IModalContext { - playSetupModalProps?: ComponentProps - setPlaySetupModalProps: (arg0?: ComponentProps) => void + playSetupModalProps?: PlaySetupModalProps + setPlaySetupModalProps: (arg0?: PlaySetupModalProps) => void } export const ModalContext = React.createContext({ @@ -20,7 +29,7 @@ export const ModalContextProvider: React.FC<{ children: ReactNode }> = ({ children: ReactNode }) => { const [playSetupModalProps, setPlaySetupModalProps] = useState< - ComponentProps | undefined + PlaySetupModalProps | undefined >(undefined) return ( diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 6cd2139e..12cae8d1 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -9,33 +9,35 @@ import { Analytics } from '@vercel/analytics/react' import { PostHogProvider } from 'posthog-js/react' import { SoundProvider } from 'src/contexts/SoundContext' -import { - AuthContextProvider, - ModalContextProvider, - WindowSizeContextProvider, - AnalysisListContextProvider, - MaiaEngineContextProvider, - StockfishEngineContextProvider, - SettingsProvider, - TourProvider as TourContextProvider, -} from 'src/contexts' +import { AnalysisListContextProvider } from 'src/contexts/AnalysisListContext' +import { AuthContextProvider } from 'src/contexts/AuthContext' +import { MaiaEngineContextProvider } from 'src/contexts/MaiaEngineContext' +import { ModalContextProvider } from 'src/contexts/ModalContext' +import { SettingsProvider } from 'src/contexts/SettingsContext' +import { StockfishEngineContextProvider } from 'src/contexts/StockfishEngineContext' +import { TourProvider as TourContextProvider } from 'src/contexts/TourContext' +import { WindowSizeContextProvider } from 'src/contexts/WindowSizeContext' import 'src/styles/tailwind.css' import 'src/styles/themes.css' import 'react-tooltip/dist/react-tooltip.css' import 'node_modules/chessground/assets/chessground.base.css' import 'node_modules/chessground/assets/chessground.brown.css' import 'node_modules/chessground/assets/chessground.cburnett.css' -import { - Footer, - Compose, - ErrorBoundary, - Header, - FeedbackButton, -} from 'src/components' +import { Compose } from 'src/components/Common/Compose' +import { ErrorBoundary } from 'src/components/Common/ErrorBoundary' +import { FeedbackButton } from 'src/components/Common/FeedbackButton' +import { Footer } from 'src/components/Common/Footer' +import { Header } from 'src/components/Common/Header' import { browserPostHogConfig } from 'src/lib/posthog-browser-config' const openSansClassName = 'font-sans' const OG_IMAGE_URL = 'https://www.maiachess.com/assets/og-maia.png' +// Only wght 200-400 and FILL 0/1 are actually used, so pin opsz=24/GRAD=0 and +// narrow the ranges: Google serves a ~1.1MB instance instead of the full +// ~3.8MB variable font. The material-symbols-ready guard hides icons until the +// font loads, so display=swap never flashes the ligature text. +const MATERIAL_SYMBOLS_STYLESHEET = + 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,200..400,0..1,0&display=swap' function MaiaPlatform({ Component, pageProps }: AppProps) { const router = useRouter() @@ -64,6 +66,28 @@ function MaiaPlatform({ Component, pageProps }: AppProps) { }) }, []) + useEffect(() => { + const root = document.documentElement + const markMaterialSymbolsReady = () => { + root.classList.add('material-symbols-ready') + } + + if (!('fonts' in document)) { + markMaterialSymbolsReady() + return + } + + if (document.fonts.check('24px "Material Symbols Outlined"')) { + markMaterialSymbolsReady() + return + } + + document.fonts + .load('24px "Material Symbols Outlined"') + .then(markMaterialSymbolsReady) + .catch(() => undefined) + }, []) + return ( @@ -82,9 +106,22 @@ function MaiaPlatform({ Component, pageProps }: AppProps) { > + + + + +