* 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
This commit is contained in:
James Grugett 2022-01-21 17:21:46 -06:00 committed by GitHub
parent 5be6a75e4b
commit 60f68b178d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 832 additions and 106 deletions

View File

@ -9,6 +9,7 @@ export type Contract = {
question: string question: string
description: string // More info about what the contract is about description: string // More info about what the contract is about
tags: string[]
outcomeType: 'BINARY' // | 'MULTI' | 'interval' | 'date' outcomeType: 'BINARY' // | 'MULTI' | 'interval' | 'date'
// outcomes: ['YES', 'NO'] // outcomes: ['YES', 'NO']
visibility: 'public' | 'unlisted' visibility: 'public' | 'unlisted'

17
common/fold.ts Normal file
View File

@ -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[]
}

View File

@ -2,6 +2,7 @@ import { calcStartPool } from './antes'
import { Contract } from './contract' import { Contract } from './contract'
import { User } from './user' import { User } from './user'
import { parseTags } from './util/parse'
export function getNewContract( export function getNewContract(
id: string, id: string,
@ -28,6 +29,7 @@ export function getNewContract(
question: question.trim(), question: question.trim(),
description: description.trim(), description: description.trim(),
tags: parseTags(`${question} ${description}`),
visibility: 'public', visibility: 'public',
mechanism: 'dpm-2', mechanism: 'dpm-2',

21
common/util/parse.ts Normal file
View File

@ -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
}

View File

@ -35,5 +35,9 @@ service cloud.firestore {
allow read; allow read;
} }
match /folds/{foldId} {
allow read;
allow update: if request.auth.uid == resource.data.curatorId;
}
} }
} }

View File

@ -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)
}

View File

@ -10,6 +10,7 @@ export * from './stripe'
export * from './sell-bet' export * from './sell-bet'
export * from './create-contract' export * from './create-contract'
export * from './create-user' export * from './create-user'
export * from './create-fold'
export * from './unsubscribe' export * from './unsubscribe'
export * from './update-contract-metrics' export * from './update-contract-metrics'
export * from './update-user-metrics' export * from './update-user-metrics'

View File

@ -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<Contract>(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<Contract>)
}
}
if (require.main === module) updateContractTags().then(() => process.exit())

View File

@ -2,7 +2,6 @@
"compilerOptions": { "compilerOptions": {
"module": "commonjs", "module": "commonjs",
"noImplicitReturns": true, "noImplicitReturns": true,
"noUnusedLocals": true,
"outDir": "lib", "outDir": "lib",
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,

View File

@ -3,17 +3,17 @@ import Link from 'next/link'
import { Row } from '../components/layout/row' import { Row } from '../components/layout/row'
import { formatMoney } from '../lib/util/format' import { formatMoney } from '../lib/util/format'
import { UserLink } from './user-page' import { UserLink } from './user-page'
import { Linkify } from './linkify'
import { import {
Contract, Contract,
contractMetrics, contractMetrics,
contractPath, contractPath,
} from '../lib/firebase/contracts' } from '../lib/firebase/contracts'
import { Col } from './layout/col' import { Col } from './layout/col'
import { parseTags } from '../lib/util/parse' import { parseTags } from '../../common/util/parse'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { TrendingUpIcon, ClockIcon } from '@heroicons/react/solid' import { TrendingUpIcon, ClockIcon } from '@heroicons/react/solid'
import { DateTimeTooltip } from './datetime-tooltip' import { DateTimeTooltip } from './datetime-tooltip'
import { TagsList } from './tags-list'
export function ContractCard(props: { export function ContractCard(props: {
contract: Contract contract: Contract
@ -196,14 +196,7 @@ export function ContractDetails(props: { contract: Contract }) {
{tags.length > 0 && ( {tags.length > 0 && (
<> <>
<div className="hidden sm:block"></div> <div className="hidden sm:block"></div>
<TagsList tags={tags} />
<Row className="gap-2 flex-wrap">
{tags.map((tag) => (
<div key={tag} className="bg-gray-100 px-1">
<Linkify text={tag} gray />
</div>
))}
</Row>
</> </>
)} )}
</Col> </Col>

View File

@ -11,7 +11,7 @@ import {
import { User } from '../lib/firebase/users' import { User } from '../lib/firebase/users'
import { Col } from './layout/col' import { Col } from './layout/col'
import { SiteLink } from './site-link' import { SiteLink } from './site-link'
import { parseTags } from '../lib/util/parse' import { parseTags } from '../../common/util/parse'
import { ContractCard } from './contract-card' import { ContractCard } from './contract-card'
import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params' import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params'

View File

@ -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="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>
)
}

View File

@ -57,6 +57,17 @@ function NavOptions(props: { user: User | null; themeClasses: string }) {
</Link> </Link>
)} )}
{/* <Link href="/folds">
<a
className={clsx(
'text-base hidden md:block whitespace-nowrap',
themeClasses
)}
>
Folds
</a>
</Link> */}
<Link href="/markets"> <Link href="/markets">
<a <a
className={clsx( className={clsx(

View File

@ -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>
)
}

View File

@ -1,13 +1,20 @@
import { getFunctions, httpsCallable } from 'firebase/functions' import { getFunctions, httpsCallable } from 'firebase/functions'
import { Fold } from '../../../common/fold'
import { User } from '../../../common/user' import { User } from '../../../common/user'
import { randomString } from '../../../common/util/random' import { randomString } from '../../../common/util/random'
const functions = getFunctions() 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 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 placeBet = cloudFunction('placeBet')
export const resolveMarket = cloudFunction('resolveMarket') export const resolveMarket = cloudFunction('resolveMarket')

View File

@ -5,7 +5,6 @@ import {
setDoc, setDoc,
query, query,
collectionGroup, collectionGroup,
getDocs,
where, where,
orderBy, orderBy,
} from 'firebase/firestore' } from 'firebase/firestore'
@ -75,9 +74,7 @@ const recentCommentsQuery = query(
) )
export async function getRecentComments() { export async function getRecentComments() {
const snapshot = await getDocs(recentCommentsQuery) return getValues<Comment>(recentCommentsQuery)
const comments = snapshot.docs.map((doc) => doc.data() as Comment)
return comments
} }
export function listenForRecentComments( export function listenForRecentComments(

View File

@ -24,7 +24,6 @@ import { createRNG, shuffle } from '../../../common/util/random'
export type { Contract } export type { Contract }
export function contractPath(contract: Contract) { export function contractPath(contract: Contract) {
// For now, derive username from creatorName
return `/${contract.creatorUsername}/${contract.slug}` return `/${contract.creatorUsername}/${contract.slug}`
} }
@ -54,7 +53,7 @@ export function contractMetrics(contract: Contract) {
} }
const db = getFirestore(app) const db = getFirestore(app)
const contractCollection = collection(db, 'contracts') export const contractCollection = collection(db, 'contracts')
// Push contract to Firestore // Push contract to Firestore
export async function setContract(contract: Contract) { export async function setContract(contract: Contract) {

71
web/lib/firebase/folds.ts Normal file
View File

@ -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]
}

View File

@ -1,5 +1,5 @@
import { getFirestore } from '@firebase/firestore' 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 // TODO: Reenable this when we have a way to set the Firebase db in dev
// export const isProd = process.env.NODE_ENV === 'production' // export const isProd = process.env.NODE_ENV === 'production'
@ -26,5 +26,6 @@ const firebaseConfig = isProd
} }
// Initialize Firebase // Initialize Firebase
export const app = initializeApp(firebaseConfig) export const app = getApps().length ? getApp() : initializeApp(firebaseConfig)
export const db = getFirestore(app) export const db = getFirestore(app)

View File

@ -8,8 +8,8 @@ import {
DocumentReference, DocumentReference,
} from 'firebase/firestore' } from 'firebase/firestore'
export const getValue = async <T>(collectionName: string, docName: string) => { export const getValue = async <T>(doc: DocumentReference) => {
const snap = await getDoc(doc(db, collectionName, docName)) const snap = await getDoc(doc)
return snap.exists() ? (snap.data() as T) : null return snap.exists() ? (snap.data() as T) : null
} }

View File

@ -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())
}

View File

@ -23,7 +23,9 @@ import { Bet, listAllBets } from '../../lib/firebase/bets'
import { Comment, listAllComments } from '../../lib/firebase/comments' import { Comment, listAllComments } from '../../lib/firebase/comments'
import Custom404 from '../404' 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 { username, contractSlug } = props.params
const contract = (await getContractFromSlug(contractSlug)) || null const contract = (await getContractFromSlug(contractSlug)) || null
const contractId = contract?.id const contractId = contract?.id

View File

@ -2,8 +2,6 @@ import _ from 'lodash'
import { ContractFeed } from '../components/contract-feed' import { ContractFeed } from '../components/contract-feed'
import { Page } from '../components/page' import { Page } from '../components/page'
import { Title } from '../components/title' import { Title } from '../components/title'
import { useRecentComments } from '../hooks/use-comments'
import { useContracts } from '../hooks/use-contracts'
import { Contract } from '../lib/firebase/contracts' import { Contract } from '../lib/firebase/contracts'
import { Comment } from '../lib/firebase/comments' import { Comment } from '../lib/firebase/comments'
import { Col } from '../components/layout/col' import { Col } from '../components/layout/col'
@ -69,18 +67,13 @@ export function ActivityFeed(props: {
contractBets: Bet[][] contractBets: Bet[][]
contractComments: Comment[][] contractComments: Comment[][]
}) { }) {
const { contractBets, contractComments } = props const { contracts, contractBets, contractComments } = props
const contracts = useContracts() ?? props.contracts
const recentComments = useRecentComments()
const activeContracts = recentComments
? findActiveContracts(contracts, recentComments)
: props.contracts
return contracts.length > 0 ? ( return contracts.length > 0 ? (
<Col className="items-center"> <Col className="items-center">
<Col className="w-full max-w-3xl"> <Col className="w-full max-w-3xl">
<Col className="w-full bg-white self-center divide-gray-300 divide-y"> <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"> <div key={contract.id} className="py-6 px-2 sm:px-4">
<ContractFeed <ContractFeed
contract={contract} contract={contract}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

158
web/pages/folds.tsx Normal file
View File

@ -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>
)
}

View File

@ -13,6 +13,8 @@ import {
listAllComments, listAllComments,
} from '../lib/firebase/comments' } from '../lib/firebase/comments'
import { Bet, listAllBets } from '../lib/firebase/bets' 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 FeedCreate, { FeedPromo } from '../components/feed-create'
import { Spacer } from '../components/layout/spacer' import { Spacer } from '../components/layout/spacer'
import { Col } from '../components/layout/col' import { Col } from '../components/layout/col'
@ -51,12 +53,13 @@ const Home = (props: {
activeContractComments: Comment[][] activeContractComments: Comment[][]
hotContracts: Contract[] hotContracts: Contract[]
}) => { }) => {
const { const { activeContractBets, activeContractComments, hotContracts } = props
activeContracts,
activeContractBets, const contracts = useContracts() ?? props.activeContracts
activeContractComments, const recentComments = useRecentComments()
hotContracts, const activeContracts = recentComments
} = props ? findActiveContracts(contracts, recentComments)
: props.activeContracts
const user = useUser() const user = useUser()

View File

@ -1,10 +1,7 @@
import _ from 'lodash' import _ from 'lodash'
import Image from 'next/image'
import { Col } from '../components/layout/col' import { Col } from '../components/layout/col'
import { Row } from '../components/layout/row' import { Leaderboard } from '../components/leaderboard'
import { Page } from '../components/page' 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 { getTopCreators, getTopTraders, User } from '../lib/firebase/users'
import { formatMoney } from '../lib/util/format' import { formatMoney } from '../lib/util/format'
@ -57,56 +54,3 @@ export default function Leaderboards(props: {
</Page> </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>
)
}

View File

@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,