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 |   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
									
								
							
							
						
						
									
										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 { 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
									
								
							
							
						
						
									
										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; |       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 './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' | ||||||
|  |  | ||||||
							
								
								
									
										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": { |   "compilerOptions": { | ||||||
|     "module": "commonjs", |     "module": "commonjs", | ||||||
|     "noImplicitReturns": true, |     "noImplicitReturns": true, | ||||||
|     "noUnusedLocals": true, |  | ||||||
|     "outDir": "lib", |     "outDir": "lib", | ||||||
|     "sourceMap": true, |     "sourceMap": true, | ||||||
|     "strict": true, |     "strict": true, | ||||||
|  |  | ||||||
|  | @ -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> | ||||||
|  |  | ||||||
|  | @ -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' | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										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> | ||||||
|       )} |       )} | ||||||
| 
 | 
 | ||||||
|  |       {/* <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( | ||||||
|  |  | ||||||
							
								
								
									
										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 { 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') | ||||||
|  |  | ||||||
|  | @ -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( | ||||||
|  |  | ||||||
|  | @ -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
									
								
							
							
						
						
									
										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 { 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) | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 { 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 | ||||||
|  |  | ||||||
|  | @ -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} | ||||||
|  |  | ||||||
							
								
								
									
										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, |   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() | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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> |  | ||||||
|   ) |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -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, | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue
	
	Block a user