diff --git a/web/components/carousel.tsx b/web/components/carousel.tsx new file mode 100644 index 00000000..7ca19c66 --- /dev/null +++ b/web/components/carousel.tsx @@ -0,0 +1,60 @@ +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid' +import clsx from 'clsx' +import { throttle } from 'lodash' +import { ReactNode, useRef, useState, useEffect } from 'react' +import { Row } from './layout/row' + +export function Carousel(props: { children: ReactNode; className?: string }) { + const { children, className } = props + + const ref = useRef(null) + + const th = (f: () => any) => throttle(f, 500, { trailing: false }) + const scrollLeft = th(() => + ref.current?.scrollBy({ left: -ref.current.clientWidth }) + ) + const scrollRight = th(() => + ref.current?.scrollBy({ left: ref.current.clientWidth }) + ) + + const [atFront, setAtFront] = useState(true) + const [atBack, setAtBack] = useState(false) + const onScroll = throttle(() => { + if (ref.current) { + const { scrollLeft, clientWidth, scrollWidth } = ref.current + setAtFront(scrollLeft < 80) + setAtBack(scrollWidth - (clientWidth + scrollLeft) < 80) + } + }, 500) + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(onScroll, []) + + return ( +
+ + {children} + + {!atFront && ( +
+ +
+ )} + {!atBack && ( +
+ +
+ )} +
+ ) +} diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index fa6ea204..097a3b44 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -10,7 +10,7 @@ import { } from './contract/contracts-grid' import { ShowTime } from './contract/contract-details' import { Row } from './layout/row' -import { useEffect, useLayoutEffect, useRef, useMemo } from 'react' +import { useEffect, useLayoutEffect, useRef, useMemo, ReactNode } from 'react' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { useFollows } from 'web/hooks/use-follows' import { @@ -85,6 +85,7 @@ export function ContractSearch(props: { isWholePage?: boolean maxItems?: number noControls?: boolean + renderContracts?: (contracts: Contract[] | undefined) => ReactNode }) { const { user, @@ -101,6 +102,7 @@ export function ContractSearch(props: { isWholePage, maxItems, noControls, + renderContracts, } = props const [state, setState] = usePersistentState( @@ -203,14 +205,18 @@ export function ContractSearch(props: { onSearchParametersChanged={onSearchParametersChanged} noControls={noControls} /> - + {renderContracts ? ( + renderContracts(renderedContracts) + ) : ( + + )} ) } diff --git a/web/pages/experimental/home.tsx b/web/pages/experimental/home.tsx index 607c54d0..a2d47609 100644 --- a/web/pages/experimental/home.tsx +++ b/web/pages/experimental/home.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { useRouter } from 'next/router' +import Router from 'next/router' import { PlusSmIcon } from '@heroicons/react/solid' import { Page } from 'web/components/page' @@ -17,7 +17,13 @@ import { Button } from 'web/components/button' import { Spacer } from 'web/components/layout/spacer' import { useMemberGroups } from 'web/hooks/use-group' import { Group } from 'common/group' -import { Title } from 'web/components/title' +import { Carousel } from 'web/components/carousel' +import { LoadingIndicator } from 'web/components/loading-indicator' +import { ContractCard } from 'web/components/contract/contract-card' +import { range } from 'lodash' +import { Subtitle } from 'web/components/subtitle' +import { Contract } from 'common/contract' +import { ShowTime } from 'web/components/contract/contract-details' export const getServerSideProps: GetServerSideProps = async (ctx) => { const creds = await authenticateOnServer(ctx) @@ -28,7 +34,6 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { const Home = (props: { auth: { user: User } | null }) => { const user = props.auth ? props.auth.user : null - const router = useRouter() useTracking('view home') useSaveReferral() @@ -39,7 +44,7 @@ const Home = (props: { auth: { user: User } | null }) => { return ( - + @@ -51,7 +56,7 @@ const Home = (props: { auth: { user: User } | null }) => { type="button" className="fixed bottom-[70px] right-3 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-3 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden" onClick={() => { - router.push('/create') + Router.push('/create') track('mobile create button') }} > @@ -68,21 +73,25 @@ function SearchSection(props: { }) { const { label, user, sort } = props - const router = useRouter() - return ( - - <Spacer h={2} /> - <ContractSearch user={user} defaultSort={sort} maxItems={4} noControls /> - <Button - className="self-end" - color="blue" - size="sm" - onClick={() => router.push(`/home?s=${sort}`)} - > - See more - </Button> + <Subtitle className="mx-2 !mt-2 !text-gray-800 sm:mx-0" text={label} /> + <ContractSearch + user={user} + defaultSort={sort} + maxItems={12} + noControls + renderContracts={(contracts) => + contracts ? ( + <DoubleCarousel + contracts={contracts} + seeMoreUrl={`/home?s=${sort}`} + /> + ) : ( + <LoadingIndicator /> + ) + } + /> </Col> ) } @@ -90,29 +99,74 @@ function SearchSection(props: { function GroupSection(props: { group: Group; user: User | null }) { const { group, user } = props - const router = useRouter() - return ( <Col className=""> - <Title className="mx-2 !text-gray-800 sm:mx-0" text={group.name} /> + <Subtitle className="mx-2 !text-gray-800 sm:mx-0" text={group.name} /> <Spacer h={2} /> <ContractSearch user={user} defaultSort={'score'} additionalFilter={{ groupSlug: group.slug }} - maxItems={4} + maxItems={12} noControls + renderContracts={(contracts) => + contracts ? ( + <DoubleCarousel + contracts={contracts} + seeMoreUrl={`/group/${group.slug}`} + /> + ) : ( + <LoadingIndicator /> + ) + } /> - <Button - className="mr-2 self-end" - color="blue" - size="sm" - onClick={() => router.push(`/group/${group.slug}`)} - > - See more - </Button> </Col> ) } +function DoubleCarousel(props: { + contracts: Contract[] + seeMoreUrl?: string + showTime?: ShowTime +}) { + const { contracts, seeMoreUrl, showTime } = props + return ( + <Carousel className="-mx-4 mt-2 sm:-mx-10"> + <div className="shrink-0 sm:w-6" /> + {contracts && + range(0, Math.floor(contracts.length / 2)).map((col) => { + const i = col * 2 + return ( + <Col> + <ContractCard + key={contracts[i].id} + contract={contracts[i]} + className="mb-2 max-h-[200px] w-96 shrink-0" + questionClass="line-clamp-3" + trackingPostfix=" tournament" + showTime={showTime} + /> + <ContractCard + key={contracts[i + 1].id} + contract={contracts[i + 1]} + className="mb-2 max-h-[200px] w-96 shrink-0" + questionClass="line-clamp-3" + trackingPostfix=" tournament" + showTime={showTime} + /> + </Col> + ) + })} + <Button + className="self-center whitespace-nowrap" + color="blue" + size="sm" + onClick={() => seeMoreUrl && Router.push(seeMoreUrl)} + > + See more + </Button> + </Carousel> + ) +} + export default Home diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index f3fcbfce..4f66cc22 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -1,10 +1,5 @@ import { ClockIcon } from '@heroicons/react/outline' -import { - ChevronLeftIcon, - ChevronRightIcon, - UsersIcon, -} from '@heroicons/react/solid' -import clsx from 'clsx' +import { UsersIcon } from '@heroicons/react/solid' import { BinaryContract, Contract, @@ -15,10 +10,10 @@ import dayjs, { Dayjs } from 'dayjs' import customParseFormat from 'dayjs/plugin/customParseFormat' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' -import { keyBy, mapValues, sortBy, throttle } from 'lodash' +import { keyBy, mapValues, sortBy } from 'lodash' import Image, { ImageProps, StaticImageData } from 'next/image' import Link from 'next/link' -import { ReactNode, useEffect, useRef, useState } from 'react' +import { useState } from 'react' import { ContractCard } from 'web/components/contract/contract-card' import { DateTimeTooltip } from 'web/components/datetime-tooltip' import { Col } from 'web/components/layout/col' @@ -33,6 +28,7 @@ import mpox_pic from './_cspi/Monkeypox_Cases.png' import race_pic from './_cspi/Supreme_Court_Ban_Race_in_College_Admissions.png' import { SiteLink } from 'web/components/site-link' import { getProbability } from 'common/calculate' +import { Carousel } from 'web/components/carousel' dayjs.extend(utc) dayjs.extend(timezone) @@ -254,58 +250,3 @@ const NaturalImage = (props: ImageProps) => { /> ) } - -function Carousel(props: { children: ReactNode; className?: string }) { - const { children, className } = props - - const ref = useRef<HTMLDivElement>(null) - - const th = (f: () => any) => throttle(f, 500, { trailing: false }) - const scrollLeft = th(() => - ref.current?.scrollBy({ left: -ref.current.clientWidth }) - ) - const scrollRight = th(() => - ref.current?.scrollBy({ left: ref.current.clientWidth }) - ) - - const [atFront, setAtFront] = useState(true) - const [atBack, setAtBack] = useState(false) - const onScroll = throttle(() => { - if (ref.current) { - const { scrollLeft, clientWidth, scrollWidth } = ref.current - setAtFront(scrollLeft < 80) - setAtBack(scrollWidth - (clientWidth + scrollLeft) < 80) - } - }, 500) - - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(onScroll, []) - - return ( - <div className={clsx('relative', className)}> - <Row - className="scrollbar-hide w-full gap-4 overflow-x-auto scroll-smooth" - ref={ref} - onScroll={onScroll} - > - {children} - </Row> - {!atFront && ( - <div - className="absolute left-0 top-0 bottom-0 z-10 flex w-10 cursor-pointer items-center justify-center hover:bg-indigo-100/30" - onMouseDown={scrollLeft} - > - <ChevronLeftIcon className="h-7 w-7 rounded-full bg-indigo-50 text-indigo-700" /> - </div> - )} - {!atBack && ( - <div - className="absolute right-0 top-0 bottom-0 z-10 flex w-10 cursor-pointer items-center justify-center hover:bg-indigo-100/30" - onMouseDown={scrollRight} - > - <ChevronRightIcon className="h-7 w-7 rounded-full bg-indigo-50 text-indigo-700" /> - </div> - )} - </div> - ) -}