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
This commit is contained in:
parent
5be6a75e4b
commit
60f68b178d
|
@ -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'
|
||||
|
|
17
common/fold.ts
Normal file
17
common/fold.ts
Normal 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[]
|
||||
}
|
|
@ -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',
|
||||
|
|
21
common/util/parse.ts
Normal file
21
common/util/parse.ts
Normal 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
|
||||
}
|
|
@ -35,5 +35,9 @@ service cloud.firestore {
|
|||
allow read;
|
||||
}
|
||||
|
||||
match /folds/{foldId} {
|
||||
allow read;
|
||||
allow update: if request.auth.uid == resource.data.curatorId;
|
||||
}
|
||||
}
|
||||
}
|
81
functions/src/create-fold.ts
Normal file
81
functions/src/create-fold.ts
Normal 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)
|
||||
}
|
|
@ -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'
|
||||
|
|
49
functions/src/scripts/update-contract-tags.ts
Normal file
49
functions/src/scripts/update-contract-tags.ts
Normal 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())
|
|
@ -2,7 +2,6 @@
|
|||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"outDir": "lib",
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
|
|
|
@ -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 && (
|
||||
<>
|
||||
<div className="hidden sm:block">•</div>
|
||||
|
||||
<Row className="gap-2 flex-wrap">
|
||||
{tags.map((tag) => (
|
||||
<div key={tag} className="bg-gray-100 px-1">
|
||||
<Linkify text={tag} gray />
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
<TagsList tags={tags} />
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
58
web/components/leaderboard.tsx
Normal file
58
web/components/leaderboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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(
|
||||
|
|
15
web/components/tags-list.tsx
Normal file
15
web/components/tags-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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')
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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) {
|
||||
|
|
71
web/lib/firebase/folds.ts
Normal file
71
web/lib/firebase/folds.ts
Normal 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]
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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())
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
116
web/pages/fold/[foldSlug]/edit.tsx
Normal file
116
web/pages/fold/[foldSlug]/edit.tsx
Normal 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>
|
||||
)
|
||||
}
|
128
web/pages/fold/[foldSlug]/index.tsx
Normal file
128
web/pages/fold/[foldSlug]/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
64
web/pages/fold/[foldSlug]/leaderboards.tsx
Normal file
64
web/pages/fold/[foldSlug]/leaderboards.tsx
Normal 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
158
web/pages/folds.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
|
Loading…
Reference in New Issue
Block a user