From 60f68b178d3e82ad2cd1a9b951bd34b040d01454 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 21 Jan 2022 17:21:46 -0600 Subject: [PATCH] Folds (#34) * Fold type, fold page, query for fold contracts * Tsconfig: target esnext, nounused locals: false * Store tags in field on contract. Script to update contract tags * Show tags on fold page * Load all fold comments server-side to serve better feed * Fix the annoying firebase already initialized error! * Add links to /edit and /leaderboards for fold * Page with list of folds * UI for creating a fold * Create a fold * Edit fold page --- common/contract.ts | 1 + common/fold.ts | 17 ++ common/new-contract.ts | 2 + common/util/parse.ts | 21 +++ firestore.rules | 4 + functions/src/create-fold.ts | 81 +++++++++ functions/src/index.ts | 1 + functions/src/scripts/update-contract-tags.ts | 49 ++++++ functions/tsconfig.json | 1 - web/components/contract-card.tsx | 13 +- web/components/contracts-list.tsx | 2 +- web/components/leaderboard.tsx | 58 +++++++ web/components/nav-bar.tsx | 11 ++ web/components/tags-list.tsx | 15 ++ web/lib/firebase/api-call.ts | 9 +- web/lib/firebase/comments.ts | 5 +- web/lib/firebase/contracts.ts | 3 +- web/lib/firebase/folds.ts | 71 ++++++++ web/lib/firebase/init.ts | 5 +- web/lib/firebase/utils.ts | 4 +- web/lib/util/parse.ts | 9 - web/pages/[username]/[contractSlug].tsx | 4 +- web/pages/activity.tsx | 11 +- web/pages/fold/[foldSlug]/edit.tsx | 116 +++++++++++++ web/pages/fold/[foldSlug]/index.tsx | 128 ++++++++++++++ web/pages/fold/[foldSlug]/leaderboards.tsx | 64 +++++++ web/pages/folds.tsx | 158 ++++++++++++++++++ web/pages/index.tsx | 15 +- web/pages/leaderboards.tsx | 58 +------ web/tsconfig.json | 2 +- 30 files changed, 832 insertions(+), 106 deletions(-) create mode 100644 common/fold.ts create mode 100644 common/util/parse.ts create mode 100644 functions/src/create-fold.ts create mode 100644 functions/src/scripts/update-contract-tags.ts create mode 100644 web/components/leaderboard.tsx create mode 100644 web/components/tags-list.tsx create mode 100644 web/lib/firebase/folds.ts delete mode 100644 web/lib/util/parse.ts create mode 100644 web/pages/fold/[foldSlug]/edit.tsx create mode 100644 web/pages/fold/[foldSlug]/index.tsx create mode 100644 web/pages/fold/[foldSlug]/leaderboards.tsx create mode 100644 web/pages/folds.tsx diff --git a/common/contract.ts b/common/contract.ts index 5e344f09..f3405c4e 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -9,6 +9,7 @@ export type Contract = { question: string description: string // More info about what the contract is about + tags: string[] outcomeType: 'BINARY' // | 'MULTI' | 'interval' | 'date' // outcomes: ['YES', 'NO'] visibility: 'public' | 'unlisted' diff --git a/common/fold.ts b/common/fold.ts new file mode 100644 index 00000000..c9df3389 --- /dev/null +++ b/common/fold.ts @@ -0,0 +1,17 @@ +export type Fold = { + id: string + slug: string + name: string + curatorId: string // User id + createdTime: number + + tags: string[] + + contractIds: string[] + excludedContractIds: string[] + + // Invariant: exactly one of the following is defined. + // Default: creatorIds: undefined, excludedCreatorIds: [] + creatorIds?: string[] + excludedCreatorIds?: string[] +} diff --git a/common/new-contract.ts b/common/new-contract.ts index 85f5dedc..c84f6f6e 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -2,6 +2,7 @@ import { calcStartPool } from './antes' import { Contract } from './contract' import { User } from './user' +import { parseTags } from './util/parse' export function getNewContract( id: string, @@ -28,6 +29,7 @@ export function getNewContract( question: question.trim(), description: description.trim(), + tags: parseTags(`${question} ${description}`), visibility: 'public', mechanism: 'dpm-2', diff --git a/common/util/parse.ts b/common/util/parse.ts new file mode 100644 index 00000000..5f78125e --- /dev/null +++ b/common/util/parse.ts @@ -0,0 +1,21 @@ +export function parseTags(text: string) { + const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi + const matches = (text.match(regex) || []).map((match) => + match.trim().substring(1) + ) + const tagSet = new Set(matches) + const uniqueTags: string[] = [] + tagSet.forEach((tag) => uniqueTags.push(tag)) + return uniqueTags +} + +export function parseWordsAsTags(text: string) { + const regex = /(?:^|\s)(?:[a-z0-9_]+)/gi + const matches = (text.match(regex) || []) + .map((match) => match.trim()) + .filter((tag) => tag) + const tagSet = new Set(matches) + const uniqueTags: string[] = [] + tagSet.forEach((tag) => uniqueTags.push(tag)) + return uniqueTags +} diff --git a/firestore.rules b/firestore.rules index 4b1d7142..2091eb0c 100644 --- a/firestore.rules +++ b/firestore.rules @@ -35,5 +35,9 @@ service cloud.firestore { allow read; } + match /folds/{foldId} { + allow read; + allow update: if request.auth.uid == resource.data.curatorId; + } } } \ No newline at end of file diff --git a/functions/src/create-fold.ts b/functions/src/create-fold.ts new file mode 100644 index 00000000..eb75a762 --- /dev/null +++ b/functions/src/create-fold.ts @@ -0,0 +1,81 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import * as _ from 'lodash' + +import { getUser } from './utils' +import { Contract } from '../../common/contract' +import { slugify } from '../../common/util/slugify' +import { randomString } from '../../common/util/random' +import { Fold } from '../../common/fold' + +export const createFold = functions.runWith({ minInstances: 1 }).https.onCall( + async ( + data: { + name: string + tags: string[] + }, + context + ) => { + const userId = context?.auth?.uid + if (!userId) return { status: 'error', message: 'Not authorized' } + + const creator = await getUser(userId) + if (!creator) return { status: 'error', message: 'User not found' } + + const { name, tags } = data + + if (!name || typeof name !== 'string') + return { status: 'error', message: 'Name must be a non-empty string' } + + if (!_.isArray(tags)) + return { status: 'error', message: 'Tags must be an array of strings' } + + console.log( + 'creating fold for', + creator.username, + 'named', + name, + 'on', + tags + ) + + const slug = await getSlug(name) + + const foldRef = firestore.collection('folds').doc() + + const fold: Fold = { + id: foldRef.id, + curatorId: userId, + slug, + name, + tags, + createdTime: Date.now(), + contractIds: [], + excludedContractIds: [], + excludedCreatorIds: [], + } + + await foldRef.create(fold) + + return { status: 'success', fold } + } +) + +const getSlug = async (name: string) => { + const proposedSlug = slugify(name) + + const preexistingFold = await getFoldFromSlug(proposedSlug) + + return preexistingFold ? proposedSlug + '-' + randomString() : proposedSlug +} + +const firestore = admin.firestore() + +export async function getFoldFromSlug(slug: string) { + const snap = await firestore + .collection('folds') + .where('slug', '==', slug) + .get() + + return snap.empty ? undefined : (snap.docs[0].data() as Contract) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 0b4ef72c..4dfd1e57 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -10,6 +10,7 @@ export * from './stripe' export * from './sell-bet' export * from './create-contract' export * from './create-user' +export * from './create-fold' export * from './unsubscribe' export * from './update-contract-metrics' export * from './update-user-metrics' diff --git a/functions/src/scripts/update-contract-tags.ts b/functions/src/scripts/update-contract-tags.ts new file mode 100644 index 00000000..933eee56 --- /dev/null +++ b/functions/src/scripts/update-contract-tags.ts @@ -0,0 +1,49 @@ +import * as admin from 'firebase-admin' +import * as _ from 'lodash' + +// Generate your own private key, and set the path below: +// https://console.firebase.google.com/u/0/project/mantic-markets/settings/serviceaccounts/adminsdk +// James: +const serviceAccount = require('/Users/jahooma/mantic-markets-firebase-adminsdk-1ep46-820891bb87.json') +// Stephen: +// const serviceAccount = require('../../../../Downloads/dev-mantic-markets-firebase-adminsdk-sir5m-b2d27f8970.json') +admin.initializeApp({ + credential: admin.credential.cert(serviceAccount), +}) +const firestore = admin.firestore() + +import { Contract } from '../../../common/contract' +import { parseTags } from '../../../common/util/parse' +import { getValues } from '../utils' + +async function updateContractTags() { + console.log('Updating contracts tags') + + const contracts = await getValues(firestore.collection('contracts')) + + console.log('Loaded', contracts.length, 'contracts') + + for (const contract of contracts) { + const contractRef = firestore.doc(`contracts/${contract.id}`) + + const tags = _.uniq([ + ...parseTags(contract.question + contract.description), + ...(contract.tags ?? []), + ]) + + console.log( + 'Updating tags', + contract.slug, + 'from', + contract.tags, + 'to', + tags + ) + + await contractRef.update({ + tags, + } as Partial) + } +} + +if (require.main === module) updateContractTags().then(() => process.exit()) diff --git a/functions/tsconfig.json b/functions/tsconfig.json index 6a0ed692..c836df11 100644 --- a/functions/tsconfig.json +++ b/functions/tsconfig.json @@ -2,7 +2,6 @@ "compilerOptions": { "module": "commonjs", "noImplicitReturns": true, - "noUnusedLocals": true, "outDir": "lib", "sourceMap": true, "strict": true, diff --git a/web/components/contract-card.tsx b/web/components/contract-card.tsx index 26d18023..90238b3f 100644 --- a/web/components/contract-card.tsx +++ b/web/components/contract-card.tsx @@ -3,17 +3,17 @@ import Link from 'next/link' import { Row } from '../components/layout/row' import { formatMoney } from '../lib/util/format' import { UserLink } from './user-page' -import { Linkify } from './linkify' import { Contract, contractMetrics, contractPath, } from '../lib/firebase/contracts' import { Col } from './layout/col' -import { parseTags } from '../lib/util/parse' +import { parseTags } from '../../common/util/parse' import dayjs from 'dayjs' import { TrendingUpIcon, ClockIcon } from '@heroicons/react/solid' import { DateTimeTooltip } from './datetime-tooltip' +import { TagsList } from './tags-list' export function ContractCard(props: { contract: Contract @@ -196,14 +196,7 @@ export function ContractDetails(props: { contract: Contract }) { {tags.length > 0 && ( <>
- - - {tags.map((tag) => ( -
- -
- ))} -
+ )} diff --git a/web/components/contracts-list.tsx b/web/components/contracts-list.tsx index cef3bdf0..0246e092 100644 --- a/web/components/contracts-list.tsx +++ b/web/components/contracts-list.tsx @@ -11,7 +11,7 @@ import { import { User } from '../lib/firebase/users' import { Col } from './layout/col' import { SiteLink } from './site-link' -import { parseTags } from '../lib/util/parse' +import { parseTags } from '../../common/util/parse' import { ContractCard } from './contract-card' import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params' diff --git a/web/components/leaderboard.tsx b/web/components/leaderboard.tsx new file mode 100644 index 00000000..429ac1d7 --- /dev/null +++ b/web/components/leaderboard.tsx @@ -0,0 +1,58 @@ +import Image from 'next/image' +import { User } from '../../common/user' +import { Row } from './layout/row' +import { SiteLink } from './site-link' +import { Title } from './title' + +export function Leaderboard(props: { + title: string + users: User[] + columns: { + header: string + renderCell: (user: User) => any + }[] +}) { + const { title, users, columns } = props + return ( +
+ + <div className="overflow-x-auto"> + <table className="table table-zebra table-compact text-gray-500 w-full"> + <thead> + <tr className="p-2"> + <th>#</th> + <th>Name</th> + {columns.map((column) => ( + <th key={column.header}>{column.header}</th> + ))} + </tr> + </thead> + <tbody> + {users.map((user, index) => ( + <tr key={user.id}> + <td>{index + 1}</td> + <td> + <SiteLink className="relative" href={`/${user.username}`}> + <Row className="items-center gap-4"> + <Image + className="rounded-full bg-gray-400 flex-shrink-0 ring-8 ring-gray-50" + src={user.avatarUrl || ''} + alt="" + width={32} + height={32} + /> + <div>{user.name}</div> + </Row> + </SiteLink> + </td> + {columns.map((column) => ( + <td key={column.header}>{column.renderCell(user)}</td> + ))} + </tr> + ))} + </tbody> + </table> + </div> + </div> + ) +} diff --git a/web/components/nav-bar.tsx b/web/components/nav-bar.tsx index 246864b1..74a54c8b 100644 --- a/web/components/nav-bar.tsx +++ b/web/components/nav-bar.tsx @@ -57,6 +57,17 @@ function NavOptions(props: { user: User | null; themeClasses: string }) { </Link> )} + {/* <Link href="/folds"> + <a + className={clsx( + 'text-base hidden md:block whitespace-nowrap', + themeClasses + )} + > + Folds + </a> + </Link> */} + <Link href="/markets"> <a className={clsx( diff --git a/web/components/tags-list.tsx b/web/components/tags-list.tsx new file mode 100644 index 00000000..1fc13b33 --- /dev/null +++ b/web/components/tags-list.tsx @@ -0,0 +1,15 @@ +import { Row } from './layout/row' +import { Linkify } from './linkify' + +export function TagsList(props: { tags: string[] }) { + const { tags } = props + return ( + <Row className="gap-2 flex-wrap text-sm text-gray-500"> + {tags.map((tag) => ( + <div key={tag} className="bg-gray-100 px-1"> + <Linkify text={tag} gray /> + </div> + ))} + </Row> + ) +} diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index 6c5e98ab..1776e46f 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -1,13 +1,20 @@ import { getFunctions, httpsCallable } from 'firebase/functions' +import { Fold } from '../../../common/fold' import { User } from '../../../common/user' import { randomString } from '../../../common/util/random' const functions = getFunctions() -export const cloudFunction = (name: string) => httpsCallable(functions, name) +export const cloudFunction = <RequestData, ResponseData>(name: string) => + httpsCallable<RequestData, ResponseData>(functions, name) export const createContract = cloudFunction('createContract') +export const createFold = cloudFunction< + { name: string; tags: string[] }, + { status: 'error' | 'success'; message?: string; fold?: Fold } +>('createFold') + export const placeBet = cloudFunction('placeBet') export const resolveMarket = cloudFunction('resolveMarket') diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index e58d8a1c..d272581e 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -5,7 +5,6 @@ import { setDoc, query, collectionGroup, - getDocs, where, orderBy, } from 'firebase/firestore' @@ -75,9 +74,7 @@ const recentCommentsQuery = query( ) export async function getRecentComments() { - const snapshot = await getDocs(recentCommentsQuery) - const comments = snapshot.docs.map((doc) => doc.data() as Comment) - return comments + return getValues<Comment>(recentCommentsQuery) } export function listenForRecentComments( diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index a4d78b59..c7328326 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -24,7 +24,6 @@ import { createRNG, shuffle } from '../../../common/util/random' export type { Contract } export function contractPath(contract: Contract) { - // For now, derive username from creatorName return `/${contract.creatorUsername}/${contract.slug}` } @@ -54,7 +53,7 @@ export function contractMetrics(contract: Contract) { } const db = getFirestore(app) -const contractCollection = collection(db, 'contracts') +export const contractCollection = collection(db, 'contracts') // Push contract to Firestore export async function setContract(contract: Contract) { diff --git a/web/lib/firebase/folds.ts b/web/lib/firebase/folds.ts new file mode 100644 index 00000000..f1da0444 --- /dev/null +++ b/web/lib/firebase/folds.ts @@ -0,0 +1,71 @@ +import { collection, doc, query, updateDoc, where } from 'firebase/firestore' +import { Fold } from '../../../common/fold' +import { Contract, contractCollection } from './contracts' +import { db } from './init' +import { getValues } from './utils' + +const foldCollection = collection(db, 'folds') + +export function foldPath(fold: Fold, subpath?: 'edit' | 'leaderboards') { + return `/fold/${fold.slug}${subpath ? `/${subpath}` : ''}` +} + +export function updateFold(fold: Fold, updates: Partial<Fold>) { + return updateDoc(doc(foldCollection, fold.id), updates) +} + +export async function listAllFolds() { + return getValues<Fold>(foldCollection) +} + +export async function getFoldBySlug(slug: string) { + const q = query(foldCollection, where('slug', '==', slug)) + const folds = await getValues<Fold>(q) + + return folds.length === 0 ? null : folds[0] +} + +export async function getFoldContracts(fold: Fold) { + const { + tags, + contractIds, + excludedContractIds, + creatorIds, + excludedCreatorIds, + } = fold + + const [tagsContracts, includedContracts] = await Promise.all([ + // TODO: if tags.length > 10, execute multiple parallel queries + tags.length > 0 + ? getValues<Contract>( + query(contractCollection, where('tags', 'array-contains-any', tags)) + ) + : [], + + // TODO: if contractIds.length > 10, execute multiple parallel queries + contractIds.length > 0 + ? getValues<Contract>( + query(contractCollection, where('id', 'in', contractIds)) + ) + : [], + ]) + + const excludedContractsSet = new Set(excludedContractIds) + + const creatorSet = creatorIds ? new Set(creatorIds) : undefined + const excludedCreatorSet = excludedCreatorIds + ? new Set(excludedCreatorIds) + : undefined + + const approvedContracts = tagsContracts.filter((contract) => { + const { id, creatorId } = contract + + if (excludedContractsSet.has(id)) return false + if (creatorSet && !creatorSet.has(creatorId)) return false + if (excludedCreatorSet && excludedCreatorSet.has(creatorId)) return false + + return true + }) + + return [...approvedContracts, ...includedContracts] +} diff --git a/web/lib/firebase/init.ts b/web/lib/firebase/init.ts index 98734946..86c0648a 100644 --- a/web/lib/firebase/init.ts +++ b/web/lib/firebase/init.ts @@ -1,5 +1,5 @@ import { getFirestore } from '@firebase/firestore' -import { initializeApp } from 'firebase/app' +import { initializeApp, getApps, getApp } from 'firebase/app' // TODO: Reenable this when we have a way to set the Firebase db in dev // export const isProd = process.env.NODE_ENV === 'production' @@ -26,5 +26,6 @@ const firebaseConfig = isProd } // Initialize Firebase -export const app = initializeApp(firebaseConfig) +export const app = getApps().length ? getApp() : initializeApp(firebaseConfig) + export const db = getFirestore(app) diff --git a/web/lib/firebase/utils.ts b/web/lib/firebase/utils.ts index 479f0087..fc516253 100644 --- a/web/lib/firebase/utils.ts +++ b/web/lib/firebase/utils.ts @@ -8,8 +8,8 @@ import { DocumentReference, } from 'firebase/firestore' -export const getValue = async <T>(collectionName: string, docName: string) => { - const snap = await getDoc(doc(db, collectionName, docName)) +export const getValue = async <T>(doc: DocumentReference) => { + const snap = await getDoc(doc) return snap.exists() ? (snap.data() as T) : null } diff --git a/web/lib/util/parse.ts b/web/lib/util/parse.ts deleted file mode 100644 index 4b20778f..00000000 --- a/web/lib/util/parse.ts +++ /dev/null @@ -1,9 +0,0 @@ -import _ from 'lodash' - -export function parseTags(text: string) { - const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi - const matches = (text.match(regex) || []).map((match) => - match.trim().substring(1) - ) - return _.uniqBy(matches, (tag) => tag.toLowerCase()) -} diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 2d192390..87a81bde 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -23,7 +23,9 @@ import { Bet, listAllBets } from '../../lib/firebase/bets' import { Comment, listAllComments } from '../../lib/firebase/comments' import Custom404 from '../404' -export async function getStaticProps(props: { params: any }) { +export async function getStaticProps(props: { + params: { username: string; contractSlug: string } +}) { const { username, contractSlug } = props.params const contract = (await getContractFromSlug(contractSlug)) || null const contractId = contract?.id diff --git a/web/pages/activity.tsx b/web/pages/activity.tsx index e05ff1df..359990dd 100644 --- a/web/pages/activity.tsx +++ b/web/pages/activity.tsx @@ -2,8 +2,6 @@ import _ from 'lodash' import { ContractFeed } from '../components/contract-feed' import { Page } from '../components/page' import { Title } from '../components/title' -import { useRecentComments } from '../hooks/use-comments' -import { useContracts } from '../hooks/use-contracts' import { Contract } from '../lib/firebase/contracts' import { Comment } from '../lib/firebase/comments' import { Col } from '../components/layout/col' @@ -69,18 +67,13 @@ export function ActivityFeed(props: { contractBets: Bet[][] contractComments: Comment[][] }) { - const { contractBets, contractComments } = props - const contracts = useContracts() ?? props.contracts - const recentComments = useRecentComments() - const activeContracts = recentComments - ? findActiveContracts(contracts, recentComments) - : props.contracts + const { contracts, contractBets, contractComments } = props return contracts.length > 0 ? ( <Col className="items-center"> <Col className="w-full max-w-3xl"> <Col className="w-full bg-white self-center divide-gray-300 divide-y"> - {activeContracts.map((contract, i) => ( + {contracts.map((contract, i) => ( <div key={contract.id} className="py-6 px-2 sm:px-4"> <ContractFeed contract={contract} diff --git a/web/pages/fold/[foldSlug]/edit.tsx b/web/pages/fold/[foldSlug]/edit.tsx new file mode 100644 index 00000000..efc6bb53 --- /dev/null +++ b/web/pages/fold/[foldSlug]/edit.tsx @@ -0,0 +1,116 @@ +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' + +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 } = props + + const [name, setName] = useState(fold?.name ?? '') + const [tags, setTags] = useState(fold?.tags.join(', ') ?? '') + const [isSubmitting, setIsSubmitting] = useState(false) + + if (!fold) return <Custom404 /> + + const saveDisabled = + !name || + !tags || + (name === fold.name && _.isEqual(parseWordsAsTags(tags), fold.tags)) + + const onSubmit = async () => { + setIsSubmitting(true) + + await updateFold(fold, { name, tags: parseWordsAsTags(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={tags} + onChange={(e) => setTags(e.target.value || '')} + /> + </div> + + <Spacer h={4} /> + <TagsList tags={parseWordsAsTags(tags)} /> + <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 new file mode 100644 index 00000000..07408fec --- /dev/null +++ b/web/pages/fold/[foldSlug]/index.tsx @@ -0,0 +1,128 @@ +import _ from 'lodash' +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' + +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) : [] + const contractComments = await Promise.all( + contracts.map((contract) => listAllComments(contract.id)) + ) + + const activeContracts = findActiveContracts( + contracts, + _.flatten(contractComments) + ) + const activeContractBets = await Promise.all( + activeContracts.map((contract) => listAllBets(contract.id)) + ) + 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 { + fold, + curator, + activeContracts, + activeContractBets, + activeContractComments, + } = props + + const { tags, curatorId } = fold + + const user = useUser() + const isCurator = user?.id === curatorId + + return ( + <Page> + <Col className="items-center"> + <Col className="max-w-3xl w-full"> + <Title text={fold.name} /> + + <Row className="items-center gap-2 mb-2 flex-wrap"> + {isCurator && ( + <> + <SiteLink className="text-sm " href={foldPath(fold, 'edit')}> + Edit + </SiteLink> + <div className="text-gray-500">•</div> + </> + )} + <SiteLink className="text-sm" href={foldPath(fold, 'leaderboards')}> + Leaderboards + </SiteLink> + <div className="text-gray-500">•</div> + <Row> + <div className="text-sm text-gray-500 mr-1">Curated by</div> + <UserLink + className="text-sm text-neutral" + name={curator.name} + username={curator.username} + /> + </Row> + </Row> + + <TagsList tags={tags.map((tag) => `#${tag}`)} /> + + <Spacer h={4} /> + + <ActivityFeed + contracts={activeContracts} + contractBets={activeContractBets} + contractComments={activeContractComments} + /> + </Col> + </Col> + </Page> + ) +} diff --git a/web/pages/fold/[foldSlug]/leaderboards.tsx b/web/pages/fold/[foldSlug]/leaderboards.tsx new file mode 100644 index 00000000..66070790 --- /dev/null +++ b/web/pages/fold/[foldSlug]/leaderboards.tsx @@ -0,0 +1,64 @@ +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 } from '../../../lib/firebase/folds' +import { Fold } from '../../../../common/fold' +import { Spacer } from '../../../components/layout/spacer' + +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 Leaderboards(props: { fold: Fold }) { + const { fold } = 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="items-center lg:flex-row gap-10"> + <Leaderboard + title="🏅 Top traders" + users={[]} + columns={[ + { + header: 'Total profit', + renderCell: (user) => formatMoney(user.totalPnLCached), + }, + ]} + /> + <Leaderboard + title="🏅 Top creators" + users={[]} + columns={[ + { + header: 'Market volume', + renderCell: (user) => formatMoney(user.creatorVolumeCached), + }, + ]} + /> + </Col> + </Page> + ) +} diff --git a/web/pages/folds.tsx b/web/pages/folds.tsx new file mode 100644 index 00000000..f44b91bb --- /dev/null +++ b/web/pages/folds.tsx @@ -0,0 +1,158 @@ +import clsx from 'clsx' +import _ from 'lodash' +import { useRouter } from 'next/router' +import { useState } from 'react' +import { Fold } from '../../common/fold' +import { parseWordsAsTags } from '../../common/util/parse' +import { ConfirmationButton } from '../components/confirmation-button' +import { Col } from '../components/layout/col' +import { Row } from '../components/layout/row' +import { Spacer } from '../components/layout/spacer' +import { Page } from '../components/page' +import { SiteLink } from '../components/site-link' +import { TagsList } from '../components/tags-list' +import { Title } from '../components/title' +import { UserLink } from '../components/user-page' +import { useUser } from '../hooks/use-user' +import { createFold } from '../lib/firebase/api-call' +import { foldPath, listAllFolds } from '../lib/firebase/folds' +import { getUser, User } from '../lib/firebase/users' + +export async function getStaticProps() { + const folds = await listAllFolds().catch((_) => []) + + const curators = await Promise.all( + folds.map((fold) => getUser(fold.curatorId)) + ) + + return { + props: { + folds, + curators, + }, + + revalidate: 60, // regenerate after a minute + } +} + +export default function Folds(props: { folds: Fold[]; curators: User[] }) { + const { folds, curators } = props + + const user = useUser() + + return ( + <Page> + <Col className="items-center"> + <Col className="max-w-2xl w-full px-2 sm:px-0"> + <Row className="justify-between items-center"> + <Title text="Folds" /> + {user && <CreateFoldButton />} + </Row> + + <Col className="gap-4"> + {folds.map((fold, index) => ( + <Row key={fold.id} className="items-center gap-2"> + <SiteLink href={foldPath(fold)}>{fold.name}</SiteLink> + <div /> + <div className="text-sm text-gray-500">12 followers</div> + <div className="text-gray-500">•</div> + <Row> + <div className="text-sm text-gray-500 mr-1">Curated by</div> + <UserLink + className="text-sm text-neutral" + name={curators[index].name} + username={curators[index].username} + /> + </Row> + </Row> + ))} + </Col> + </Col> + </Col> + </Page> + ) +} + +function CreateFoldButton() { + const [name, setName] = useState('') + const [tags, setTags] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + const router = useRouter() + + const onSubmit = async () => { + setIsSubmitting(true) + + const result = await createFold({ + name, + tags: parseWordsAsTags(tags), + }).then((r) => r.data || {}) + + if (result.fold) await router.push(foldPath(result.fold)) + else console.log(result.status, result.message) + + setIsSubmitting(false) + } + + return ( + <ConfirmationButton + id="create-fold" + openModelBtn={{ + label: 'Create a fold', + className: clsx( + isSubmitting ? 'loading btn-disabled' : 'btn-primary', + 'btn-sm' + ), + }} + submitBtn={{ + label: 'Create', + className: clsx(name && tags ? 'btn-primary' : 'btn-disabled'), + }} + onSubmit={onSubmit} + > + <Title className="!mt-0" text="Create a fold" /> + + <Col className="text-gray-500 gap-1"> + <div>A fold is a view of markets that match selected tags.</div> + <div>You can further include or exclude individual markets.</div> + </Col> + + <Spacer h={4} /> + + <form> + <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={tags} + onChange={(e) => setTags(e.target.value || '')} + /> + </div> + + <Spacer h={4} /> + <TagsList tags={parseWordsAsTags(tags)} /> + </form> + </ConfirmationButton> + ) +} diff --git a/web/pages/index.tsx b/web/pages/index.tsx index 9bd5947b..40c68f68 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -13,6 +13,8 @@ import { listAllComments, } from '../lib/firebase/comments' import { Bet, listAllBets } from '../lib/firebase/bets' +import { useContracts } from '../hooks/use-contracts' +import { useRecentComments } from '../hooks/use-comments' import FeedCreate, { FeedPromo } from '../components/feed-create' import { Spacer } from '../components/layout/spacer' import { Col } from '../components/layout/col' @@ -51,12 +53,13 @@ const Home = (props: { activeContractComments: Comment[][] hotContracts: Contract[] }) => { - const { - activeContracts, - activeContractBets, - activeContractComments, - hotContracts, - } = props + const { activeContractBets, activeContractComments, hotContracts } = props + + const contracts = useContracts() ?? props.activeContracts + const recentComments = useRecentComments() + const activeContracts = recentComments + ? findActiveContracts(contracts, recentComments) + : props.activeContracts const user = useUser() diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index 8f96a7e3..0a78ec42 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -1,10 +1,7 @@ import _ from 'lodash' -import Image from 'next/image' import { Col } from '../components/layout/col' -import { Row } from '../components/layout/row' +import { Leaderboard } from '../components/leaderboard' import { Page } from '../components/page' -import { SiteLink } from '../components/site-link' -import { Title } from '../components/title' import { getTopCreators, getTopTraders, User } from '../lib/firebase/users' import { formatMoney } from '../lib/util/format' @@ -57,56 +54,3 @@ export default function Leaderboards(props: { </Page> ) } - -function Leaderboard(props: { - title: string - users: User[] - columns: { - header: string - renderCell: (user: User) => any - }[] -}) { - const { title, users, columns } = props - return ( - <div className="max-w-xl w-full px-1"> - <Title text={title} /> - <div className="overflow-x-auto"> - <table className="table table-zebra table-compact text-gray-500 w-full"> - <thead> - <tr className="p-2"> - <th>#</th> - <th>Name</th> - {columns.map((column) => ( - <th key={column.header}>{column.header}</th> - ))} - </tr> - </thead> - <tbody> - {users.map((user, index) => ( - <tr key={user.id}> - <td>{index + 1}</td> - <td> - <SiteLink className="relative" href={`/${user.username}`}> - <Row className="items-center gap-4"> - <Image - className="rounded-full bg-gray-400 flex-shrink-0 ring-8 ring-gray-50" - src={user.avatarUrl || ''} - alt="" - width={32} - height={32} - /> - <div>{user.name}</div> - </Row> - </SiteLink> - </td> - {columns.map((column) => ( - <td key={column.header}>{column.renderCell(user)}</td> - ))} - </tr> - ))} - </tbody> - </table> - </div> - </div> - ) -} diff --git a/web/tsconfig.json b/web/tsconfig.json index dee57196..1ea03bef 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "esnext", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true,