diff --git a/web/components/edit-fold-button.tsx b/web/components/edit-fold-button.tsx new file mode 100644 index 00000000..2f840eff --- /dev/null +++ b/web/components/edit-fold-button.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react' +import _ from 'lodash' +import clsx from 'clsx' +import { PencilIcon } from '@heroicons/react/outline' + +import { Fold } from '../../common/fold' +import { parseWordsAsTags } from '../../common/util/parse' +import { updateFold } from '../lib/firebase/folds' +import { toCamelCase } from '../lib/util/format' +import { Spacer } from './layout/spacer' +import { TagsList } from './tags-list' + +export function EditFoldButton(props: { fold: Fold }) { + const { fold } = props + const [name, setName] = useState(fold?.name ?? '') + + const initialOtherTags = + fold?.tags.filter((tag) => tag !== toCamelCase(name)).join(', ') ?? '' + + const [otherTags, setOtherTags] = useState(initialOtherTags) + const [isSubmitting, setIsSubmitting] = useState(false) + + const tags = parseWordsAsTags(toCamelCase(name) + ' ' + otherTags) + + const saveDisabled = + !name || (name === fold.name && _.isEqual(tags, fold.tags)) + + const onSubmit = async () => { + setIsSubmitting(true) + + await updateFold(fold, { + name, + tags, + }) + + setIsSubmitting(false) + } + + return ( +
+ + + +
+
+
+ + + setName(e.target.value || '')} + /> +
+ + + +
+ + + setOtherTags(e.target.value || '')} + /> +
+ + + `#${tag}`)} noLink /> + + +
+ + +
+
+
+
+ ) +} diff --git a/web/components/fold-back.tsx b/web/components/fold-back.tsx deleted file mode 100644 index 374e13e3..00000000 --- a/web/components/fold-back.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { ArrowCircleLeftIcon } from '@heroicons/react/outline' -import { Fold } from '../../common/fold' -import { foldPath } from '../lib/firebase/folds' -import { SiteLink } from './site-link' - -export function FoldBack(props: { fold: Fold }) { - const { fold } = props - return ( - - {' '} - {fold.name} - - ) -} diff --git a/web/components/leaderboard.tsx b/web/components/leaderboard.tsx index 27cf1c27..5ab792b1 100644 --- a/web/components/leaderboard.tsx +++ b/web/components/leaderboard.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx' import { User } from '../../common/user' import { Row } from './layout/row' import { SiteLink } from './site-link' @@ -10,10 +11,11 @@ export function Leaderboard(props: { header: string renderCell: (user: User) => any }[] + className?: string }) { - const { title, users, columns } = props + const { title, users, columns, className } = props return ( -
+
<div className="overflow-x-auto"> <table className="table table-zebra table-compact text-gray-500 w-full"> @@ -40,7 +42,7 @@ export function Leaderboard(props: { width={32} height={32} /> - <div>{user.name}</div> + <div className="truncate">{user.name}</div> </Row> </SiteLink> </td> diff --git a/web/pages/fold/[...slugs]/index.tsx b/web/pages/fold/[...slugs]/index.tsx new file mode 100644 index 00000000..0b7c6b34 --- /dev/null +++ b/web/pages/fold/[...slugs]/index.tsx @@ -0,0 +1,310 @@ +import _ from 'lodash' +import Link from 'next/link' + +import { Fold } from '../../../../common/fold' +import { Comment } from '../../../../common/comment' +import { Page } from '../../../components/page' +import { Title } from '../../../components/title' +import { Bet, listAllBets } from '../../../lib/firebase/bets' +import { listAllComments } from '../../../lib/firebase/comments' +import { Contract } from '../../../lib/firebase/contracts' +import { + foldPath, + getFoldBySlug, + getFoldContracts, +} from '../../../lib/firebase/folds' +import { ActivityFeed, findActiveContracts } from '../../activity' +import { TagsList } from '../../../components/tags-list' +import { Row } from '../../../components/layout/row' +import { UserLink } from '../../../components/user-page' +import { getUser, User } from '../../../lib/firebase/users' +import { Spacer } from '../../../components/layout/spacer' +import { Col } from '../../../components/layout/col' +import { useUser } from '../../../hooks/use-user' +import { useFold } from '../../../hooks/use-fold' +import { SearchableGrid } from '../../../components/contracts-list' +import { useQueryAndSortParams } from '../../../hooks/use-sort-and-query-params' +import { useRouter } from 'next/router' +import clsx from 'clsx' +import { scoreCreators, scoreTraders } from '../../../lib/firebase/scoring' +import { Leaderboard } from '../../../components/leaderboard' +import { formatMoney } from '../../../lib/util/format' +import { EditFoldButton } from '../../../components/edit-fold-button' + +export async function getStaticProps(props: { params: { slugs: string[] } }) { + const { slugs } = props.params + + const fold = await getFoldBySlug(slugs[0]) + const curatorPromise = fold ? getUser(fold.curatorId) : null + + const contracts = fold ? await getFoldContracts(fold).catch((_) => []) : [] + const contractComments = await Promise.all( + contracts.map((contract) => listAllComments(contract.id).catch((_) => [])) + ) + + let activeContracts = findActiveContracts( + contracts, + _.flatten(contractComments), + 365 + ) + const [resolved, unresolved] = _.partition( + activeContracts, + ({ isResolved }) => isResolved + ) + activeContracts = [...unresolved, ...resolved] + + const activeContractBets = await Promise.all( + activeContracts.map((contract) => listAllBets(contract.id).catch((_) => [])) + ) + const activeContractComments = activeContracts.map( + (contract) => + contractComments[contracts.findIndex((c) => c.id === contract.id)] + ) + + const curator = await curatorPromise + + const bets = await Promise.all( + contracts.map((contract) => listAllBets(contract.id)) + ) + + const creatorScores = scoreCreators(contracts, bets) + const [topCreators, topCreatorScores] = await toUserScores(creatorScores) + + const traderScores = scoreTraders(contracts, bets) + const [topTraders, topTraderScores] = await toUserScores(traderScores) + + return { + props: { + fold, + curator, + contracts, + activeContracts, + activeContractBets, + activeContractComments, + topTraders, + topTraderScores, + topCreators, + topCreatorScores, + }, + + revalidate: 60, // regenerate after a minute + } +} + +async function toUserScores(userScores: { [userId: string]: number }) { + const topUserPairs = _.take( + _.sortBy(Object.entries(userScores), ([_, score]) => -1 * score), + 10 + ) + const topUsers = await Promise.all( + topUserPairs.map(([userId]) => getUser(userId)) + ) + const topUserScores = topUserPairs.map(([_, score]) => score) + return [topUsers, topUserScores] as const +} + +export async function getStaticPaths() { + return { paths: [], fallback: 'blocking' } +} + +export default function FoldPage(props: { + fold: Fold + curator: User + contracts: Contract[] + activeContracts: Contract[] + activeContractBets: Bet[][] + activeContractComments: Comment[][] + topTraders: User[] + topTraderScores: number[] + topCreators: User[] + topCreatorScores: number[] + params: { tab: string } +}) { + const { + curator, + contracts, + activeContracts, + activeContractBets, + activeContractComments, + topTraders, + topTraderScores, + topCreators, + topCreatorScores, + } = props + + const router = useRouter() + const { slugs } = router.query as { slugs: string[] } + const page = + (slugs[1] as 'markets' | 'leaderboards' | undefined) ?? 'activity' + + const fold = useFold(props.fold.id) ?? props.fold + const { curatorId } = fold + + const { query, setQuery, sort, setSort } = useQueryAndSortParams({ + defaultSort: 'most-traded', + }) + + const user = useUser() + const isCurator = user?.id === curatorId + + return ( + <Page wide> + <Col className="items-center"> + <Col className="max-w-5xl w-full"> + <Col className="sm:flex-row sm:justify-between sm:items-end gap-4 mb-6"> + <Title className="!m-0" text={fold.name} /> + {isCurator && <EditFoldButton fold={fold} />} + </Col> + + <div className="tabs mb-4"> + <Link href={foldPath(fold)} shallow> + <a + className={clsx( + 'tab tab-bordered', + page === 'activity' && 'tab-active' + )} + > + Activity + </a> + </Link> + + <Link href={foldPath(fold, 'markets')} shallow> + <a + className={clsx( + 'tab tab-bordered', + page === 'markets' && 'tab-active' + )} + > + Markets + </a> + </Link> + <Link href={foldPath(fold, 'leaderboards')} shallow> + <a + className={clsx( + 'tab tab-bordered', + page === 'leaderboards' && 'tab-active', + page !== 'leaderboards' && 'md:hidden' + )} + > + Leaderboards + </a> + </Link> + </div> + + {page === 'activity' && ( + <Row className="gap-8"> + <Col> + <ActivityFeed + contracts={activeContracts} + contractBets={activeContractBets} + contractComments={activeContractComments} + /> + </Col> + <Col className="hidden md:flex max-w-xs gap-10"> + <FoldOverview fold={fold} curator={curator} /> + <FoldLeaderboards + topTraders={topTraders} + topTraderScores={topTraderScores} + topCreators={topCreators} + topCreatorScores={topCreatorScores} + /> + </Col> + </Row> + )} + + {page === 'markets' && ( + <div className="w-full"> + <SearchableGrid + contracts={contracts} + query={query} + setQuery={setQuery} + sort={sort} + setSort={setSort} + /> + </div> + )} + + {page === 'leaderboards' && ( + <Col className="gap-8"> + <FoldLeaderboards + topTraders={topTraders} + topTraderScores={topTraderScores} + topCreators={topCreators} + topCreatorScores={topCreatorScores} + /> + </Col> + )} + </Col> + </Col> + </Page> + ) +} + +function FoldOverview(props: { fold: Fold; curator: User }) { + const { fold, curator } = props + const { tags } = fold + + return ( + <Col className="max-w-sm"> + <div className="px-4 py-3 bg-indigo-700 text-white text-sm rounded-t"> + About community + </div> + <Col className="p-4 bg-white self-start gap-2 rounded-b"> + <Row> + <div className="text-gray-500 mr-1">Curated by</div> + <UserLink + className="text-neutral" + name={curator.name} + username={curator.username} + /> + </Row> + + <Spacer h={2} /> + <div className="text-gray-500"> + This is a community for predicting asdf asd fasdf asdf asdf . + </div> + + <Spacer h={2} /> + + <TagsList tags={tags.map((tag) => `#${tag}`)} /> + </Col> + </Col> + ) +} + +function FoldLeaderboards(props: { + topTraders: User[] + topTraderScores: number[] + topCreators: User[] + topCreatorScores: number[] +}) { + const { topTraders, topTraderScores, topCreators, topCreatorScores } = props + return ( + <> + <Leaderboard + className="max-w-xl" + title="🏅 Top traders" + users={topTraders} + columns={[ + { + header: 'Profit', + renderCell: (user) => + formatMoney(topTraderScores[topTraders.indexOf(user)]), + }, + ]} + /> + <Leaderboard + className="max-w-xl" + title="🏅 Top creators" + users={topCreators} + columns={[ + { + header: 'Market pool', + renderCell: (user) => + formatMoney(topCreatorScores[topCreators.indexOf(user)]), + }, + ]} + /> + </> + ) +} diff --git a/web/pages/fold/[foldSlug]/edit.tsx b/web/pages/fold/[foldSlug]/edit.tsx deleted file mode 100644 index c89992ed..00000000 --- a/web/pages/fold/[foldSlug]/edit.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import clsx from 'clsx' -import _ from 'lodash' -import { ArrowCircleLeftIcon } from '@heroicons/react/solid' -import { useState } from 'react' -import { Fold } from '../../../../common/fold' -import { parseWordsAsTags } from '../../../../common/util/parse' -import { Col } from '../../../components/layout/col' -import { Spacer } from '../../../components/layout/spacer' -import { Page } from '../../../components/page' -import { TagsList } from '../../../components/tags-list' -import { - foldPath, - getFoldBySlug, - updateFold, -} from '../../../lib/firebase/folds' -import Custom404 from '../../404' -import { SiteLink } from '../../../components/site-link' -import { toCamelCase } from '../../../lib/util/format' -import { useFold } from '../../../hooks/use-fold' - -export async function getStaticProps(props: { params: { foldSlug: string } }) { - const { foldSlug } = props.params - - const fold = await getFoldBySlug(foldSlug) - - return { - props: { fold }, - - revalidate: 60, // regenerate after a minute - } -} - -export async function getStaticPaths() { - return { paths: [], fallback: 'blocking' } -} - -export default function EditFoldPage(props: { fold: Fold | null }) { - const fold = useFold(props.fold?.id ?? '') ?? props.fold - const [name, setName] = useState(fold?.name ?? '') - - const initialOtherTags = - fold?.tags.filter((tag) => tag !== toCamelCase(name)).join(', ') ?? '' - - const [otherTags, setOtherTags] = useState(initialOtherTags) - const [isSubmitting, setIsSubmitting] = useState(false) - - if (!fold) return <Custom404 /> - - const tags = parseWordsAsTags(toCamelCase(name) + ' ' + otherTags) - - const saveDisabled = - !name || (name === fold.name && _.isEqual(tags, fold.tags)) - - const onSubmit = async () => { - setIsSubmitting(true) - - await updateFold(fold, { - name, - tags, - }) - - setIsSubmitting(false) - } - - return ( - <Page> - <Col className="items-center"> - <Col className="max-w-2xl w-full px-2 sm:px-0"> - <SiteLink href={foldPath(fold)}> - <ArrowCircleLeftIcon className="h-5 w-5 text-gray-500 inline mr-1" />{' '} - {fold.name} - </SiteLink> - - <Spacer h={4} /> - - <div className="form-control w-full"> - <label className="label"> - <span className="mb-1">Fold name</span> - </label> - - <input - placeholder="Your fold name" - className="input input-bordered resize-none" - disabled={isSubmitting} - value={name} - onChange={(e) => setName(e.target.value || '')} - /> - </div> - - <Spacer h={4} /> - - <div className="form-control w-full"> - <label className="label"> - <span className="mb-1">Tags</span> - </label> - - <input - placeholder="Politics, Economics, Rationality" - className="input input-bordered resize-none" - disabled={isSubmitting} - value={otherTags} - onChange={(e) => setOtherTags(e.target.value || '')} - /> - </div> - - <Spacer h={4} /> - <TagsList tags={tags.map((tag) => `#${tag}`)} noLink /> - <Spacer h={4} /> - - <button - className={clsx( - 'btn self-end', - saveDisabled ? 'btn-disabled' : 'btn-primary', - isSubmitting && 'loading' - )} - onClick={onSubmit} - > - Save - </button> - </Col> - </Col> - </Page> - ) -} diff --git a/web/pages/fold/[foldSlug]/index.tsx b/web/pages/fold/[foldSlug]/index.tsx deleted file mode 100644 index a631a699..00000000 --- a/web/pages/fold/[foldSlug]/index.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import _ from 'lodash' -import Link from 'next/link' -import { Fold } from '../../../../common/fold' -import { Comment } from '../../../../common/comment' -import { Page } from '../../../components/page' -import { Title } from '../../../components/title' -import { Bet, listAllBets } from '../../../lib/firebase/bets' -import { listAllComments } from '../../../lib/firebase/comments' -import { Contract } from '../../../lib/firebase/contracts' -import { - foldPath, - getFoldBySlug, - getFoldContracts, -} from '../../../lib/firebase/folds' -import { ActivityFeed, findActiveContracts } from '../../activity' -import { TagsList } from '../../../components/tags-list' -import { Row } from '../../../components/layout/row' -import { UserLink } from '../../../components/user-page' -import { getUser, User } from '../../../lib/firebase/users' -import { Spacer } from '../../../components/layout/spacer' -import { Col } from '../../../components/layout/col' -import { SiteLink } from '../../../components/site-link' -import { useUser } from '../../../hooks/use-user' -import { useFold } from '../../../hooks/use-fold' - -export async function getStaticProps(props: { params: { foldSlug: string } }) { - const { foldSlug } = props.params - - const fold = await getFoldBySlug(foldSlug) - const curatorPromise = fold ? getUser(fold.curatorId) : null - - const contracts = fold ? await getFoldContracts(fold).catch((_) => []) : [] - const contractComments = await Promise.all( - contracts.map((contract) => listAllComments(contract.id).catch((_) => [])) - ) - - let activeContracts = findActiveContracts( - contracts, - _.flatten(contractComments), - 365 - ) - const [resolved, unresolved] = _.partition( - activeContracts, - ({ isResolved }) => isResolved - ) - activeContracts = [...unresolved, ...resolved] - - const activeContractBets = await Promise.all( - activeContracts.map((contract) => listAllBets(contract.id).catch((_) => [])) - ) - const activeContractComments = activeContracts.map( - (contract) => - contractComments[contracts.findIndex((c) => c.id === contract.id)] - ) - - const curator = await curatorPromise - - return { - props: { - fold, - curator, - activeContracts, - activeContractBets, - activeContractComments, - }, - - revalidate: 60, // regenerate after a minute - } -} - -export async function getStaticPaths() { - return { paths: [], fallback: 'blocking' } -} - -export default function FoldPage(props: { - fold: Fold - curator: User - activeContracts: Contract[] - activeContractBets: Bet[][] - activeContractComments: Comment[][] -}) { - const { - curator, - activeContracts, - activeContractBets, - activeContractComments, - } = props - - const fold = useFold(props.fold.id) ?? props.fold - - return ( - <Page wide> - <Col className="items-center"> - <Col> - <Title className="!mt-0" text={fold.name} /> - - <div className="tabs mb-4"> - <div className="tab tab-bordered tab-active">Activity</div> - - <Link href={foldPath(fold, 'markets')}> - <a className="tab tab-bordered">Markets</a> - </Link> - <Link href={foldPath(fold, 'leaderboards')}> - <a className="tab tab-bordered ">Leaderboards</a> - </Link> - </div> - - <Row className="gap-8 bg-"> - <Col className="max-w-2xl w-full"> - <ActivityFeed - contracts={activeContracts} - contractBets={activeContractBets} - contractComments={activeContractComments} - /> - </Col> - <FoldOverview fold={fold} curator={curator} /> - </Row> - </Col> - </Col> - </Page> - ) -} - -function FoldOverview(props: { fold: Fold; curator: User }) { - const { fold, curator } = props - const { tags, curatorId } = fold - - const user = useUser() - const isCurator = user?.id === curatorId - - return ( - <Col style={{ maxWidth: 350 }}> - <div className="px-4 py-3 bg-indigo-700 text-white text-sm rounded-t"> - About community - </div> - <Col className="p-4 bg-white self-start gap-2 rounded-b"> - {isCurator && ( - <SiteLink className="text-sm " href={foldPath(fold, 'edit')}> - Edit - </SiteLink> - )} - - <Row> - <div className="text-gray-500 mr-1">Curated by</div> - <UserLink - className="text-neutral" - name={curator.name} - username={curator.username} - /> - </Row> - - <Spacer h={2} /> - <div className="text-gray-500"> - This is a community for predicting asdf asd fasdf asdf asdf . - </div> - - <Spacer h={2} /> - - <TagsList tags={tags.map((tag) => `#${tag}`)} /> - </Col> - </Col> - ) -} diff --git a/web/pages/fold/[foldSlug]/leaderboards.tsx b/web/pages/fold/[foldSlug]/leaderboards.tsx deleted file mode 100644 index ea89a5ed..00000000 --- a/web/pages/fold/[foldSlug]/leaderboards.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import _ from 'lodash' -import { ArrowCircleLeftIcon } from '@heroicons/react/solid' - -import { Col } from '../../../components/layout/col' -import { Leaderboard } from '../../../components/leaderboard' -import { Page } from '../../../components/page' -import { SiteLink } from '../../../components/site-link' -import { formatMoney } from '../../../lib/util/format' -import { - foldPath, - getFoldBySlug, - getFoldContracts, -} from '../../../lib/firebase/folds' -import { Fold } from '../../../../common/fold' -import { Spacer } from '../../../components/layout/spacer' -import { scoreCreators, scoreTraders } from '../../../lib/firebase/scoring' -import { getUser, User } from '../../../lib/firebase/users' -import { listAllBets } from '../../../lib/firebase/bets' - -export async function getStaticProps(props: { params: { foldSlug: string } }) { - const { foldSlug } = props.params - - const fold = await getFoldBySlug(foldSlug) - const contracts = fold ? await getFoldContracts(fold) : [] - const bets = await Promise.all( - contracts.map((contract) => listAllBets(contract.id)) - ) - - const creatorScores = scoreCreators(contracts, bets) - const [topCreators, topCreatorScores] = await toUserScores(creatorScores) - - const traderScores = scoreTraders(contracts, bets) - const [topTraders, topTraderScores] = await toUserScores(traderScores) - - return { - props: { fold, topTraders, topTraderScores, topCreators, topCreatorScores }, - - revalidate: 60, // regenerate after 60 seconds - } -} - -export async function getStaticPaths() { - return { paths: [], fallback: 'blocking' } -} - -async function toUserScores(userScores: { [userId: string]: number }) { - const topUserPairs = _.take( - _.sortBy(Object.entries(userScores), ([_, score]) => -1 * score), - 10 - ) - const topUsers = await Promise.all( - topUserPairs.map(([userId]) => getUser(userId)) - ) - const topUserScores = topUserPairs.map(([_, score]) => score) - return [topUsers, topUserScores] as const -} - -export default function Leaderboards(props: { - fold: Fold - topTraders: User[] - topTraderScores: number[] - topCreators: User[] - topCreatorScores: number[] -}) { - const { fold, topTraders, topTraderScores, topCreators, topCreatorScores } = - props - return ( - <Page> - <SiteLink href={foldPath(fold)}> - <ArrowCircleLeftIcon className="h-5 w-5 text-gray-500 inline mr-1" />{' '} - {fold.name} - </SiteLink> - - <Spacer h={4} /> - - <Col className="lg:flex-row gap-10"> - <Leaderboard - title="🏅 Top traders" - users={topTraders} - columns={[ - { - header: 'Total profit', - renderCell: (user) => - formatMoney(topTraderScores[topTraders.indexOf(user)]), - }, - ]} - /> - <Leaderboard - title="🏅 Top creators" - users={topCreators} - columns={[ - { - header: 'Market pool', - renderCell: (user) => - formatMoney(topCreatorScores[topCreators.indexOf(user)]), - }, - ]} - /> - </Col> - </Page> - ) -} diff --git a/web/pages/fold/[foldSlug]/markets.tsx b/web/pages/fold/[foldSlug]/markets.tsx deleted file mode 100644 index 667cb68a..00000000 --- a/web/pages/fold/[foldSlug]/markets.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import _ from 'lodash' -import { Contract } from '../../../../common/contract' -import { Fold } from '../../../../common/fold' -import { SearchableGrid } from '../../../components/contracts-list' -import { FoldBack } from '../../../components/fold-back' -import { Spacer } from '../../../components/layout/spacer' -import { Page } from '../../../components/page' -import { SEO } from '../../../components/SEO' -import { useQueryAndSortParams } from '../../../hooks/use-sort-and-query-params' -import { getFoldBySlug, getFoldContracts } from '../../../lib/firebase/folds' - -export async function getStaticProps(props: { params: { foldSlug: string } }) { - const { foldSlug } = props.params - - const fold = await getFoldBySlug(foldSlug) - const contracts = fold ? await getFoldContracts(fold) : [] - - return { - props: { - fold, - contracts, - }, - - revalidate: 60, // regenerate after a minute - } -} - -export async function getStaticPaths() { - return { paths: [], fallback: 'blocking' } -} - -export default function Markets(props: { fold: Fold; contracts: Contract[] }) { - const { fold, contracts } = props - const { query, setQuery, sort, setSort } = useQueryAndSortParams({ - defaultSort: 'most-traded', - }) - - return ( - <Page> - <SEO - title={`${fold.name}'s markets`} - description={`Explore or search all the markets of ${fold.name}`} - url="/markets" - /> - - <FoldBack fold={fold} /> - - <Spacer h={4} /> - - <SearchableGrid - contracts={contracts} - query={query} - setQuery={setQuery} - sort={sort} - setSort={setSort} - /> - </Page> - ) -}