Merge remote-tracking branch 'origin/main' into fix-large-groups
This commit is contained in:
commit
82772cb733
|
@ -116,12 +116,12 @@ const calculateProfitForPeriod = (
|
|||
return currentProfit
|
||||
}
|
||||
|
||||
const startingProfit = calculateTotalProfit(startingPortfolio)
|
||||
const startingProfit = calculatePortfolioProfit(startingPortfolio)
|
||||
|
||||
return currentProfit - startingProfit
|
||||
}
|
||||
|
||||
const calculateTotalProfit = (portfolio: PortfolioMetrics) => {
|
||||
export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
|
||||
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
|
||||
}
|
||||
|
||||
|
@ -129,7 +129,7 @@ export const calculateNewProfit = (
|
|||
portfolioHistory: PortfolioMetrics[],
|
||||
newPortfolio: PortfolioMetrics
|
||||
) => {
|
||||
const allTimeProfit = calculateTotalProfit(newPortfolio)
|
||||
const allTimeProfit = calculatePortfolioProfit(newPortfolio)
|
||||
const descendingPortfolio = sortBy(
|
||||
portfolioHistory,
|
||||
(p) => p.timestamp
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { JSONContent } from '@tiptap/core'
|
||||
|
||||
export type AnyCommentType = OnContract | OnGroup
|
||||
export type AnyCommentType = OnContract | OnGroup | OnPost
|
||||
|
||||
// Currently, comments are created after the bet, not atomically with the bet.
|
||||
// They're uniquely identified by the pair contractId/betId.
|
||||
|
@ -20,7 +20,7 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
|
|||
userAvatarUrl?: string
|
||||
} & T
|
||||
|
||||
type OnContract = {
|
||||
export type OnContract = {
|
||||
commentType: 'contract'
|
||||
contractId: string
|
||||
answerOutcome?: string
|
||||
|
@ -35,10 +35,16 @@ type OnContract = {
|
|||
betOutcome?: string
|
||||
}
|
||||
|
||||
type OnGroup = {
|
||||
export type OnGroup = {
|
||||
commentType: 'group'
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export type OnPost = {
|
||||
commentType: 'post'
|
||||
postId: string
|
||||
}
|
||||
|
||||
export type ContractComment = Comment<OnContract>
|
||||
export type GroupComment = Comment<OnGroup>
|
||||
export type PostComment = Comment<OnPost>
|
||||
|
|
|
@ -73,6 +73,7 @@ export const PROD_CONFIG: EnvConfig = {
|
|||
'manticmarkets@gmail.com', // Manifold
|
||||
'iansphilips@gmail.com', // Ian
|
||||
'd4vidchee@gmail.com', // D4vid
|
||||
'federicoruizcassarino@gmail.com', // Fede
|
||||
],
|
||||
visibility: 'PUBLIC',
|
||||
|
||||
|
|
|
@ -13,7 +13,6 @@ import { addObjects } from './util/object'
|
|||
export const getDpmCancelPayouts = (contract: DPMContract, bets: Bet[]) => {
|
||||
const { pool } = contract
|
||||
const poolTotal = sum(Object.values(pool))
|
||||
console.log('resolved N/A, pool M$', poolTotal)
|
||||
|
||||
const betSum = sumBy(bets, (b) => b.amount)
|
||||
|
||||
|
@ -58,17 +57,6 @@ export const getDpmStandardPayouts = (
|
|||
liquidityFee: 0,
|
||||
})
|
||||
|
||||
console.log(
|
||||
'resolved',
|
||||
outcome,
|
||||
'pool',
|
||||
poolTotal,
|
||||
'profits',
|
||||
profits,
|
||||
'creator fee',
|
||||
creatorFee
|
||||
)
|
||||
|
||||
return {
|
||||
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
|
||||
creatorPayout: creatorFee,
|
||||
|
@ -110,17 +98,6 @@ export const getNumericDpmPayouts = (
|
|||
liquidityFee: 0,
|
||||
})
|
||||
|
||||
console.log(
|
||||
'resolved numeric bucket: ',
|
||||
outcome,
|
||||
'pool',
|
||||
poolTotal,
|
||||
'profits',
|
||||
profits,
|
||||
'creator fee',
|
||||
creatorFee
|
||||
)
|
||||
|
||||
return {
|
||||
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
|
||||
creatorPayout: creatorFee,
|
||||
|
@ -163,17 +140,6 @@ export const getDpmMktPayouts = (
|
|||
liquidityFee: 0,
|
||||
})
|
||||
|
||||
console.log(
|
||||
'resolved MKT',
|
||||
p,
|
||||
'pool',
|
||||
pool,
|
||||
'profits',
|
||||
profits,
|
||||
'creator fee',
|
||||
creatorFee
|
||||
)
|
||||
|
||||
return {
|
||||
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
|
||||
creatorPayout: creatorFee,
|
||||
|
@ -216,16 +182,6 @@ export const getPayoutsMultiOutcome = (
|
|||
liquidityFee: 0,
|
||||
})
|
||||
|
||||
console.log(
|
||||
'resolved',
|
||||
resolutions,
|
||||
'pool',
|
||||
poolTotal,
|
||||
'profits',
|
||||
profits,
|
||||
'creator fee',
|
||||
creatorFee
|
||||
)
|
||||
return {
|
||||
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
|
||||
creatorPayout: creatorFee,
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { sum } from 'lodash'
|
||||
|
||||
import { Bet } from './bet'
|
||||
import { getProbability } from './calculate'
|
||||
|
@ -43,18 +42,6 @@ export const getStandardFixedPayouts = (
|
|||
|
||||
const { collectedFees } = contract
|
||||
const creatorPayout = collectedFees.creatorFee
|
||||
|
||||
console.log(
|
||||
'resolved',
|
||||
outcome,
|
||||
'pool',
|
||||
contract.pool[outcome],
|
||||
'payouts',
|
||||
sum(payouts),
|
||||
'creator fee',
|
||||
creatorPayout
|
||||
)
|
||||
|
||||
const liquidityPayouts = getLiquidityPoolPayouts(
|
||||
contract,
|
||||
outcome,
|
||||
|
@ -98,18 +85,6 @@ export const getMktFixedPayouts = (
|
|||
|
||||
const { collectedFees } = contract
|
||||
const creatorPayout = collectedFees.creatorFee
|
||||
|
||||
console.log(
|
||||
'resolved PROB',
|
||||
p,
|
||||
'pool',
|
||||
p * contract.pool.YES + (1 - p) * contract.pool.NO,
|
||||
'payouts',
|
||||
sum(payouts),
|
||||
'creator fee',
|
||||
creatorPayout
|
||||
)
|
||||
|
||||
const liquidityPayouts = getLiquidityPoolProbPayouts(contract, p, liquidities)
|
||||
|
||||
return { payouts, creatorPayout, liquidityPayouts, collectedFees }
|
||||
|
|
|
@ -13,7 +13,10 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => {
|
|||
const yesShares = sumBy(yesBets, (b) => b.shares)
|
||||
const noShares = sumBy(noBets, (b) => b.shares)
|
||||
const shares = Math.max(Math.min(yesShares, noShares), 0)
|
||||
const soldFrac = shares > 0 ? Math.min(yesShares, noShares) / shares : 0
|
||||
const soldFrac =
|
||||
shares > 0
|
||||
? Math.min(yesShares, noShares) / Math.max(yesShares, noShares)
|
||||
: 0
|
||||
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
|
||||
const loanPayment = loanAmount * soldFrac
|
||||
const netAmount = shares - loanPayment
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { groupBy, sumBy, mapValues, partition } from 'lodash'
|
||||
import { groupBy, sumBy, mapValues } from 'lodash'
|
||||
|
||||
import { Bet } from './bet'
|
||||
import { getContractBetMetrics } from './calculate'
|
||||
import { Contract } from './contract'
|
||||
import { getPayouts } from './payouts'
|
||||
|
||||
export function scoreCreators(contracts: Contract[]) {
|
||||
const creatorScore = mapValues(
|
||||
|
@ -30,46 +30,8 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) {
|
|||
}
|
||||
|
||||
export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
|
||||
const { resolution } = contract
|
||||
const resolutionProb =
|
||||
contract.outcomeType == 'BINARY'
|
||||
? contract.resolutionProbability
|
||||
: undefined
|
||||
|
||||
const [closedBets, openBets] = partition(
|
||||
bets,
|
||||
(bet) => bet.isSold || bet.sale
|
||||
)
|
||||
const { payouts: resolvePayouts } = getPayouts(
|
||||
resolution as string,
|
||||
contract,
|
||||
openBets,
|
||||
[],
|
||||
{},
|
||||
resolutionProb
|
||||
)
|
||||
|
||||
const salePayouts = closedBets.map((bet) => {
|
||||
const { userId, sale } = bet
|
||||
return { userId, payout: sale ? sale.amount : 0 }
|
||||
})
|
||||
|
||||
const investments = bets
|
||||
.filter((bet) => !bet.sale)
|
||||
.map((bet) => {
|
||||
const { userId, amount, loanAmount } = bet
|
||||
const payout = -amount - (loanAmount ?? 0)
|
||||
return { userId, payout }
|
||||
})
|
||||
|
||||
const netPayouts = [...resolvePayouts, ...salePayouts, ...investments]
|
||||
|
||||
const userScore = mapValues(
|
||||
groupBy(netPayouts, (payout) => payout.userId),
|
||||
(payouts) => sumBy(payouts, ({ payout }) => payout)
|
||||
)
|
||||
|
||||
return userScore
|
||||
const betsByUser = groupBy(bets, bet => bet.userId)
|
||||
return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit)
|
||||
}
|
||||
|
||||
export function addUserScores(
|
||||
|
|
|
@ -34,7 +34,7 @@ export type User = {
|
|||
followerCountCached: number
|
||||
|
||||
followedCategories?: string[]
|
||||
homeSections?: { visible: string[]; hidden: string[] }
|
||||
homeSections?: string[]
|
||||
|
||||
referredByUserId?: string
|
||||
referredByContractId?: string
|
||||
|
|
|
@ -60,23 +60,27 @@ Parameters:
|
|||
|
||||
Requires no authorization.
|
||||
|
||||
### `GET /v0/groups/[slug]`
|
||||
### `GET /v0/group/[slug]`
|
||||
|
||||
Gets a group by its slug.
|
||||
|
||||
Requires no authorization.
|
||||
Note: group is singular in the URL.
|
||||
|
||||
### `GET /v0/group/by-id/[id]`
|
||||
|
||||
Gets a group by its unique ID.
|
||||
|
||||
Requires no authorization.
|
||||
Note: group is singular in the URL.
|
||||
|
||||
### `GET /v0/group/by-id/[id]/markets`
|
||||
|
||||
Gets a group's markets by its unique ID.
|
||||
|
||||
Requires no authorization.
|
||||
Note: group is singular in the URL.
|
||||
|
||||
|
||||
### `GET /v0/markets`
|
||||
|
||||
|
|
|
@ -12,7 +12,9 @@ service cloud.firestore {
|
|||
'taowell@gmail.com',
|
||||
'abc.sinclair@gmail.com',
|
||||
'manticmarkets@gmail.com',
|
||||
'iansphilips@gmail.com'
|
||||
'iansphilips@gmail.com',
|
||||
'd4vidchee@gmail.com',
|
||||
'federicoruizcassarino@gmail.com'
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -203,6 +205,10 @@ service cloud.firestore {
|
|||
.affectedKeys()
|
||||
.hasOnly(['name', 'content']);
|
||||
allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId;
|
||||
match /comments/{commentId} {
|
||||
allow read;
|
||||
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) ;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,6 +37,45 @@ export const changeUser = async (
|
|||
avatarUrl?: string
|
||||
}
|
||||
) => {
|
||||
// Update contracts, comments, and answers outside of a transaction to avoid contention.
|
||||
// Using bulkWriter to supports >500 writes at a time
|
||||
const contractsRef = firestore
|
||||
.collection('contracts')
|
||||
.where('creatorId', '==', user.id)
|
||||
|
||||
const contracts = await contractsRef.get()
|
||||
|
||||
const contractUpdate: Partial<Contract> = removeUndefinedProps({
|
||||
creatorName: update.name,
|
||||
creatorUsername: update.username,
|
||||
creatorAvatarUrl: update.avatarUrl,
|
||||
})
|
||||
|
||||
const commentSnap = await firestore
|
||||
.collectionGroup('comments')
|
||||
.where('userUsername', '==', user.username)
|
||||
.get()
|
||||
|
||||
const commentUpdate: Partial<Comment> = removeUndefinedProps({
|
||||
userName: update.name,
|
||||
userUsername: update.username,
|
||||
userAvatarUrl: update.avatarUrl,
|
||||
})
|
||||
|
||||
const answerSnap = await firestore
|
||||
.collectionGroup('answers')
|
||||
.where('username', '==', user.username)
|
||||
.get()
|
||||
const answerUpdate: Partial<Answer> = removeUndefinedProps(update)
|
||||
|
||||
const bulkWriter = firestore.bulkWriter()
|
||||
commentSnap.docs.forEach((d) => bulkWriter.update(d.ref, commentUpdate))
|
||||
answerSnap.docs.forEach((d) => bulkWriter.update(d.ref, answerUpdate))
|
||||
contracts.docs.forEach((d) => bulkWriter.update(d.ref, contractUpdate))
|
||||
await bulkWriter.flush()
|
||||
console.log('Done writing!')
|
||||
|
||||
// Update the username inside a transaction
|
||||
return await firestore.runTransaction(async (transaction) => {
|
||||
if (update.username) {
|
||||
update.username = cleanUsername(update.username)
|
||||
|
@ -58,42 +97,7 @@ export const changeUser = async (
|
|||
|
||||
const userRef = firestore.collection('users').doc(user.id)
|
||||
const userUpdate: Partial<User> = removeUndefinedProps(update)
|
||||
|
||||
const contractsRef = firestore
|
||||
.collection('contracts')
|
||||
.where('creatorId', '==', user.id)
|
||||
|
||||
const contracts = await transaction.get(contractsRef)
|
||||
|
||||
const contractUpdate: Partial<Contract> = removeUndefinedProps({
|
||||
creatorName: update.name,
|
||||
creatorUsername: update.username,
|
||||
creatorAvatarUrl: update.avatarUrl,
|
||||
})
|
||||
|
||||
const commentSnap = await transaction.get(
|
||||
firestore
|
||||
.collectionGroup('comments')
|
||||
.where('userUsername', '==', user.username)
|
||||
)
|
||||
|
||||
const commentUpdate: Partial<Comment> = removeUndefinedProps({
|
||||
userName: update.name,
|
||||
userUsername: update.username,
|
||||
userAvatarUrl: update.avatarUrl,
|
||||
})
|
||||
|
||||
const answerSnap = await transaction.get(
|
||||
firestore
|
||||
.collectionGroup('answers')
|
||||
.where('username', '==', user.username)
|
||||
)
|
||||
const answerUpdate: Partial<Answer> = removeUndefinedProps(update)
|
||||
|
||||
transaction.update(userRef, userUpdate)
|
||||
commentSnap.docs.forEach((d) => transaction.update(d.ref, commentUpdate))
|
||||
answerSnap.docs.forEach((d) => transaction.update(d.ref, answerUpdate))
|
||||
contracts.docs.forEach((d) => transaction.update(d.ref, contractUpdate))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -186,8 +186,9 @@
|
|||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
">Did you know you create your own prediction market on <a class="link-build-content"
|
||||
style="color: #55575d" target="_blank" href="https://manifold.markets">Manifold</a> for
|
||||
">Did you know you can create your own prediction market on <a
|
||||
class="link-build-content" style="color: #55575d" target="_blank"
|
||||
href="https://manifold.markets">Manifold</a> on
|
||||
any question you care about?</span>
|
||||
</p>
|
||||
|
||||
|
|
|
@ -135,7 +135,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
!isFinite(newP) ||
|
||||
Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY)
|
||||
) {
|
||||
throw new APIError(400, 'Bet too large for current liquidity pool.')
|
||||
throw new APIError(400, 'Trade too large for current liquidity pool.')
|
||||
}
|
||||
|
||||
const betDoc = contractDoc.collection('bets').doc()
|
||||
|
|
|
@ -122,6 +122,18 @@ export function BuyAmountInput(props: {
|
|||
}
|
||||
}
|
||||
|
||||
const parseRaw = (x: number) => {
|
||||
if (x <= 100) return x
|
||||
if (x <= 130) return 100 + (x - 100) * 5
|
||||
return 250 + (x - 130) * 10
|
||||
}
|
||||
|
||||
const getRaw = (x: number) => {
|
||||
if (x <= 100) return x
|
||||
if (x <= 250) return 100 + (x - 100) / 5
|
||||
return 130 + (x - 250) / 10
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AmountInput
|
||||
|
@ -138,10 +150,10 @@ export function BuyAmountInput(props: {
|
|||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={amount ?? 0}
|
||||
onChange={(e) => onAmountChange(parseInt(e.target.value))}
|
||||
className="range range-lg z-40 mb-2 xl:hidden"
|
||||
max="205"
|
||||
value={getRaw(amount ?? 0)}
|
||||
onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))}
|
||||
className="range range-lg only-thumb z-40 mb-2 xl:hidden"
|
||||
step="5"
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import clsx from 'clsx'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { XIcon } from '@heroicons/react/solid'
|
||||
|
||||
import { Answer } from 'common/answer'
|
||||
|
@ -25,8 +25,7 @@ import {
|
|||
import { Bet } from 'common/bet'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { BetSignUpPrompt } from '../sign-up-prompt'
|
||||
import { isIOS } from 'web/lib/util/device'
|
||||
import { AlertBox } from '../alert-box'
|
||||
import { WarningConfirmationButton } from '../warning-confirmation-button'
|
||||
|
||||
export function AnswerBetPanel(props: {
|
||||
answer: Answer
|
||||
|
@ -44,12 +43,6 @@ export function AnswerBetPanel(props: {
|
|||
const [error, setError] = useState<string | undefined>()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const inputRef = useRef<HTMLElement>(null)
|
||||
useEffect(() => {
|
||||
if (isIOS()) window.scrollTo(0, window.scrollY + 200)
|
||||
inputRef.current && inputRef.current.focus()
|
||||
}, [])
|
||||
|
||||
async function submitBet() {
|
||||
if (!user || !betAmount) return
|
||||
|
||||
|
@ -116,11 +109,20 @@ export function AnswerBetPanel(props: {
|
|||
|
||||
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
||||
|
||||
const warning =
|
||||
(betAmount ?? 0) >= 100 && bankrollFraction >= 0.5 && bankrollFraction <= 1
|
||||
? `You might not want to spend ${formatPercent(
|
||||
bankrollFraction
|
||||
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
|
||||
user?.balance ?? 0
|
||||
)}`
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}>
|
||||
<Row className="items-center justify-between self-stretch">
|
||||
<div className="text-xl">
|
||||
Bet on {isModal ? `"${answer.text}"` : 'this answer'}
|
||||
Buy answer: {isModal ? `"${answer.text}"` : 'this answer'}
|
||||
</div>
|
||||
|
||||
{!isModal && (
|
||||
|
@ -144,25 +146,9 @@ export function AnswerBetPanel(props: {
|
|||
error={error}
|
||||
setError={setError}
|
||||
disabled={isSubmitting}
|
||||
inputRef={inputRef}
|
||||
showSliderOnMobile
|
||||
/>
|
||||
|
||||
{(betAmount ?? 0) > 10 &&
|
||||
bankrollFraction >= 0.5 &&
|
||||
bankrollFraction <= 1 ? (
|
||||
<AlertBox
|
||||
title="Whoa, there!"
|
||||
text={`You might not want to spend ${formatPercent(
|
||||
bankrollFraction
|
||||
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
|
||||
user?.balance ?? 0
|
||||
)}`}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
<Col className="mt-3 w-full gap-3">
|
||||
<Row className="items-center justify-between text-sm">
|
||||
<div className="text-gray-500">Probability</div>
|
||||
|
@ -198,16 +184,17 @@ export function AnswerBetPanel(props: {
|
|||
<Spacer h={6} />
|
||||
|
||||
{user ? (
|
||||
<button
|
||||
className={clsx(
|
||||
<WarningConfirmationButton
|
||||
warning={warning}
|
||||
onSubmit={submitBet}
|
||||
isSubmitting={isSubmitting}
|
||||
disabled={!!betDisabled}
|
||||
openModalButtonClass={clsx(
|
||||
'btn self-stretch',
|
||||
betDisabled ? 'btn-disabled' : 'btn-primary',
|
||||
isSubmitting ? 'loading' : ''
|
||||
)}
|
||||
onClick={betDisabled ? undefined : submitBet}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit trade'}
|
||||
</button>
|
||||
/>
|
||||
) : (
|
||||
<BetSignUpPrompt />
|
||||
)}
|
||||
|
|
|
@ -194,7 +194,7 @@ function OpenAnswer(props: {
|
|||
|
||||
return (
|
||||
<Col className={'border-base-200 bg-base-200 flex-1 rounded-md px-2'}>
|
||||
<Modal open={open} setOpen={setOpen}>
|
||||
<Modal open={open} setOpen={setOpen} position="center">
|
||||
<AnswerBetPanel
|
||||
answer={answer}
|
||||
contract={contract}
|
||||
|
|
|
@ -12,47 +12,33 @@ import { User } from 'common/user'
|
|||
import { Group } from 'common/group'
|
||||
|
||||
export function ArrangeHome(props: {
|
||||
user: User | null
|
||||
homeSections: { visible: string[]; hidden: string[] }
|
||||
setHomeSections: (homeSections: {
|
||||
visible: string[]
|
||||
hidden: string[]
|
||||
}) => void
|
||||
user: User | null | undefined
|
||||
homeSections: string[]
|
||||
setHomeSections: (sections: string[]) => void
|
||||
}) {
|
||||
const { user, homeSections, setHomeSections } = props
|
||||
|
||||
const groups = useMemberGroups(user?.id) ?? []
|
||||
const { itemsById, visibleItems, hiddenItems } = getHomeItems(
|
||||
groups,
|
||||
homeSections
|
||||
)
|
||||
const { itemsById, sections } = getHomeItems(groups, homeSections)
|
||||
|
||||
return (
|
||||
<DragDropContext
|
||||
onDragEnd={(e) => {
|
||||
console.log('drag end', e)
|
||||
const { destination, source, draggableId } = e
|
||||
if (!destination) return
|
||||
|
||||
const item = itemsById[draggableId]
|
||||
|
||||
const newHomeSections = {
|
||||
visible: visibleItems.map((item) => item.id),
|
||||
hidden: hiddenItems.map((item) => item.id),
|
||||
}
|
||||
const newHomeSections = sections.map((section) => section.id)
|
||||
|
||||
const sourceSection = source.droppableId as 'visible' | 'hidden'
|
||||
newHomeSections[sourceSection].splice(source.index, 1)
|
||||
|
||||
const destSection = destination.droppableId as 'visible' | 'hidden'
|
||||
newHomeSections[destSection].splice(destination.index, 0, item.id)
|
||||
newHomeSections.splice(source.index, 1)
|
||||
newHomeSections.splice(destination.index, 0, item.id)
|
||||
|
||||
setHomeSections(newHomeSections)
|
||||
}}
|
||||
>
|
||||
<Row className="relative max-w-lg gap-4">
|
||||
<DraggableList items={visibleItems} title="Visible" />
|
||||
<DraggableList items={hiddenItems} title="Hidden" />
|
||||
<Row className="relative max-w-md gap-4">
|
||||
<DraggableList items={sections} title="Sections" />
|
||||
</Row>
|
||||
</DragDropContext>
|
||||
)
|
||||
|
@ -65,16 +51,13 @@ function DraggableList(props: {
|
|||
const { title, items } = props
|
||||
return (
|
||||
<Droppable droppableId={title.toLowerCase()}>
|
||||
{(provided, snapshot) => (
|
||||
{(provided) => (
|
||||
<Col
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
className={clsx(
|
||||
'width-[220px] flex-1 items-start rounded bg-gray-50 p-2',
|
||||
snapshot.isDraggingOver && 'bg-gray-100'
|
||||
)}
|
||||
className={clsx('flex-1 items-stretch gap-1 rounded bg-gray-100 p-4')}
|
||||
>
|
||||
<Subtitle text={title} className="mx-2 !my-2" />
|
||||
<Subtitle text={title} className="mx-2 !mt-0 !mb-4" />
|
||||
{items.map((item, index) => (
|
||||
<Draggable key={item.id} draggableId={item.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
|
@ -83,16 +66,13 @@ function DraggableList(props: {
|
|||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={provided.draggableProps.style}
|
||||
className={clsx(
|
||||
'flex flex-row items-center gap-4 rounded bg-gray-50 p-2',
|
||||
snapshot.isDragging && 'z-[9000] bg-gray-300'
|
||||
)}
|
||||
>
|
||||
<MenuIcon
|
||||
className="h-5 w-5 flex-shrink-0 text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
{item.label}
|
||||
<SectionItem
|
||||
className={clsx(
|
||||
snapshot.isDragging && 'z-[9000] bg-gray-200'
|
||||
)}
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
|
@ -104,15 +84,33 @@ function DraggableList(props: {
|
|||
)
|
||||
}
|
||||
|
||||
export const getHomeItems = (
|
||||
groups: Group[],
|
||||
homeSections: { visible: string[]; hidden: string[] }
|
||||
) => {
|
||||
const SectionItem = (props: {
|
||||
item: { id: string; label: string }
|
||||
className?: string
|
||||
}) => {
|
||||
const { item, className } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'flex flex-row items-center gap-4 rounded bg-gray-50 p-2'
|
||||
)}
|
||||
>
|
||||
<MenuIcon
|
||||
className="h-5 w-5 flex-shrink-0 text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
{item.label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const getHomeItems = (groups: Group[], sections: string[]) => {
|
||||
const items = [
|
||||
{ label: 'Daily movers', id: 'daily-movers' },
|
||||
{ label: 'Trending', id: 'score' },
|
||||
{ label: 'Newest', id: 'newest' },
|
||||
{ label: 'Close date', id: 'close-date' },
|
||||
{ label: 'Your bets', id: 'your-bets' },
|
||||
{ label: 'New for you', id: 'newest' },
|
||||
...groups.map((g) => ({
|
||||
label: g.name,
|
||||
id: g.id,
|
||||
|
@ -120,23 +118,13 @@ export const getHomeItems = (
|
|||
]
|
||||
const itemsById = keyBy(items, 'id')
|
||||
|
||||
const { visible, hidden } = homeSections
|
||||
const sectionItems = filterDefined(sections.map((id) => itemsById[id]))
|
||||
|
||||
const [visibleItems, hiddenItems] = [
|
||||
filterDefined(visible.map((id) => itemsById[id])),
|
||||
filterDefined(hidden.map((id) => itemsById[id])),
|
||||
]
|
||||
|
||||
// Add unmentioned items to the visible list.
|
||||
visibleItems.push(
|
||||
...items.filter(
|
||||
(item) => !visibleItems.includes(item) && !hiddenItems.includes(item)
|
||||
)
|
||||
)
|
||||
// Add unmentioned items to the end.
|
||||
sectionItems.push(...items.filter((item) => !sectionItems.includes(item)))
|
||||
|
||||
return {
|
||||
visibleItems,
|
||||
hiddenItems,
|
||||
sections: sectionItems,
|
||||
itemsById,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import Router from 'next/router'
|
|||
import clsx from 'clsx'
|
||||
import { MouseEvent, useEffect, useState } from 'react'
|
||||
import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid'
|
||||
import Image from 'next/future/image'
|
||||
|
||||
export function Avatar(props: {
|
||||
username?: string
|
||||
|
@ -14,6 +15,7 @@ export function Avatar(props: {
|
|||
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
|
||||
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
|
||||
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
|
||||
const sizeInPx = s * 4
|
||||
|
||||
const onClick =
|
||||
noLink && username
|
||||
|
@ -26,7 +28,9 @@ export function Avatar(props: {
|
|||
// there can be no avatar URL or username in the feed, we show a "submit comment"
|
||||
// item with a fake grey user circle guy even if you aren't signed in
|
||||
return avatarUrl ? (
|
||||
<img
|
||||
<Image
|
||||
width={sizeInPx}
|
||||
height={sizeInPx}
|
||||
className={clsx(
|
||||
'flex-shrink-0 rounded-full bg-white object-cover',
|
||||
`w-${s} h-${s}`,
|
||||
|
|
|
@ -35,10 +35,13 @@ export default function BetButton(props: {
|
|||
{user ? (
|
||||
<Button
|
||||
size="lg"
|
||||
className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)}
|
||||
className={clsx(
|
||||
'my-auto inline-flex min-w-[75px] whitespace-nowrap',
|
||||
btnClassName
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Bet
|
||||
Predict
|
||||
</Button>
|
||||
) : (
|
||||
<BetSignUpPrompt />
|
||||
|
@ -57,7 +60,7 @@ export default function BetButton(props: {
|
|||
)}
|
||||
</Col>
|
||||
|
||||
<Modal open={open} setOpen={setOpen}>
|
||||
<Modal open={open} setOpen={setOpen} position="center">
|
||||
<SimpleBetPanel
|
||||
className={betPanelClassName}
|
||||
contract={contract}
|
||||
|
|
|
@ -40,7 +40,8 @@ import { LimitBets } from './limit-bets'
|
|||
import { PillButton } from './buttons/pill-button'
|
||||
import { YesNoSelector } from './yes-no-selector'
|
||||
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
|
||||
import { AlertBox } from './alert-box'
|
||||
import { isAndroid, isIOS } from 'web/lib/util/device'
|
||||
import { WarningConfirmationButton } from './warning-confirmation-button'
|
||||
|
||||
export function BetPanel(props: {
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
|
@ -184,17 +185,13 @@ function BuyPanel(props: {
|
|||
|
||||
const [inputRef, focusAmountInput] = useFocus()
|
||||
|
||||
// useEffect(() => {
|
||||
// if (selected) {
|
||||
// if (isIOS()) window.scrollTo(0, window.scrollY + 200)
|
||||
// focusAmountInput()
|
||||
// }
|
||||
// }, [selected, focusAmountInput])
|
||||
|
||||
function onBetChoice(choice: 'YES' | 'NO') {
|
||||
setOutcome(choice)
|
||||
setWasSubmitted(false)
|
||||
focusAmountInput()
|
||||
|
||||
if (!isIOS() && !isAndroid()) {
|
||||
focusAmountInput()
|
||||
}
|
||||
}
|
||||
|
||||
function onBetChange(newAmount: number | undefined) {
|
||||
|
@ -274,25 +271,15 @@ function BuyPanel(props: {
|
|||
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
||||
|
||||
const warning =
|
||||
(betAmount ?? 0) > 10 &&
|
||||
bankrollFraction >= 0.5 &&
|
||||
bankrollFraction <= 1 ? (
|
||||
<AlertBox
|
||||
title="Whoa, there!"
|
||||
text={`You might not want to spend ${formatPercent(
|
||||
(betAmount ?? 0) >= 100 && bankrollFraction >= 0.5 && bankrollFraction <= 1
|
||||
? `You might not want to spend ${formatPercent(
|
||||
bankrollFraction
|
||||
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
|
||||
)} of your balance on a single trade. \n\nCurrent balance: ${formatMoney(
|
||||
user?.balance ?? 0
|
||||
)}`}
|
||||
/>
|
||||
) : (betAmount ?? 0) > 10 && probChange >= 0.3 && bankrollFraction <= 1 ? (
|
||||
<AlertBox
|
||||
title="Whoa, there!"
|
||||
text={`Are you sure you want to move the market by ${displayedDifference}?`}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
)}`
|
||||
: (betAmount ?? 0) > 10 && probChange >= 0.3 && bankrollFraction <= 1
|
||||
? `Are you sure you want to move the market by ${displayedDifference}?`
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Col className={hidden ? 'hidden' : ''}>
|
||||
|
@ -325,8 +312,6 @@ function BuyPanel(props: {
|
|||
showSliderOnMobile
|
||||
/>
|
||||
|
||||
{warning}
|
||||
|
||||
<Col className="mt-3 w-full gap-3">
|
||||
<Row className="items-center justify-between text-sm">
|
||||
<div className="text-gray-500">
|
||||
|
@ -367,23 +352,23 @@ function BuyPanel(props: {
|
|||
<Spacer h={8} />
|
||||
|
||||
{user && (
|
||||
<button
|
||||
className={clsx(
|
||||
<WarningConfirmationButton
|
||||
warning={warning}
|
||||
onSubmit={submitBet}
|
||||
isSubmitting={isSubmitting}
|
||||
disabled={!!betDisabled}
|
||||
openModalButtonClass={clsx(
|
||||
'btn mb-2 flex-1',
|
||||
betDisabled
|
||||
? 'btn-disabled'
|
||||
: outcome === 'YES'
|
||||
? 'btn-primary'
|
||||
: 'border-none bg-red-400 hover:bg-red-500',
|
||||
isSubmitting ? 'loading' : ''
|
||||
: 'border-none bg-red-400 hover:bg-red-500'
|
||||
)}
|
||||
onClick={betDisabled ? undefined : submitBet}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit bet'}
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
|
||||
{wasSubmitted && <div className="mt-4">Bet submitted!</div>}
|
||||
{wasSubmitted && <div className="mt-4">Trade submitted!</div>}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
@ -569,7 +554,7 @@ function LimitOrderPanel(props: {
|
|||
<Row className="mt-1 items-center gap-4">
|
||||
<Col className="gap-2">
|
||||
<div className="relative ml-1 text-sm text-gray-500">
|
||||
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
|
||||
Buy {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
|
||||
</div>
|
||||
<ProbabilityOrNumericInput
|
||||
contract={contract}
|
||||
|
@ -580,7 +565,7 @@ function LimitOrderPanel(props: {
|
|||
</Col>
|
||||
<Col className="gap-2">
|
||||
<div className="ml-1 text-sm text-gray-500">
|
||||
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
|
||||
Buy {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
|
||||
</div>
|
||||
<ProbabilityOrNumericInput
|
||||
contract={contract}
|
||||
|
@ -750,15 +735,16 @@ function QuickOrLimitBet(props: {
|
|||
|
||||
return (
|
||||
<Row className="align-center mb-4 justify-between">
|
||||
<div className="text-4xl">Bet</div>
|
||||
<div className="mr-2 -ml-2 shrink-0 text-3xl sm:-ml-0">Predict</div>
|
||||
{!hideToggle && (
|
||||
<Row className="mt-1 items-center gap-2">
|
||||
<Row className="mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2">
|
||||
<PillButton
|
||||
selected={!isLimitOrder}
|
||||
onSelect={() => {
|
||||
setIsLimitOrder(false)
|
||||
track('select quick order')
|
||||
}}
|
||||
xs={true}
|
||||
>
|
||||
Quick
|
||||
</PillButton>
|
||||
|
@ -768,6 +754,7 @@ function QuickOrLimitBet(props: {
|
|||
setIsLimitOrder(true)
|
||||
track('select limit order')
|
||||
}}
|
||||
xs={true}
|
||||
>
|
||||
Limit
|
||||
</PillButton>
|
||||
|
|
|
@ -5,19 +5,19 @@ export function PillButton(props: {
|
|||
selected: boolean
|
||||
onSelect: () => void
|
||||
color?: string
|
||||
big?: boolean
|
||||
xs?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
const { children, selected, onSelect, color, big } = props
|
||||
const { children, selected, onSelect, color, xs } = props
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
'cursor-pointer select-none whitespace-nowrap rounded-full',
|
||||
'cursor-pointer select-none whitespace-nowrap rounded-full px-3 py-1.5 text-sm',
|
||||
xs ? 'text-xs' : '',
|
||||
selected
|
||||
? ['text-white', color ?? 'bg-greyscale-6']
|
||||
: 'bg-greyscale-2 hover:bg-greyscale-3',
|
||||
big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm'
|
||||
: 'bg-greyscale-2 hover:bg-greyscale-3'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
|
|
|
@ -38,7 +38,7 @@ export function Carousel(props: {
|
|||
return (
|
||||
<div className={clsx('relative', className)}>
|
||||
<Row
|
||||
className="scrollbar-hide w-full gap-4 overflow-x-auto scroll-smooth"
|
||||
className="scrollbar-hide w-full snap-x gap-4 overflow-x-auto scroll-smooth"
|
||||
ref={ref}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
|
|
|
@ -27,7 +27,8 @@ export function AcceptChallengeButton(props: {
|
|||
setErrorText('')
|
||||
}, [open])
|
||||
|
||||
if (!user) return <BetSignUpPrompt label="Accept this bet" className="mt-4" />
|
||||
if (!user)
|
||||
return <BetSignUpPrompt label="Sign up to accept" className="mt-4" />
|
||||
|
||||
const iAcceptChallenge = () => {
|
||||
setLoading(true)
|
||||
|
|
169
web/components/comment-input.tsx
Normal file
169
web/components/comment-input.tsx
Normal file
|
@ -0,0 +1,169 @@
|
|||
import { PaperAirplaneIcon } from '@heroicons/react/solid'
|
||||
import { Editor } from '@tiptap/react'
|
||||
import clsx from 'clsx'
|
||||
import { User } from 'common/user'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments'
|
||||
import { Avatar } from './avatar'
|
||||
import { TextEditor, useTextEditor } from './editor'
|
||||
import { Row } from './layout/row'
|
||||
import { LoadingIndicator } from './loading-indicator'
|
||||
|
||||
export function CommentInput(props: {
|
||||
replyToUser?: { id: string; username: string }
|
||||
// Reply to a free response answer
|
||||
parentAnswerOutcome?: string
|
||||
// Reply to another comment
|
||||
parentCommentId?: string
|
||||
onSubmitComment?: (editor: Editor, betId: string | undefined) => void
|
||||
className?: string
|
||||
presetId?: string
|
||||
}) {
|
||||
const {
|
||||
parentAnswerOutcome,
|
||||
parentCommentId,
|
||||
replyToUser,
|
||||
onSubmitComment,
|
||||
presetId,
|
||||
} = props
|
||||
const user = useUser()
|
||||
|
||||
const { editor, upload } = useTextEditor({
|
||||
simple: true,
|
||||
max: MAX_COMMENT_LENGTH,
|
||||
placeholder:
|
||||
!!parentCommentId || !!parentAnswerOutcome
|
||||
? 'Write a reply...'
|
||||
: 'Write a comment...',
|
||||
})
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function submitComment(betId: string | undefined) {
|
||||
if (!editor || editor.isEmpty || isSubmitting) return
|
||||
setIsSubmitting(true)
|
||||
onSubmitComment?.(editor, betId)
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
if (user?.isBannedFromPosting) return <></>
|
||||
|
||||
return (
|
||||
<Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
|
||||
<Avatar
|
||||
avatarUrl={user?.avatarUrl}
|
||||
username={user?.username}
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 pl-0.5 text-sm">
|
||||
<CommentInputTextArea
|
||||
editor={editor}
|
||||
upload={upload}
|
||||
replyToUser={replyToUser}
|
||||
user={user}
|
||||
submitComment={submitComment}
|
||||
isSubmitting={isSubmitting}
|
||||
presetId={presetId}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
export function CommentInputTextArea(props: {
|
||||
user: User | undefined | null
|
||||
replyToUser?: { id: string; username: string }
|
||||
editor: Editor | null
|
||||
upload: Parameters<typeof TextEditor>[0]['upload']
|
||||
submitComment: (id?: string) => void
|
||||
isSubmitting: boolean
|
||||
presetId?: string
|
||||
}) {
|
||||
const {
|
||||
user,
|
||||
editor,
|
||||
upload,
|
||||
submitComment,
|
||||
presetId,
|
||||
isSubmitting,
|
||||
replyToUser,
|
||||
} = props
|
||||
useEffect(() => {
|
||||
editor?.setEditable(!isSubmitting)
|
||||
}, [isSubmitting, editor])
|
||||
|
||||
const submit = () => {
|
||||
submitComment(presetId)
|
||||
editor?.commands?.clearContent()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
// Submit on ctrl+enter or mod+enter key
|
||||
editor.setOptions({
|
||||
editorProps: {
|
||||
handleKeyDown: (view, event) => {
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
!event.shiftKey &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
// mention list is closed
|
||||
!(view.state as any).mention$.active
|
||||
) {
|
||||
submit()
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
})
|
||||
// insert at mention and focus
|
||||
if (replyToUser) {
|
||||
editor
|
||||
.chain()
|
||||
.setContent({
|
||||
type: 'mention',
|
||||
attrs: { label: replyToUser.username, id: replyToUser.id },
|
||||
})
|
||||
.insertContent(' ')
|
||||
.focus()
|
||||
.run()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextEditor editor={editor} upload={upload}>
|
||||
{user && !isSubmitting && (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
|
||||
disabled={!editor || editor.isEmpty}
|
||||
onClick={submit}
|
||||
>
|
||||
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isSubmitting && (
|
||||
<LoadingIndicator spinnerClassName={'border-gray-500'} />
|
||||
)}
|
||||
</TextEditor>
|
||||
<Row>
|
||||
{!user && (
|
||||
<button
|
||||
className={'btn btn-outline btn-sm mt-2 normal-case'}
|
||||
onClick={() => submitComment(presetId)}
|
||||
>
|
||||
Add my comment
|
||||
</button>
|
||||
)}
|
||||
</Row>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -47,13 +47,13 @@ export function ConfirmationButton(props: {
|
|||
{children}
|
||||
<Row className="gap-4">
|
||||
<div
|
||||
className={clsx('btn normal-case', cancelBtn?.className)}
|
||||
className={clsx('btn', cancelBtn?.className)}
|
||||
onClick={() => updateOpen(false)}
|
||||
>
|
||||
{cancelBtn?.label ?? 'Cancel'}
|
||||
</div>
|
||||
<div
|
||||
className={clsx('btn normal-case', submitBtn?.className)}
|
||||
className={clsx('btn', submitBtn?.className)}
|
||||
onClick={
|
||||
onSubmitWithSuccess
|
||||
? () =>
|
||||
|
@ -69,7 +69,7 @@ export function ConfirmationButton(props: {
|
|||
</Col>
|
||||
</Modal>
|
||||
<div
|
||||
className={clsx('btn normal-case', openModalBtn.className)}
|
||||
className={clsx('btn', openModalBtn.className)}
|
||||
onClick={() => updateOpen(true)}
|
||||
>
|
||||
{openModalBtn.icon}
|
||||
|
|
|
@ -69,6 +69,7 @@ type AdditionalFilter = {
|
|||
excludeContractIds?: string[]
|
||||
groupSlug?: string
|
||||
yourBets?: boolean
|
||||
followed?: boolean
|
||||
}
|
||||
|
||||
export function ContractSearch(props: {
|
||||
|
@ -88,6 +89,7 @@ export function ContractSearch(props: {
|
|||
useQueryUrlParam?: boolean
|
||||
isWholePage?: boolean
|
||||
noControls?: boolean
|
||||
maxResults?: number
|
||||
renderContracts?: (
|
||||
contracts: Contract[] | undefined,
|
||||
loadMore: () => void
|
||||
|
@ -107,6 +109,7 @@ export function ContractSearch(props: {
|
|||
useQueryUrlParam,
|
||||
isWholePage,
|
||||
noControls,
|
||||
maxResults,
|
||||
renderContracts,
|
||||
} = props
|
||||
|
||||
|
@ -189,7 +192,8 @@ export function ContractSearch(props: {
|
|||
const contracts = state.pages
|
||||
.flat()
|
||||
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
|
||||
const renderedContracts = state.pages.length === 0 ? undefined : contracts
|
||||
const renderedContracts =
|
||||
state.pages.length === 0 ? undefined : contracts.slice(0, maxResults)
|
||||
|
||||
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||
return <ContractSearchFirestore additionalFilter={additionalFilter} />
|
||||
|
@ -292,6 +296,19 @@ function ContractSearchControls(props: {
|
|||
const pillGroups: { name: string; slug: string }[] =
|
||||
memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS
|
||||
|
||||
const personalFilters = user
|
||||
? [
|
||||
// Show contracts in groups that the user is a member of.
|
||||
memberGroupSlugs
|
||||
.map((slug) => `groupLinks.slug:${slug}`)
|
||||
// Or, show contracts created by users the user follows
|
||||
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? []),
|
||||
|
||||
// Subtract contracts you bet on, to show new ones.
|
||||
`uniqueBettorIds:-${user.id}`,
|
||||
]
|
||||
: []
|
||||
|
||||
const additionalFilters = [
|
||||
additionalFilter?.creatorId
|
||||
? `creatorId:${additionalFilter.creatorId}`
|
||||
|
@ -304,6 +321,7 @@ function ContractSearchControls(props: {
|
|||
? // Show contracts bet on by the user
|
||||
`uniqueBettorIds:${user.id}`
|
||||
: '',
|
||||
...(additionalFilter?.followed ? personalFilters : []),
|
||||
]
|
||||
const facetFilters = query
|
||||
? additionalFilters
|
||||
|
@ -320,17 +338,7 @@ function ContractSearchControls(props: {
|
|||
state.pillFilter !== 'your-bets'
|
||||
? `groupLinks.slug:${state.pillFilter}`
|
||||
: '',
|
||||
state.pillFilter === 'personal'
|
||||
? // Show contracts in groups that the user is a member of
|
||||
memberGroupSlugs
|
||||
.map((slug) => `groupLinks.slug:${slug}`)
|
||||
// Show contracts created by users the user follows
|
||||
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
|
||||
: '',
|
||||
// Subtract contracts you bet on from For you.
|
||||
state.pillFilter === 'personal' && user
|
||||
? `uniqueBettorIds:-${user.id}`
|
||||
: '',
|
||||
...(state.pillFilter === 'personal' ? personalFilters : []),
|
||||
state.pillFilter === 'your-bets' && user
|
||||
? // Show contracts bet on by the user
|
||||
`uniqueBettorIds:${user.id}`
|
||||
|
@ -441,7 +449,7 @@ function ContractSearchControls(props: {
|
|||
selected={state.pillFilter === 'your-bets'}
|
||||
onSelect={selectPill('your-bets')}
|
||||
>
|
||||
Your bets
|
||||
Your trades
|
||||
</PillButton>
|
||||
)}
|
||||
|
||||
|
|
|
@ -294,7 +294,7 @@ export function ExtraMobileContractDetails(props: {
|
|||
<Tooltip
|
||||
text={`${formatMoney(
|
||||
volume
|
||||
)} bet - ${uniqueBettors} unique bettors`}
|
||||
)} bet - ${uniqueBettors} unique traders`}
|
||||
>
|
||||
{volumeTranslation}
|
||||
</Tooltip>
|
||||
|
|
|
@ -135,7 +135,7 @@ export function ContractInfoDialog(props: {
|
|||
</tr> */}
|
||||
|
||||
<tr>
|
||||
<td>Bettors</td>
|
||||
<td>Traders</td>
|
||||
<td>{bettorsCount}</td>
|
||||
</tr>
|
||||
|
||||
|
|
|
@ -49,7 +49,7 @@ export function ContractLeaderboard(props: {
|
|||
|
||||
return users && users.length > 0 ? (
|
||||
<Leaderboard
|
||||
title="🏅 Top bettors"
|
||||
title="🏅 Top traders"
|
||||
users={users || []}
|
||||
columns={[
|
||||
{
|
||||
|
|
|
@ -13,7 +13,6 @@ import { Tabs } from '../layout/tabs'
|
|||
import { Col } from '../layout/col'
|
||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
import { useBets } from 'web/hooks/use-bets'
|
||||
import { useComments } from 'web/hooks/use-comments'
|
||||
import { useLiquidity } from 'web/hooks/use-liquidity'
|
||||
import { BetSignUpPrompt } from '../sign-up-prompt'
|
||||
|
@ -27,24 +26,23 @@ export function ContractTabs(props: {
|
|||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { contract, user, tips } = props
|
||||
const { contract, user, bets, tips } = props
|
||||
const { outcomeType } = contract
|
||||
|
||||
const bets = useBets(contract.id) ?? props.bets
|
||||
const lps = useLiquidity(contract.id) ?? []
|
||||
const lps = useLiquidity(contract.id)
|
||||
|
||||
const userBets =
|
||||
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
|
||||
const visibleBets = bets.filter(
|
||||
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
|
||||
)
|
||||
const visibleLps = lps.filter((l) => !l.isAnte && l.amount > 0)
|
||||
const visibleLps = lps?.filter((l) => !l.isAnte && l.amount > 0)
|
||||
|
||||
// Load comments here, so the badge count will be correct
|
||||
const updatedComments = useComments(contract.id)
|
||||
const comments = updatedComments ?? props.comments
|
||||
|
||||
const betActivity = (
|
||||
const betActivity = visibleLps && (
|
||||
<ContractBetsActivity
|
||||
contract={contract}
|
||||
bets={visibleBets}
|
||||
|
@ -116,13 +114,13 @@ export function ContractTabs(props: {
|
|||
badge: `${comments.length}`,
|
||||
},
|
||||
{
|
||||
title: 'Bets',
|
||||
title: 'Trades',
|
||||
content: betActivity,
|
||||
badge: `${visibleBets.length}`,
|
||||
},
|
||||
...(!user || !userBets?.length
|
||||
? []
|
||||
: [{ title: 'Your bets', content: yourTrades }]),
|
||||
: [{ title: 'Your trades', content: yourTrades }]),
|
||||
]}
|
||||
/>
|
||||
{!user ? (
|
||||
|
|
|
@ -114,6 +114,7 @@ export function CreatorContractsList(props: {
|
|||
additionalFilter={{
|
||||
creatorId: creator.id,
|
||||
}}
|
||||
persistPrefix={`user-${creator.id}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import { Col } from 'web/components/layout/col'
|
|||
import { withTracking } from 'web/lib/service/analytics'
|
||||
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
|
||||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||
import ChallengeIcon from 'web/lib/icons/challenge-icon'
|
||||
|
||||
export function ExtraContractActionsRow(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
|
@ -61,9 +62,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
|
|||
)}
|
||||
>
|
||||
<Col className="items-center sm:flex-row">
|
||||
<span className="h-[24px] w-5 sm:mr-2" aria-hidden="true">
|
||||
⚔️
|
||||
</span>
|
||||
<ChallengeIcon className="mx-auto h-[24px] w-5 text-gray-500 sm:mr-2" />
|
||||
<span>Challenge</span>
|
||||
</Col>
|
||||
<CreateChallengeModal
|
||||
|
|
|
@ -39,14 +39,14 @@ export function LikeMarketButton(props: {
|
|||
return (
|
||||
<Button
|
||||
size={'lg'}
|
||||
className={'mb-1'}
|
||||
className={'max-w-xs self-center'}
|
||||
color={'gray-white'}
|
||||
onClick={onLike}
|
||||
>
|
||||
<Col className={'items-center sm:flex-row sm:gap-x-2'}>
|
||||
<Col className={'items-center sm:flex-row'}>
|
||||
<HeartIcon
|
||||
className={clsx(
|
||||
'h-6 w-6',
|
||||
'h-[24px] w-5 sm:mr-2',
|
||||
user &&
|
||||
(userLikedContractIds?.includes(contract.id) ||
|
||||
(!likes && contract.likedByUserIds?.includes(user.id)))
|
||||
|
|
|
@ -2,53 +2,70 @@ import clsx from 'clsx'
|
|||
import { contractPath } from 'web/lib/firebase/contracts'
|
||||
import { CPMMContract } from 'common/contract'
|
||||
import { formatPercent } from 'common/util/format'
|
||||
import { useProbChanges } from 'web/hooks/use-prob-changes'
|
||||
import { SiteLink } from '../site-link'
|
||||
import { Col } from '../layout/col'
|
||||
import { Row } from '../layout/row'
|
||||
import { LoadingIndicator } from '../loading-indicator'
|
||||
|
||||
export function ProbChangeTable(props: { userId: string | undefined }) {
|
||||
const { userId } = props
|
||||
export function ProbChangeTable(props: {
|
||||
changes:
|
||||
| { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] }
|
||||
| undefined
|
||||
}) {
|
||||
const { changes } = props
|
||||
|
||||
const changes = useProbChanges(userId ?? '')
|
||||
|
||||
if (!changes) {
|
||||
return null
|
||||
}
|
||||
if (!changes) return <LoadingIndicator />
|
||||
|
||||
const { positiveChanges, negativeChanges } = changes
|
||||
|
||||
const count = 3
|
||||
const threshold = 0.075
|
||||
const countOverThreshold = Math.max(
|
||||
positiveChanges.findIndex((c) => c.probChanges.day < threshold) + 1,
|
||||
negativeChanges.findIndex((c) => c.probChanges.day > -threshold) + 1
|
||||
)
|
||||
const maxRows = Math.min(positiveChanges.length, negativeChanges.length)
|
||||
const rows = Math.min(3, Math.min(maxRows, countOverThreshold))
|
||||
|
||||
const filteredPositiveChanges = positiveChanges.slice(0, rows)
|
||||
const filteredNegativeChanges = negativeChanges.slice(0, rows)
|
||||
|
||||
if (rows === 0) return <div className="px-4 text-gray-500">None</div>
|
||||
|
||||
return (
|
||||
<Row className="w-full flex-wrap divide-x-2 rounded bg-white shadow-md">
|
||||
<Col className="min-w-[300px] flex-1 divide-y">
|
||||
{positiveChanges.slice(0, count).map((contract) => (
|
||||
<Row className="hover:bg-gray-100">
|
||||
<ProbChange className="p-4 text-right" contract={contract} />
|
||||
<Col className="mb-4 w-full divide-x-2 divide-y rounded-lg bg-white shadow-md md:flex-row md:divide-y-0">
|
||||
<Col className="flex-1 divide-y">
|
||||
{filteredPositiveChanges.map((contract) => (
|
||||
<Row className="items-center hover:bg-gray-100">
|
||||
<ProbChange
|
||||
className="p-4 text-right text-xl"
|
||||
contract={contract}
|
||||
/>
|
||||
<SiteLink
|
||||
className="p-4 font-semibold text-indigo-700"
|
||||
className="p-4 pl-2 font-semibold text-indigo-700"
|
||||
href={contractPath(contract)}
|
||||
>
|
||||
{contract.question}
|
||||
<span className="line-clamp-2">{contract.question}</span>
|
||||
</SiteLink>
|
||||
</Row>
|
||||
))}
|
||||
</Col>
|
||||
<Col className="justify-content-stretch min-w-[300px] flex-1 divide-y">
|
||||
{negativeChanges.slice(0, count).map((contract) => (
|
||||
<Row className="hover:bg-gray-100">
|
||||
<ProbChange className="p-4 text-right" contract={contract} />
|
||||
<Col className="flex-1 divide-y">
|
||||
{filteredNegativeChanges.map((contract) => (
|
||||
<Row className="items-center hover:bg-gray-100">
|
||||
<ProbChange
|
||||
className="p-4 text-right text-xl"
|
||||
contract={contract}
|
||||
/>
|
||||
<SiteLink
|
||||
className="p-4 font-semibold text-indigo-700"
|
||||
className="p-4 pl-2 font-semibold text-indigo-700"
|
||||
href={contractPath(contract)}
|
||||
>
|
||||
{contract.question}
|
||||
<span className="line-clamp-2">{contract.question}</span>
|
||||
</SiteLink>
|
||||
</Row>
|
||||
))}
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -63,9 +80,9 @@ export function ProbChange(props: {
|
|||
|
||||
const color =
|
||||
change > 0
|
||||
? 'text-green-600'
|
||||
? 'text-green-500'
|
||||
: change < 0
|
||||
? 'text-red-600'
|
||||
? 'text-red-500'
|
||||
: 'text-gray-600'
|
||||
|
||||
const str =
|
||||
|
|
|
@ -7,7 +7,6 @@ import { Col } from 'web/components/layout/col'
|
|||
|
||||
export function DoubleCarousel(props: {
|
||||
contracts: Contract[]
|
||||
seeMoreUrl?: string
|
||||
showTime?: ShowTime
|
||||
loadMore?: () => void
|
||||
}) {
|
||||
|
@ -19,7 +18,7 @@ export function DoubleCarousel(props: {
|
|||
? range(0, Math.floor(contracts.length / 2)).map((col) => {
|
||||
const i = col * 2
|
||||
return (
|
||||
<Col key={contracts[i].id}>
|
||||
<Col className="snap-start scroll-m-4" key={contracts[i].id}>
|
||||
<ContractCard
|
||||
contract={contracts[i]}
|
||||
className="mb-2 w-96 shrink-0"
|
||||
|
|
|
@ -18,7 +18,6 @@ import { uploadImage } from 'web/lib/firebase/storage'
|
|||
import { useMutation } from 'react-query'
|
||||
import { FileUploadButton } from './file-upload-button'
|
||||
import { linkClass } from './site-link'
|
||||
import { useUsers } from 'web/hooks/use-users'
|
||||
import { mentionSuggestion } from './editor/mention-suggestion'
|
||||
import { DisplayMention } from './editor/mention'
|
||||
import Iframe from 'common/util/tiptap-iframe'
|
||||
|
@ -68,8 +67,6 @@ export function useTextEditor(props: {
|
|||
}) {
|
||||
const { placeholder, max, defaultValue = '', disabled, simple } = props
|
||||
|
||||
const users = useUsers()
|
||||
|
||||
const editorClass = clsx(
|
||||
proseClass,
|
||||
!simple && 'min-h-[6em]',
|
||||
|
@ -78,32 +75,27 @@ export function useTextEditor(props: {
|
|||
'[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-indigo-300' // selected img, emebeds
|
||||
)
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
editorProps: { attributes: { class: editorClass } },
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: simple ? false : { levels: [1, 2, 3] },
|
||||
horizontalRule: simple ? false : {},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
emptyEditorClass:
|
||||
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text',
|
||||
}),
|
||||
CharacterCount.configure({ limit: max }),
|
||||
simple ? DisplayImage : Image,
|
||||
DisplayLink,
|
||||
DisplayMention.configure({
|
||||
suggestion: mentionSuggestion(users),
|
||||
}),
|
||||
Iframe,
|
||||
TiptapTweet,
|
||||
],
|
||||
content: defaultValue,
|
||||
},
|
||||
[!users.length] // passed as useEffect dependency. (re-render editor when users load, to update mention menu)
|
||||
)
|
||||
const editor = useEditor({
|
||||
editorProps: { attributes: { class: editorClass } },
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: simple ? false : { levels: [1, 2, 3] },
|
||||
horizontalRule: simple ? false : {},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
emptyEditorClass:
|
||||
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text',
|
||||
}),
|
||||
CharacterCount.configure({ limit: max }),
|
||||
simple ? DisplayImage : Image,
|
||||
DisplayLink,
|
||||
DisplayMention.configure({ suggestion: mentionSuggestion }),
|
||||
Iframe,
|
||||
TiptapTweet,
|
||||
],
|
||||
content: defaultValue,
|
||||
})
|
||||
|
||||
const upload = useUploadMutation(editor)
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import type { MentionOptions } from '@tiptap/extension-mention'
|
||||
import { ReactRenderer } from '@tiptap/react'
|
||||
import { User } from 'common/user'
|
||||
import { searchInAny } from 'common/util/parse'
|
||||
import { orderBy } from 'lodash'
|
||||
import tippy from 'tippy.js'
|
||||
import { getCachedUsers } from 'web/hooks/use-users'
|
||||
import { MentionList } from './mention-list'
|
||||
|
||||
type Suggestion = MentionOptions['suggestion']
|
||||
|
@ -12,10 +12,12 @@ const beginsWith = (text: string, query: string) =>
|
|||
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
|
||||
|
||||
// copied from https://tiptap.dev/api/nodes/mention#usage
|
||||
export const mentionSuggestion = (users: User[]): Suggestion => ({
|
||||
items: ({ query }) =>
|
||||
export const mentionSuggestion: Suggestion = {
|
||||
items: async ({ query }) =>
|
||||
orderBy(
|
||||
users.filter((u) => searchInAny(query, u.username, u.name)),
|
||||
(await getCachedUsers()).filter((u) =>
|
||||
searchInAny(query, u.username, u.name)
|
||||
),
|
||||
[
|
||||
(u) => [u.name, u.username].some((s) => beginsWith(s, query)),
|
||||
'followerCountCached',
|
||||
|
@ -38,7 +40,7 @@ export const mentionSuggestion = (users: User[]): Suggestion => ({
|
|||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect as any,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
content: component?.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
|
@ -46,27 +48,27 @@ export const mentionSuggestion = (users: User[]): Suggestion => ({
|
|||
})
|
||||
},
|
||||
onUpdate(props) {
|
||||
component.updateProps(props)
|
||||
component?.updateProps(props)
|
||||
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
popup?.[0].setProps({
|
||||
getReferenceClientRect: props.clientRect as any,
|
||||
})
|
||||
},
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide()
|
||||
popup?.[0].hide()
|
||||
return true
|
||||
}
|
||||
return (component.ref as any)?.onKeyDown(props)
|
||||
return (component?.ref as any)?.onKeyDown(props)
|
||||
},
|
||||
onExit() {
|
||||
popup[0].destroy()
|
||||
component.destroy()
|
||||
popup?.[0].destroy()
|
||||
component?.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import { getOutcomeProbability } from 'common/calculate'
|
|||
import { FeedBet } from './feed-bets'
|
||||
import { FeedLiquidity } from './feed-liquidity'
|
||||
import { FeedAnswerCommentGroup } from './feed-answer-comment-group'
|
||||
import { FeedCommentThread, CommentInput } from './feed-comments'
|
||||
import { FeedCommentThread, ContractCommentInput } from './feed-comments'
|
||||
import { User } from 'common/user'
|
||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
|
@ -72,7 +72,7 @@ export function ContractCommentsActivity(props: {
|
|||
|
||||
return (
|
||||
<>
|
||||
<CommentInput
|
||||
<ContractCommentInput
|
||||
className="mb-5"
|
||||
contract={contract}
|
||||
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
|
||||
|
|
|
@ -9,7 +9,7 @@ import { Avatar } from 'web/components/avatar'
|
|||
import { Linkify } from 'web/components/linkify'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
CommentInput,
|
||||
ContractCommentInput,
|
||||
FeedComment,
|
||||
getMostRecentCommentableBet,
|
||||
} from 'web/components/feed/feed-comments'
|
||||
|
@ -177,7 +177,7 @@ export function FeedAnswerCommentGroup(props: {
|
|||
className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CommentInput
|
||||
<ContractCommentInput
|
||||
contract={contract}
|
||||
betsByCurrentUser={betsByCurrentUser}
|
||||
commentsByCurrentUser={commentsByCurrentUser}
|
||||
|
|
|
@ -13,22 +13,17 @@ import { Avatar } from 'web/components/avatar'
|
|||
import { OutcomeLabel } from 'web/components/outcome-label'
|
||||
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
import {
|
||||
createCommentOnContract,
|
||||
MAX_COMMENT_LENGTH,
|
||||
} from 'web/lib/firebase/comments'
|
||||
import { createCommentOnContract } from 'web/lib/firebase/comments'
|
||||
import { BetStatusText } from 'web/components/feed/feed-bets'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { getProbability } from 'common/calculate'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
import { PaperAirplaneIcon } from '@heroicons/react/outline'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { Tipper } from '../tipper'
|
||||
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { Content, TextEditor, useTextEditor } from '../editor'
|
||||
import { Content } from '../editor'
|
||||
import { Editor } from '@tiptap/react'
|
||||
import { UserLink } from 'web/components/user-link'
|
||||
import { CommentInput } from '../comment-input'
|
||||
|
||||
export function FeedCommentThread(props: {
|
||||
user: User | null | undefined
|
||||
|
@ -90,14 +85,16 @@ export function FeedCommentThread(props: {
|
|||
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CommentInput
|
||||
<ContractCommentInput
|
||||
contract={contract}
|
||||
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
|
||||
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
|
||||
parentCommentId={parentComment.id}
|
||||
replyToUser={replyTo}
|
||||
parentAnswerOutcome={parentComment.answerOutcome}
|
||||
onSubmitComment={() => setShowReply(false)}
|
||||
onSubmitComment={() => {
|
||||
setShowReply(false)
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
|
@ -267,67 +264,76 @@ function CommentStatus(props: {
|
|||
)
|
||||
}
|
||||
|
||||
//TODO: move commentinput and comment input text area into their own files
|
||||
export function CommentInput(props: {
|
||||
export function ContractCommentInput(props: {
|
||||
contract: Contract
|
||||
betsByCurrentUser: Bet[]
|
||||
commentsByCurrentUser: ContractComment[]
|
||||
className?: string
|
||||
parentAnswerOutcome?: string | undefined
|
||||
replyToUser?: { id: string; username: string }
|
||||
// Reply to a free response answer
|
||||
parentAnswerOutcome?: string
|
||||
// Reply to another comment
|
||||
parentCommentId?: string
|
||||
onSubmitComment?: () => void
|
||||
}) {
|
||||
const {
|
||||
contract,
|
||||
betsByCurrentUser,
|
||||
commentsByCurrentUser,
|
||||
className,
|
||||
parentAnswerOutcome,
|
||||
parentCommentId,
|
||||
replyToUser,
|
||||
onSubmitComment,
|
||||
} = props
|
||||
const user = useUser()
|
||||
const { editor, upload } = useTextEditor({
|
||||
simple: true,
|
||||
max: MAX_COMMENT_LENGTH,
|
||||
placeholder:
|
||||
!!parentCommentId || !!parentAnswerOutcome
|
||||
? 'Write a reply...'
|
||||
: 'Write a comment...',
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const mostRecentCommentableBet = getMostRecentCommentableBet(
|
||||
betsByCurrentUser,
|
||||
commentsByCurrentUser,
|
||||
user,
|
||||
parentAnswerOutcome
|
||||
)
|
||||
const { id } = mostRecentCommentableBet || { id: undefined }
|
||||
|
||||
async function submitComment(betId: string | undefined) {
|
||||
async function onSubmitComment(editor: Editor, betId: string | undefined) {
|
||||
if (!user) {
|
||||
track('sign in to comment')
|
||||
return await firebaseLogin()
|
||||
}
|
||||
if (!editor || editor.isEmpty || isSubmitting) return
|
||||
setIsSubmitting(true)
|
||||
await createCommentOnContract(
|
||||
contract.id,
|
||||
props.contract.id,
|
||||
editor.getJSON(),
|
||||
user,
|
||||
betId,
|
||||
parentAnswerOutcome,
|
||||
parentCommentId
|
||||
props.parentAnswerOutcome,
|
||||
props.parentCommentId
|
||||
)
|
||||
onSubmitComment?.()
|
||||
setIsSubmitting(false)
|
||||
props.onSubmitComment?.()
|
||||
}
|
||||
|
||||
const mostRecentCommentableBet = getMostRecentCommentableBet(
|
||||
props.betsByCurrentUser,
|
||||
props.commentsByCurrentUser,
|
||||
user,
|
||||
props.parentAnswerOutcome
|
||||
)
|
||||
|
||||
const { id } = mostRecentCommentableBet || { id: undefined }
|
||||
|
||||
return (
|
||||
<Col>
|
||||
<CommentBetArea
|
||||
betsByCurrentUser={props.betsByCurrentUser}
|
||||
contract={props.contract}
|
||||
commentsByCurrentUser={props.commentsByCurrentUser}
|
||||
parentAnswerOutcome={props.parentAnswerOutcome}
|
||||
user={useUser()}
|
||||
className={props.className}
|
||||
mostRecentCommentableBet={mostRecentCommentableBet}
|
||||
/>
|
||||
<CommentInput
|
||||
replyToUser={props.replyToUser}
|
||||
parentAnswerOutcome={props.parentAnswerOutcome}
|
||||
parentCommentId={props.parentCommentId}
|
||||
onSubmitComment={onSubmitComment}
|
||||
className={props.className}
|
||||
presetId={id}
|
||||
/>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function CommentBetArea(props: {
|
||||
betsByCurrentUser: Bet[]
|
||||
contract: Contract
|
||||
commentsByCurrentUser: ContractComment[]
|
||||
parentAnswerOutcome?: string
|
||||
user?: User | null
|
||||
className?: string
|
||||
mostRecentCommentableBet?: Bet
|
||||
}) {
|
||||
const { betsByCurrentUser, contract, user, mostRecentCommentableBet } = props
|
||||
|
||||
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
|
||||
contract,
|
||||
Date.now(),
|
||||
|
@ -336,158 +342,36 @@ export function CommentInput(props: {
|
|||
|
||||
const isNumeric = contract.outcomeType === 'NUMERIC'
|
||||
|
||||
if (user?.isBannedFromPosting) return <></>
|
||||
|
||||
return (
|
||||
<Row className={clsx(className, 'mb-2 gap-1 sm:gap-2')}>
|
||||
<Avatar
|
||||
avatarUrl={user?.avatarUrl}
|
||||
username={user?.username}
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 pl-0.5 text-sm">
|
||||
<div className="mb-1 text-gray-500">
|
||||
{mostRecentCommentableBet && (
|
||||
<BetStatusText
|
||||
<Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
|
||||
<div className="mb-1 text-gray-500">
|
||||
{mostRecentCommentableBet && (
|
||||
<BetStatusText
|
||||
contract={contract}
|
||||
bet={mostRecentCommentableBet}
|
||||
isSelf={true}
|
||||
hideOutcome={isNumeric || contract.outcomeType === 'FREE_RESPONSE'}
|
||||
/>
|
||||
)}
|
||||
{!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && (
|
||||
<>
|
||||
{"You're"}
|
||||
<CommentStatus
|
||||
outcome={outcome}
|
||||
contract={contract}
|
||||
bet={mostRecentCommentableBet}
|
||||
isSelf={true}
|
||||
hideOutcome={
|
||||
isNumeric || contract.outcomeType === 'FREE_RESPONSE'
|
||||
prob={
|
||||
contract.outcomeType === 'BINARY'
|
||||
? getProbability(contract)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && (
|
||||
<>
|
||||
{"You're"}
|
||||
<CommentStatus
|
||||
outcome={outcome}
|
||||
contract={contract}
|
||||
prob={
|
||||
contract.outcomeType === 'BINARY'
|
||||
? getProbability(contract)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<CommentInputTextArea
|
||||
editor={editor}
|
||||
upload={upload}
|
||||
replyToUser={replyToUser}
|
||||
user={user}
|
||||
submitComment={submitComment}
|
||||
isSubmitting={isSubmitting}
|
||||
presetId={id}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
export function CommentInputTextArea(props: {
|
||||
user: User | undefined | null
|
||||
replyToUser?: { id: string; username: string }
|
||||
editor: Editor | null
|
||||
upload: Parameters<typeof TextEditor>[0]['upload']
|
||||
submitComment: (id?: string) => void
|
||||
isSubmitting: boolean
|
||||
submitOnEnter?: boolean
|
||||
presetId?: string
|
||||
}) {
|
||||
const {
|
||||
user,
|
||||
editor,
|
||||
upload,
|
||||
submitComment,
|
||||
presetId,
|
||||
isSubmitting,
|
||||
submitOnEnter,
|
||||
replyToUser,
|
||||
} = props
|
||||
const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch)
|
||||
|
||||
useEffect(() => {
|
||||
editor?.setEditable(!isSubmitting)
|
||||
}, [isSubmitting, editor])
|
||||
|
||||
const submit = () => {
|
||||
submitComment(presetId)
|
||||
editor?.commands?.clearContent()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
// submit on Enter key
|
||||
editor.setOptions({
|
||||
editorProps: {
|
||||
handleKeyDown: (view, event) => {
|
||||
if (
|
||||
submitOnEnter &&
|
||||
event.key === 'Enter' &&
|
||||
!event.shiftKey &&
|
||||
(!isMobile || event.ctrlKey || event.metaKey) &&
|
||||
// mention list is closed
|
||||
!(view.state as any).mention$.active
|
||||
) {
|
||||
submit()
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
})
|
||||
// insert at mention and focus
|
||||
if (replyToUser) {
|
||||
editor
|
||||
.chain()
|
||||
.setContent({
|
||||
type: 'mention',
|
||||
attrs: { label: replyToUser.username, id: replyToUser.id },
|
||||
})
|
||||
.insertContent(' ')
|
||||
.focus()
|
||||
.run()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextEditor editor={editor} upload={upload}>
|
||||
{user && !isSubmitting && (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
|
||||
disabled={!editor || editor.isEmpty}
|
||||
onClick={submit}
|
||||
>
|
||||
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isSubmitting && (
|
||||
<LoadingIndicator spinnerClassName={'border-gray-500'} />
|
||||
)}
|
||||
</TextEditor>
|
||||
<Row>
|
||||
{!user && (
|
||||
<button
|
||||
className={'btn btn-outline btn-sm mt-2 normal-case'}
|
||||
onClick={() => submitComment(presetId)}
|
||||
>
|
||||
Add my comment
|
||||
</button>
|
||||
)}
|
||||
</Row>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function getBettorsLargestPositionBeforeTime(
|
||||
contract: Contract,
|
||||
createdTime: number,
|
||||
|
|
|
@ -22,7 +22,7 @@ export function GroupAboutPost(props: {
|
|||
const post = usePost(group.aboutPostId) ?? props.post
|
||||
|
||||
return (
|
||||
<div className="rounded-md bg-white p-4">
|
||||
<div className="rounded-md bg-white p-4 ">
|
||||
{isEditable ? (
|
||||
<RichEditGroupAboutPost group={group} post={post} />
|
||||
) : (
|
||||
|
|
|
@ -27,23 +27,18 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
|
|||
<div className="m-4 max-w-[550px] self-center">
|
||||
<h1 className="text-3xl sm:text-6xl xl:text-6xl">
|
||||
<div className="font-semibold sm:mb-2">
|
||||
Predict{' '}
|
||||
A{' '}
|
||||
<span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent">
|
||||
anything!
|
||||
</span>
|
||||
market
|
||||
</span>{' '}
|
||||
for every question
|
||||
</div>
|
||||
</h1>
|
||||
<Spacer h={6} />
|
||||
<div className="mb-4 px-2 ">
|
||||
Create a play-money prediction market on any topic you care about
|
||||
and bet with your friends on what will happen!
|
||||
Create a play-money prediction market on any topic you care about.
|
||||
Trade with your friends to forecast the future.
|
||||
<br />
|
||||
{/* <br />
|
||||
Sign up and get {formatMoney(1000)} - worth $10 to your{' '}
|
||||
<SiteLink className="font-semibold" href="/charity">
|
||||
favorite charity.
|
||||
</SiteLink>
|
||||
<br /> */}
|
||||
</div>
|
||||
</div>
|
||||
<Spacer h={6} />
|
||||
|
|
|
@ -8,9 +8,10 @@ export function Modal(props: {
|
|||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
position?: 'center' | 'top' | 'bottom'
|
||||
className?: string
|
||||
}) {
|
||||
const { children, open, setOpen, size = 'md', className } = props
|
||||
const { children, position, open, setOpen, size = 'md', className } = props
|
||||
|
||||
const sizeClass = {
|
||||
sm: 'max-w-sm',
|
||||
|
@ -19,6 +20,12 @@ export function Modal(props: {
|
|||
xl: 'max-w-5xl',
|
||||
}[size]
|
||||
|
||||
const positionClass = {
|
||||
center: 'items-center',
|
||||
top: 'items-start',
|
||||
bottom: 'items-end',
|
||||
}[position ?? 'bottom']
|
||||
|
||||
return (
|
||||
<Transition.Root show={open} as={Fragment}>
|
||||
<Dialog
|
||||
|
@ -26,7 +33,12 @@ export function Modal(props: {
|
|||
className="fixed inset-0 z-50 overflow-y-auto"
|
||||
onClose={setOpen}
|
||||
>
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:p-0">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex min-h-screen justify-center px-4 pt-4 pb-20 text-center sm:p-0',
|
||||
positionClass
|
||||
)}
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
|
|
|
@ -64,7 +64,7 @@ export function BottomNavBar() {
|
|||
item={{
|
||||
name: formatMoney(user.balance),
|
||||
trackingEventName: 'profile',
|
||||
href: `/${user.username}?tab=bets`,
|
||||
href: `/${user.username}?tab=trades`,
|
||||
icon: () => (
|
||||
<Avatar
|
||||
className="mx-auto my-1"
|
||||
|
|
|
@ -8,7 +8,7 @@ import { trackCallback } from 'web/lib/service/analytics'
|
|||
export function ProfileSummary(props: { user: User }) {
|
||||
const { user } = props
|
||||
return (
|
||||
<Link href={`/${user.username}?tab=bets`}>
|
||||
<Link href={`/${user.username}?tab=trades`}>
|
||||
<a
|
||||
onClick={trackCallback('sidebar: profile')}
|
||||
className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||
|
|
|
@ -203,7 +203,7 @@ function NumericBuyPanel(props: {
|
|||
)}
|
||||
onClick={betDisabled ? undefined : submitBet}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit bet'}
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
|
|
@ -2,8 +2,8 @@ import { InfoBox } from './info-box'
|
|||
|
||||
export const PlayMoneyDisclaimer = () => (
|
||||
<InfoBox
|
||||
title="Play-money betting"
|
||||
title="Play-money trading"
|
||||
className="mt-4 max-w-md"
|
||||
text="Mana (M$) is the play-money used by our platform to keep track of your bets. It's completely free for you and your friends to get started!"
|
||||
text="Mana (M$) is the play-money used by our platform to keep track of your trades. It's completely free for you and your friends to get started!"
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -11,7 +11,7 @@ export function LoansModal(props: {
|
|||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
||||
<span className={'text-8xl'}>🏦</span>
|
||||
<span className="text-xl">Daily loans on your bets</span>
|
||||
<span className="text-xl">Daily loans on your trades</span>
|
||||
<Col className={'gap-2'}>
|
||||
<span className={'text-indigo-700'}>• What are daily loans?</span>
|
||||
<span className={'ml-2'}>
|
||||
|
|
|
@ -83,14 +83,14 @@ export function ResolutionPanel(props: {
|
|||
<div>
|
||||
{outcome === 'YES' ? (
|
||||
<>
|
||||
Winnings will be paid out to YES bettors.
|
||||
Winnings will be paid out to traders who bought YES.
|
||||
{/* <br />
|
||||
<br />
|
||||
You will earn {earnedFees}. */}
|
||||
</>
|
||||
) : outcome === 'NO' ? (
|
||||
<>
|
||||
Winnings will be paid out to NO bettors.
|
||||
Winnings will be paid out to traders who bought NO.
|
||||
{/* <br />
|
||||
<br />
|
||||
You will earn {earnedFees}. */}
|
||||
|
|
|
@ -19,7 +19,7 @@ export function BetSignUpPrompt(props: {
|
|||
size={size}
|
||||
color="gradient"
|
||||
>
|
||||
{label ?? 'Sign up to bet!'}
|
||||
{label ?? 'Sign up to predict!'}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
|
|
|
@ -46,6 +46,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
|||
comment.commentType === 'contract' ? comment.contractId : undefined
|
||||
const groupId =
|
||||
comment.commentType === 'group' ? comment.groupId : undefined
|
||||
const postId = comment.commentType === 'post' ? comment.postId : undefined
|
||||
await transact({
|
||||
amount: change,
|
||||
fromId: user.id,
|
||||
|
@ -54,7 +55,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
|||
toType: 'USER',
|
||||
token: 'M$',
|
||||
category: 'TIP',
|
||||
data: { commentId: comment.id, contractId, groupId },
|
||||
data: { commentId: comment.id, contractId, groupId, postId },
|
||||
description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`,
|
||||
})
|
||||
|
||||
|
@ -62,6 +63,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
|||
commentId: comment.id,
|
||||
contractId,
|
||||
groupId,
|
||||
postId,
|
||||
amount: change,
|
||||
fromId: user.id,
|
||||
toId: comment.userId,
|
||||
|
|
|
@ -260,7 +260,7 @@ export function UserPage(props: { user: User }) {
|
|||
),
|
||||
},
|
||||
{
|
||||
title: 'Bets',
|
||||
title: 'Trades',
|
||||
content: (
|
||||
<>
|
||||
<BetsList user={user} />
|
||||
|
|
74
web/components/warning-confirmation-button.tsx
Normal file
74
web/components/warning-confirmation-button.tsx
Normal file
|
@ -0,0 +1,74 @@
|
|||
import clsx from 'clsx'
|
||||
import React from 'react'
|
||||
|
||||
import { Row } from './layout/row'
|
||||
import { ConfirmationButton } from './confirmation-button'
|
||||
import { ExclamationIcon } from '@heroicons/react/solid'
|
||||
|
||||
export function WarningConfirmationButton(props: {
|
||||
warning?: string
|
||||
onSubmit: () => void
|
||||
disabled?: boolean
|
||||
isSubmitting: boolean
|
||||
openModalButtonClass?: string
|
||||
submitButtonClassName?: string
|
||||
}) {
|
||||
const {
|
||||
onSubmit,
|
||||
warning,
|
||||
disabled,
|
||||
isSubmitting,
|
||||
openModalButtonClass,
|
||||
submitButtonClassName,
|
||||
} = props
|
||||
|
||||
if (!warning) {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
openModalButtonClass,
|
||||
isSubmitting ? 'loading' : '',
|
||||
disabled && 'btn-disabled'
|
||||
)}
|
||||
onClick={onSubmit}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationButton
|
||||
openModalBtn={{
|
||||
className: clsx(
|
||||
openModalButtonClass,
|
||||
isSubmitting && 'btn-disabled loading'
|
||||
),
|
||||
label: 'Submit',
|
||||
}}
|
||||
cancelBtn={{
|
||||
label: 'Cancel',
|
||||
className: 'btn-warning',
|
||||
}}
|
||||
submitBtn={{
|
||||
label: 'Submit',
|
||||
className: clsx(
|
||||
'border-none btn-sm btn-ghost self-center',
|
||||
submitButtonClassName
|
||||
),
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Row className="items-center text-xl">
|
||||
<ExclamationIcon
|
||||
className="h-16 w-16 text-yellow-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Whoa, there!
|
||||
</Row>
|
||||
|
||||
<p>{warning}</p>
|
||||
</ConfirmationButton>
|
||||
)
|
||||
}
|
|
@ -193,7 +193,7 @@ export function BuyButton(props: { className?: string; onClick?: () => void }) {
|
|||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
Bet
|
||||
Buy
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,8 +1,14 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Comment, ContractComment, GroupComment } from 'common/comment'
|
||||
import {
|
||||
Comment,
|
||||
ContractComment,
|
||||
GroupComment,
|
||||
PostComment,
|
||||
} from 'common/comment'
|
||||
import {
|
||||
listenForCommentsOnContract,
|
||||
listenForCommentsOnGroup,
|
||||
listenForCommentsOnPost,
|
||||
listenForRecentComments,
|
||||
} from 'web/lib/firebase/comments'
|
||||
|
||||
|
@ -25,6 +31,16 @@ export const useCommentsOnGroup = (groupId: string | undefined) => {
|
|||
return comments
|
||||
}
|
||||
|
||||
export const useCommentsOnPost = (postId: string | undefined) => {
|
||||
const [comments, setComments] = useState<PostComment[] | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (postId) return listenForCommentsOnPost(postId, setComments)
|
||||
}, [postId])
|
||||
|
||||
return comments
|
||||
}
|
||||
|
||||
export const useRecentComments = () => {
|
||||
const [recentComments, setRecentComments] = useState<Comment[] | undefined>()
|
||||
useEffect(() => listenForRecentComments(setRecentComments), [])
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
getUserBetContractsQuery,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import { useQueryClient } from 'react-query'
|
||||
import { MINUTE_MS } from 'common/util/time'
|
||||
|
||||
export const useContracts = () => {
|
||||
const [contracts, setContracts] = useState<Contract[] | undefined>()
|
||||
|
@ -96,8 +97,10 @@ export const useUpdatedContracts = (contracts: Contract[] | undefined) => {
|
|||
|
||||
export const usePrefetchUserBetContracts = (userId: string) => {
|
||||
const queryClient = useQueryClient()
|
||||
return queryClient.prefetchQuery(['contracts', 'bets', userId], () =>
|
||||
getUserBetContracts(userId)
|
||||
return queryClient.prefetchQuery(
|
||||
['contracts', 'bets', userId],
|
||||
() => getUserBetContracts(userId),
|
||||
{ staleTime: 5 * MINUTE_MS }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -2,13 +2,13 @@ import { useEffect, useState } from 'react'
|
|||
import { Group } from 'common/group'
|
||||
import { User } from 'common/user'
|
||||
import {
|
||||
getMemberGroups,
|
||||
GroupMemberDoc,
|
||||
groupMembers,
|
||||
listenForGroup,
|
||||
listenForGroupContractDocs,
|
||||
listenForGroups,
|
||||
listenForMemberGroupIds,
|
||||
listenForMemberGroups,
|
||||
listenForOpenGroups,
|
||||
listGroups,
|
||||
} from 'web/lib/firebase/groups'
|
||||
|
@ -17,6 +17,7 @@ import { filterDefined } from 'common/util/array'
|
|||
import { Contract } from 'common/contract'
|
||||
import { uniq } from 'lodash'
|
||||
import { listenForValues } from 'web/lib/firebase/utils'
|
||||
import { useQuery } from 'react-query'
|
||||
|
||||
export const useGroup = (groupId: string | undefined) => {
|
||||
const [group, setGroup] = useState<Group | null | undefined>()
|
||||
|
@ -49,12 +50,10 @@ export const useOpenGroups = () => {
|
|||
}
|
||||
|
||||
export const useMemberGroups = (userId: string | null | undefined) => {
|
||||
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
|
||||
useEffect(() => {
|
||||
if (userId)
|
||||
return listenForMemberGroups(userId, (groups) => setMemberGroups(groups))
|
||||
}, [userId])
|
||||
return memberGroups
|
||||
const result = useQuery(['member-groups', userId ?? ''], () =>
|
||||
getMemberGroups(userId ?? '')
|
||||
)
|
||||
return result.data
|
||||
}
|
||||
|
||||
// Note: We cache member group ids in localstorage to speed up the initial load
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useQueryClient } from 'react-query'
|
||||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||
import { DAY_MS, HOUR_MS } from 'common/util/time'
|
||||
import { DAY_MS, HOUR_MS, MINUTE_MS } from 'common/util/time'
|
||||
import {
|
||||
getPortfolioHistory,
|
||||
getPortfolioHistoryQuery,
|
||||
|
@ -15,8 +15,10 @@ const getCutoff = (period: Period) => {
|
|||
export const usePrefetchPortfolioHistory = (userId: string, period: Period) => {
|
||||
const queryClient = useQueryClient()
|
||||
const cutoff = getCutoff(period)
|
||||
return queryClient.prefetchQuery(['portfolio-history', userId, cutoff], () =>
|
||||
getPortfolioHistory(userId, cutoff)
|
||||
return queryClient.prefetchQuery(
|
||||
['portfolio-history', userId, cutoff],
|
||||
() => getPortfolioHistory(userId, cutoff),
|
||||
{ staleTime: 15 * MINUTE_MS }
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -24,7 +26,9 @@ export const usePortfolioHistory = (userId: string, period: Period) => {
|
|||
const cutoff = getCutoff(period)
|
||||
const result = useFirestoreQueryData(
|
||||
['portfolio-history', userId, cutoff],
|
||||
getPortfolioHistoryQuery(userId, cutoff)
|
||||
getPortfolioHistoryQuery(userId, cutoff),
|
||||
{},
|
||||
{ staleTime: 15 * MINUTE_MS }
|
||||
)
|
||||
return result.data
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
|
|||
import {
|
||||
listenForTipTxns,
|
||||
listenForTipTxnsOnGroup,
|
||||
listenForTipTxnsOnPost,
|
||||
} from 'web/lib/firebase/txns'
|
||||
|
||||
export type CommentTips = { [userId: string]: number }
|
||||
|
@ -12,14 +13,16 @@ export type CommentTipMap = { [commentId: string]: CommentTips }
|
|||
export function useTipTxns(on: {
|
||||
contractId?: string
|
||||
groupId?: string
|
||||
postId?: string
|
||||
}): CommentTipMap {
|
||||
const [txns, setTxns] = useState<TipTxn[]>([])
|
||||
const { contractId, groupId } = on
|
||||
const { contractId, groupId, postId } = on
|
||||
|
||||
useEffect(() => {
|
||||
if (contractId) return listenForTipTxns(contractId, setTxns)
|
||||
if (groupId) return listenForTipTxnsOnGroup(groupId, setTxns)
|
||||
}, [contractId, groupId, setTxns])
|
||||
if (postId) return listenForTipTxnsOnPost(postId, setTxns)
|
||||
}, [contractId, groupId, postId, setTxns])
|
||||
|
||||
return useMemo(() => {
|
||||
const byComment = groupBy(txns, 'data.commentId')
|
||||
|
|
|
@ -7,10 +7,15 @@ import {
|
|||
getUserBetsQuery,
|
||||
listenForUserContractBets,
|
||||
} from 'web/lib/firebase/bets'
|
||||
import { MINUTE_MS } from 'common/util/time'
|
||||
|
||||
export const usePrefetchUserBets = (userId: string) => {
|
||||
const queryClient = useQueryClient()
|
||||
return queryClient.prefetchQuery(['bets', userId], () => getUserBets(userId))
|
||||
return queryClient.prefetchQuery(
|
||||
['bets', userId],
|
||||
() => getUserBets(userId),
|
||||
{ staleTime: MINUTE_MS }
|
||||
)
|
||||
}
|
||||
|
||||
export const useUserBets = (userId: string) => {
|
||||
|
|
|
@ -6,7 +6,8 @@ import { useFollows } from './use-follows'
|
|||
import { useUser } from './use-user'
|
||||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||
import { DocumentData } from 'firebase/firestore'
|
||||
import { users, privateUsers } from 'web/lib/firebase/users'
|
||||
import { users, privateUsers, getUsers } from 'web/lib/firebase/users'
|
||||
import { QueryClient } from 'react-query'
|
||||
|
||||
export const useUsers = () => {
|
||||
const result = useFirestoreQueryData<DocumentData, User[]>(['users'], users, {
|
||||
|
@ -16,6 +17,10 @@ export const useUsers = () => {
|
|||
return result.data ?? []
|
||||
}
|
||||
|
||||
const q = new QueryClient()
|
||||
export const getCachedUsers = async () =>
|
||||
q.fetchQuery(['users'], getUsers, { staleTime: Infinity })
|
||||
|
||||
export const usePrivateUsers = () => {
|
||||
const result = useFirestoreQueryData<DocumentData, PrivateUser[]>(
|
||||
['private users'],
|
||||
|
|
|
@ -7,12 +7,22 @@ import {
|
|||
query,
|
||||
setDoc,
|
||||
where,
|
||||
DocumentData,
|
||||
DocumentReference,
|
||||
} from 'firebase/firestore'
|
||||
|
||||
import { getValues, listenForValues } from './utils'
|
||||
import { db } from './init'
|
||||
import { User } from 'common/user'
|
||||
import { Comment, ContractComment, GroupComment } from 'common/comment'
|
||||
import {
|
||||
Comment,
|
||||
ContractComment,
|
||||
GroupComment,
|
||||
OnContract,
|
||||
OnGroup,
|
||||
OnPost,
|
||||
PostComment,
|
||||
} from 'common/comment'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
import { JSONContent } from '@tiptap/react'
|
||||
|
@ -24,7 +34,7 @@ export const MAX_COMMENT_LENGTH = 10000
|
|||
export async function createCommentOnContract(
|
||||
contractId: string,
|
||||
content: JSONContent,
|
||||
commenter: User,
|
||||
user: User,
|
||||
betId?: string,
|
||||
answerOutcome?: string,
|
||||
replyToCommentId?: string
|
||||
|
@ -32,28 +42,20 @@ export async function createCommentOnContract(
|
|||
const ref = betId
|
||||
? doc(getCommentsCollection(contractId), betId)
|
||||
: doc(getCommentsCollection(contractId))
|
||||
// contract slug and question are set via trigger
|
||||
const comment = removeUndefinedProps({
|
||||
id: ref.id,
|
||||
const onContract = {
|
||||
commentType: 'contract',
|
||||
contractId,
|
||||
userId: commenter.id,
|
||||
content: content,
|
||||
createdTime: Date.now(),
|
||||
userName: commenter.name,
|
||||
userUsername: commenter.username,
|
||||
userAvatarUrl: commenter.avatarUrl,
|
||||
betId: betId,
|
||||
answerOutcome: answerOutcome,
|
||||
replyToCommentId: replyToCommentId,
|
||||
})
|
||||
track('comment', {
|
||||
betId,
|
||||
answerOutcome,
|
||||
} as OnContract
|
||||
return await createComment(
|
||||
contractId,
|
||||
commentId: ref.id,
|
||||
betId: betId,
|
||||
replyToCommentId: replyToCommentId,
|
||||
})
|
||||
return await setDoc(ref, comment)
|
||||
onContract,
|
||||
content,
|
||||
user,
|
||||
ref,
|
||||
replyToCommentId
|
||||
)
|
||||
}
|
||||
export async function createCommentOnGroup(
|
||||
groupId: string,
|
||||
|
@ -62,10 +64,45 @@ export async function createCommentOnGroup(
|
|||
replyToCommentId?: string
|
||||
) {
|
||||
const ref = doc(getCommentsOnGroupCollection(groupId))
|
||||
const onGroup = { commentType: 'group', groupId: groupId } as OnGroup
|
||||
return await createComment(
|
||||
groupId,
|
||||
onGroup,
|
||||
content,
|
||||
user,
|
||||
ref,
|
||||
replyToCommentId
|
||||
)
|
||||
}
|
||||
|
||||
export async function createCommentOnPost(
|
||||
postId: string,
|
||||
content: JSONContent,
|
||||
user: User,
|
||||
replyToCommentId?: string
|
||||
) {
|
||||
const ref = doc(getCommentsOnPostCollection(postId))
|
||||
const onPost = { postId: postId, commentType: 'post' } as OnPost
|
||||
return await createComment(
|
||||
postId,
|
||||
onPost,
|
||||
content,
|
||||
user,
|
||||
ref,
|
||||
replyToCommentId
|
||||
)
|
||||
}
|
||||
|
||||
async function createComment(
|
||||
surfaceId: string,
|
||||
extraFields: OnContract | OnGroup | OnPost,
|
||||
content: JSONContent,
|
||||
user: User,
|
||||
ref: DocumentReference<DocumentData>,
|
||||
replyToCommentId?: string
|
||||
) {
|
||||
const comment = removeUndefinedProps({
|
||||
id: ref.id,
|
||||
commentType: 'group',
|
||||
groupId,
|
||||
userId: user.id,
|
||||
content: content,
|
||||
createdTime: Date.now(),
|
||||
|
@ -73,11 +110,13 @@ export async function createCommentOnGroup(
|
|||
userUsername: user.username,
|
||||
userAvatarUrl: user.avatarUrl,
|
||||
replyToCommentId: replyToCommentId,
|
||||
...extraFields,
|
||||
})
|
||||
track('group message', {
|
||||
|
||||
track(`${extraFields.commentType} message`, {
|
||||
user,
|
||||
commentId: ref.id,
|
||||
groupId,
|
||||
surfaceId,
|
||||
replyToCommentId: replyToCommentId,
|
||||
})
|
||||
return await setDoc(ref, comment)
|
||||
|
@ -91,6 +130,10 @@ function getCommentsOnGroupCollection(groupId: string) {
|
|||
return collection(db, 'groups', groupId, 'comments')
|
||||
}
|
||||
|
||||
function getCommentsOnPostCollection(postId: string) {
|
||||
return collection(db, 'posts', postId, 'comments')
|
||||
}
|
||||
|
||||
export async function listAllComments(contractId: string) {
|
||||
return await getValues<Comment>(
|
||||
query(getCommentsCollection(contractId), orderBy('createdTime', 'desc'))
|
||||
|
@ -103,6 +146,12 @@ export async function listAllCommentsOnGroup(groupId: string) {
|
|||
)
|
||||
}
|
||||
|
||||
export async function listAllCommentsOnPost(postId: string) {
|
||||
return await getValues<PostComment>(
|
||||
query(getCommentsOnPostCollection(postId), orderBy('createdTime', 'desc'))
|
||||
)
|
||||
}
|
||||
|
||||
export function listenForCommentsOnContract(
|
||||
contractId: string,
|
||||
setComments: (comments: ContractComment[]) => void
|
||||
|
@ -126,6 +175,16 @@ export function listenForCommentsOnGroup(
|
|||
)
|
||||
}
|
||||
|
||||
export function listenForCommentsOnPost(
|
||||
postId: string,
|
||||
setComments: (comments: PostComment[]) => void
|
||||
) {
|
||||
return listenForValues<PostComment>(
|
||||
query(getCommentsOnPostCollection(postId), orderBy('createdTime', 'desc')),
|
||||
setComments
|
||||
)
|
||||
}
|
||||
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
// Define "recent" as "<3 days ago" for now
|
||||
|
|
|
@ -31,7 +31,7 @@ export const groupMembers = (groupId: string) =>
|
|||
export const groupContracts = (groupId: string) =>
|
||||
collection(groups, groupId, 'groupContracts')
|
||||
const openGroupsQuery = query(groups, where('anyoneCanJoin', '==', true))
|
||||
const memberGroupsQuery = (userId: string) =>
|
||||
export const memberGroupsQuery = (userId: string) =>
|
||||
query(collectionGroup(db, 'groupMembers'), where('userId', '==', userId))
|
||||
|
||||
export function groupPath(
|
||||
|
@ -112,6 +112,15 @@ export function listenForGroup(
|
|||
return listenForValue(doc(groups, groupId), setGroup)
|
||||
}
|
||||
|
||||
export async function getMemberGroups(userId: string) {
|
||||
const snapshot = await getDocs(memberGroupsQuery(userId))
|
||||
const groupIds = filterDefined(
|
||||
snapshot.docs.map((doc) => doc.ref.parent.parent?.id)
|
||||
)
|
||||
const groups = await Promise.all(groupIds.map(getGroup))
|
||||
return filterDefined(groups)
|
||||
}
|
||||
|
||||
export function listenForMemberGroupIds(
|
||||
userId: string,
|
||||
setGroupIds: (groupIds: string[]) => void
|
||||
|
|
|
@ -41,6 +41,13 @@ const getTipsOnGroupQuery = (groupId: string) =>
|
|||
where('data.groupId', '==', groupId)
|
||||
)
|
||||
|
||||
const getTipsOnPostQuery = (postId: string) =>
|
||||
query(
|
||||
txns,
|
||||
where('category', '==', 'TIP'),
|
||||
where('data.postId', '==', postId)
|
||||
)
|
||||
|
||||
export function listenForTipTxns(
|
||||
contractId: string,
|
||||
setTxns: (txns: TipTxn[]) => void
|
||||
|
@ -54,6 +61,13 @@ export function listenForTipTxnsOnGroup(
|
|||
return listenForValues<TipTxn>(getTipsOnGroupQuery(groupId), setTxns)
|
||||
}
|
||||
|
||||
export function listenForTipTxnsOnPost(
|
||||
postId: string,
|
||||
setTxns: (txns: TipTxn[]) => void
|
||||
) {
|
||||
return listenForValues<TipTxn>(getTipsOnPostQuery(postId), setTxns)
|
||||
}
|
||||
|
||||
// Find all manalink Txns that are from or to this user
|
||||
export function useManalinkTxns(userId: string) {
|
||||
const [fromTxns, setFromTxns] = useState<ManalinkTxn[]>([])
|
||||
|
|
19
web/lib/icons/challenge-icon.tsx
Normal file
19
web/lib/icons/challenge-icon.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
export default function ChallengeIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
className={props.className}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g>
|
||||
<polygon points="18.63 15.11 15.37 18.49 3.39 6.44 1.82 1.05 7.02 2.68 18.63 15.11" />
|
||||
<polygon points="21.16 13.73 22.26 14.87 19.51 17.72 23 21.35 21.41 23 17.91 19.37 15.16 22.23 14.07 21.09 21.16 13.73" />
|
||||
</g>
|
||||
<g>
|
||||
<polygon points="8.6 18.44 5.34 15.06 16.96 2.63 22.15 1 20.58 6.39 8.6 18.44" />
|
||||
<polygon points="9.93 21.07 8.84 22.21 6.09 19.35 2.59 22.98 1 21.33 4.49 17.7 1.74 14.85 2.84 13.71 9.93 21.07" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
|
@ -12,3 +12,7 @@ export function isIOS() {
|
|||
(navigator.userAgent.includes('Mac') && 'ontouchend' in document)
|
||||
)
|
||||
}
|
||||
|
||||
export function isAndroid() {
|
||||
return navigator.userAgent.includes('Android')
|
||||
}
|
||||
|
|
|
@ -9,6 +9,9 @@ module.exports = {
|
|||
reactStrictMode: true,
|
||||
optimizeFonts: false,
|
||||
experimental: {
|
||||
images: {
|
||||
allowFutureImage: true,
|
||||
},
|
||||
scrollRestoration: true,
|
||||
externalDir: true,
|
||||
modularizeImports: {
|
||||
|
@ -25,7 +28,12 @@ module.exports = {
|
|||
},
|
||||
},
|
||||
images: {
|
||||
domains: ['lh3.googleusercontent.com', 'i.imgur.com'],
|
||||
domains: [
|
||||
'manifold.markets',
|
||||
'lh3.googleusercontent.com',
|
||||
'i.imgur.com',
|
||||
'firebasestorage.googleapis.com',
|
||||
],
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
|
|
|
@ -5,7 +5,7 @@ import { Comment } from 'common/comment'
|
|||
import { Contract } from 'common/contract'
|
||||
import { User } from 'common/user'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { DOMAIN, ENV_CONFIG } from 'common/envs/constants'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { richTextToString } from 'common/util/parse'
|
||||
|
||||
|
@ -121,7 +121,7 @@ export function toLiteMarket(contract: Contract): LiteMarket {
|
|||
: closeTime,
|
||||
question,
|
||||
tags,
|
||||
url: `https://manifold.markets/${creatorUsername}/${slug}`,
|
||||
url: `https://${DOMAIN}/${creatorUsername}/${slug}`,
|
||||
pool,
|
||||
probability,
|
||||
p,
|
||||
|
|
|
@ -178,7 +178,7 @@ export default function Charity(props: {
|
|||
className="input input-bordered mb-6 w-full"
|
||||
/>
|
||||
</Col>
|
||||
<div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 lg:max-w-full lg:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 self-center lg:max-w-full lg:grid-cols-2 xl:grid-cols-3">
|
||||
{filterCharities.map((charity) => (
|
||||
<CharityCard
|
||||
charity={charity}
|
||||
|
@ -203,18 +203,26 @@ export default function Charity(props: {
|
|||
></iframe>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 text-gray-500">
|
||||
<span className="font-semibold">Notes</span>
|
||||
<br />
|
||||
- Don't see your favorite charity? Recommend it by emailing
|
||||
charity@manifold.markets!
|
||||
<br />
|
||||
- Manifold is not affiliated with non-Featured charities; we're just
|
||||
fans of their work.
|
||||
<br />
|
||||
- As Manifold itself is a for-profit entity, your contributions will
|
||||
not be tax deductible.
|
||||
<br />- Donations + matches are wired once each quarter.
|
||||
<div className="prose mt-10 max-w-none text-gray-500">
|
||||
<span className="text-lg font-semibold">Notes</span>
|
||||
<ul>
|
||||
<li>
|
||||
Don't see your favorite charity? Recommend it by emailing{' '}
|
||||
<a href="mailto:charity@manifold.markets?subject=Add%20Charity">
|
||||
charity@manifold.markets
|
||||
</a>
|
||||
!
|
||||
</li>
|
||||
<li>
|
||||
Manifold is not affiliated with non-Featured charities; we're just
|
||||
fans of their work.
|
||||
</li>
|
||||
<li>
|
||||
As Manifold itself is a for-profit entity, your contributions will
|
||||
not be tax deductible.
|
||||
</li>
|
||||
<li>Donations + matches are wired once each quarter.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Col>
|
||||
</Page>
|
||||
|
|
55
web/pages/experimental/home/edit.tsx
Normal file
55
web/pages/experimental/home/edit.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
import { ArrangeHome } from 'web/components/arrange-home'
|
||||
import { Button } from 'web/components/button'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Page } from 'web/components/page'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { Title } from 'web/components/title'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { updateUser } from 'web/lib/firebase/users'
|
||||
|
||||
export default function Home() {
|
||||
const user = useUser()
|
||||
|
||||
useTracking('edit home')
|
||||
|
||||
const [homeSections, setHomeSections] = useState(user?.homeSections ?? [])
|
||||
|
||||
const updateHomeSections = (newHomeSections: string[]) => {
|
||||
if (!user) return
|
||||
updateUser(user.id, { homeSections: newHomeSections })
|
||||
setHomeSections(newHomeSections)
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Col className="pm:mx-10 gap-4 px-4 pb-6 pt-2">
|
||||
<Row className={'w-full items-center justify-between'}>
|
||||
<Title text="Edit your home page" />
|
||||
<DoneButton />
|
||||
</Row>
|
||||
|
||||
<ArrangeHome
|
||||
user={user}
|
||||
homeSections={homeSections}
|
||||
setHomeSections={updateHomeSections}
|
||||
/>
|
||||
</Col>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
function DoneButton(props: { className?: string }) {
|
||||
const { className } = props
|
||||
|
||||
return (
|
||||
<SiteLink href="/experimental/home">
|
||||
<Button size="lg" color="blue" className={clsx(className, 'flex')}>
|
||||
Done
|
||||
</Button>
|
||||
</SiteLink>
|
||||
)
|
||||
}
|
|
@ -1,40 +1,37 @@
|
|||
import React, { useState } from 'react'
|
||||
import React from 'react'
|
||||
import Router from 'next/router'
|
||||
import { PencilIcon, PlusSmIcon } from '@heroicons/react/solid'
|
||||
import {
|
||||
PencilIcon,
|
||||
PlusSmIcon,
|
||||
ArrowSmRightIcon,
|
||||
} from '@heroicons/react/solid'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { Page } from 'web/components/page'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { ContractSearch, SORTS } from 'web/components/contract-search'
|
||||
import { User } from 'common/user'
|
||||
import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
|
||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||
import { GetServerSideProps } from 'next'
|
||||
import { Sort } from 'web/components/contract-search'
|
||||
import { Group } from 'common/group'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
import { GroupLinkItem } from '../../groups'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
import { DoubleCarousel } from '../../../components/double-carousel'
|
||||
import clsx from 'clsx'
|
||||
import { Button } from 'web/components/button'
|
||||
import { ArrangeHome, getHomeItems } from '../../../components/arrange-home'
|
||||
import { getHomeItems } from '../../../components/arrange-home'
|
||||
import { Title } from 'web/components/title'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { ProbChangeTable } from 'web/components/contract/prob-change-table'
|
||||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
|
||||
import { calculatePortfolioProfit } from 'common/calculate-metrics'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { useProbChanges } from 'web/hooks/use-prob-changes'
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const creds = await authenticateOnServer(ctx)
|
||||
const auth = creds ? await getUserAndPrivateUser(creds.uid) : null
|
||||
return { props: { auth } }
|
||||
}
|
||||
|
||||
const Home = (props: { auth: { user: User } | null }) => {
|
||||
const user = useUser() ?? props.auth?.user ?? null
|
||||
const Home = () => {
|
||||
const user = useUser()
|
||||
|
||||
useTracking('view home')
|
||||
|
||||
|
@ -42,76 +39,41 @@ const Home = (props: { auth: { user: User } | null }) => {
|
|||
|
||||
const groups = useMemberGroups(user?.id) ?? []
|
||||
|
||||
const [homeSections, setHomeSections] = useState(
|
||||
user?.homeSections ?? { visible: [], hidden: [] }
|
||||
)
|
||||
const { visibleItems } = getHomeItems(groups, homeSections)
|
||||
|
||||
const updateHomeSections = (newHomeSections: {
|
||||
visible: string[]
|
||||
hidden: string[]
|
||||
}) => {
|
||||
if (!user) return
|
||||
updateUser(user.id, { homeSections: newHomeSections })
|
||||
setHomeSections(newHomeSections)
|
||||
}
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const { sections } = getHomeItems(groups, user?.homeSections ?? [])
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Col className="pm:mx-10 gap-4 px-4 pb-12 xl:w-[125%]">
|
||||
<Col className="pm:mx-10 gap-4 px-4 pb-12">
|
||||
<Row className={'w-full items-center justify-between'}>
|
||||
<Title text={isEditing ? 'Edit your home page' : 'Home'} />
|
||||
<Title className="!mb-0" text="Home" />
|
||||
|
||||
<EditDoneButton isEditing={isEditing} setIsEditing={setIsEditing} />
|
||||
<EditButton />
|
||||
</Row>
|
||||
|
||||
{isEditing ? (
|
||||
<>
|
||||
<ArrangeHome
|
||||
user={user}
|
||||
homeSections={homeSections}
|
||||
setHomeSections={updateHomeSections}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xl text-gray-800">Daily movers</div>
|
||||
<ProbChangeTable userId={user?.id} />
|
||||
<DailyProfitAndBalance userId={user?.id} />
|
||||
|
||||
{visibleItems.map((item) => {
|
||||
const { id } = item
|
||||
if (id === 'your-bets') {
|
||||
return (
|
||||
<SearchSection
|
||||
key={id}
|
||||
label={'Your bets'}
|
||||
sort={'prob-change-day'}
|
||||
user={user}
|
||||
yourBets
|
||||
/>
|
||||
)
|
||||
}
|
||||
const sort = SORTS.find((sort) => sort.value === id)
|
||||
if (sort)
|
||||
return (
|
||||
<SearchSection
|
||||
key={id}
|
||||
label={sort.label}
|
||||
sort={sort.value}
|
||||
user={user}
|
||||
/>
|
||||
)
|
||||
{sections.map((item) => {
|
||||
const { id } = item
|
||||
if (id === 'daily-movers') {
|
||||
return <DailyMoversSection key={id} userId={user?.id} />
|
||||
}
|
||||
const sort = SORTS.find((sort) => sort.value === id)
|
||||
if (sort)
|
||||
return (
|
||||
<SearchSection
|
||||
key={id}
|
||||
label={sort.value === 'newest' ? 'New for you' : sort.label}
|
||||
sort={sort.value}
|
||||
followed={sort.value === 'newest'}
|
||||
user={user}
|
||||
/>
|
||||
)
|
||||
|
||||
const group = groups.find((g) => g.id === id)
|
||||
if (group)
|
||||
return <GroupSection key={id} group={group} user={user} />
|
||||
const group = groups.find((g) => g.id === id)
|
||||
if (group) return <GroupSection key={id} group={group} user={user} />
|
||||
|
||||
return null
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
return null
|
||||
})}
|
||||
</Col>
|
||||
<button
|
||||
type="button"
|
||||
|
@ -129,98 +91,129 @@ const Home = (props: { auth: { user: User } | null }) => {
|
|||
|
||||
function SearchSection(props: {
|
||||
label: string
|
||||
user: User | null
|
||||
user: User | null | undefined | undefined
|
||||
sort: Sort
|
||||
yourBets?: boolean
|
||||
followed?: boolean
|
||||
}) {
|
||||
const { label, user, sort, yourBets } = props
|
||||
const { label, user, sort, yourBets, followed } = props
|
||||
const href = `/home?s=${sort}`
|
||||
|
||||
return (
|
||||
<Col>
|
||||
<SiteLink className="mb-2 text-xl" href={href}>
|
||||
{label}
|
||||
{label}{' '}
|
||||
<ArrowSmRightIcon
|
||||
className="mb-0.5 inline h-6 w-6 text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</SiteLink>
|
||||
<ContractSearch
|
||||
user={user}
|
||||
defaultSort={sort}
|
||||
additionalFilter={yourBets ? { yourBets: true } : undefined}
|
||||
noControls
|
||||
// persistPrefix={`experimental-home-${sort}`}
|
||||
renderContracts={(contracts, loadMore) =>
|
||||
contracts ? (
|
||||
<DoubleCarousel
|
||||
contracts={contracts}
|
||||
seeMoreUrl={href}
|
||||
showTime={
|
||||
sort === 'close-date' || sort === 'resolve-date'
|
||||
? sort
|
||||
: undefined
|
||||
}
|
||||
loadMore={loadMore}
|
||||
/>
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)
|
||||
additionalFilter={
|
||||
yourBets
|
||||
? { yourBets: true }
|
||||
: followed
|
||||
? { followed: true }
|
||||
: undefined
|
||||
}
|
||||
noControls
|
||||
maxResults={6}
|
||||
persistPrefix={`experimental-home-${sort}`}
|
||||
/>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupSection(props: { group: Group; user: User | null }) {
|
||||
function GroupSection(props: {
|
||||
group: Group
|
||||
user: User | null | undefined | undefined
|
||||
}) {
|
||||
const { group, user } = props
|
||||
|
||||
return (
|
||||
<Col>
|
||||
<GroupLinkItem className="mb-2 text-xl" group={group} />
|
||||
<SiteLink className="mb-2 text-xl" href={groupPath(group.slug)}>
|
||||
{group.name}{' '}
|
||||
<ArrowSmRightIcon
|
||||
className="mb-0.5 inline h-6 w-6 text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</SiteLink>
|
||||
<ContractSearch
|
||||
user={user}
|
||||
defaultSort={'score'}
|
||||
additionalFilter={{ groupSlug: group.slug }}
|
||||
noControls
|
||||
// persistPrefix={`experimental-home-${group.slug}`}
|
||||
renderContracts={(contracts, loadMore) =>
|
||||
contracts ? (
|
||||
contracts.length == 0 ? (
|
||||
<div className="m-2 text-gray-500">No open markets</div>
|
||||
) : (
|
||||
<DoubleCarousel
|
||||
contracts={contracts}
|
||||
seeMoreUrl={`/group/${group.slug}`}
|
||||
loadMore={loadMore}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)
|
||||
}
|
||||
maxResults={6}
|
||||
persistPrefix={`experimental-home-${group.slug}`}
|
||||
/>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function EditDoneButton(props: {
|
||||
isEditing: boolean
|
||||
setIsEditing: (isEditing: boolean) => void
|
||||
className?: string
|
||||
}) {
|
||||
const { isEditing, setIsEditing, className } = props
|
||||
function DailyMoversSection(props: { userId: string | null | undefined }) {
|
||||
const { userId } = props
|
||||
const changes = useProbChanges(userId ?? '')
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="lg"
|
||||
color={isEditing ? 'blue' : 'gray-white'}
|
||||
className={clsx(className, 'flex')}
|
||||
onClick={() => {
|
||||
setIsEditing(!isEditing)
|
||||
}}
|
||||
>
|
||||
{!isEditing && (
|
||||
<PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />
|
||||
)}
|
||||
{isEditing ? 'Done' : 'Edit'}
|
||||
</Button>
|
||||
<Col className="gap-2">
|
||||
<SiteLink className="text-xl" href={'/daily-movers'}>
|
||||
Daily movers{' '}
|
||||
<ArrowSmRightIcon
|
||||
className="mb-0.5 inline h-6 w-6 text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</SiteLink>
|
||||
<ProbChangeTable changes={changes} />
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function EditButton(props: { className?: string }) {
|
||||
const { className } = props
|
||||
|
||||
return (
|
||||
<SiteLink href="/experimental/home/edit">
|
||||
<Button size="lg" color="gray-white" className={clsx(className, 'flex')}>
|
||||
<PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />{' '}
|
||||
Edit
|
||||
</Button>
|
||||
</SiteLink>
|
||||
)
|
||||
}
|
||||
|
||||
function DailyProfitAndBalance(props: {
|
||||
userId: string | null | undefined
|
||||
className?: string
|
||||
}) {
|
||||
const { userId, className } = props
|
||||
const metrics = usePortfolioHistory(userId ?? '', 'daily') ?? []
|
||||
const [first, last] = [metrics[0], metrics[metrics.length - 1]]
|
||||
|
||||
if (first === undefined || last === undefined) return null
|
||||
|
||||
const profit =
|
||||
calculatePortfolioProfit(last) - calculatePortfolioProfit(first)
|
||||
|
||||
const balanceChange = last.balance - first.balance
|
||||
|
||||
return (
|
||||
<div className={clsx(className, 'text-lg')}>
|
||||
<span className={clsx(profit >= 0 ? 'text-green-500' : 'text-red-500')}>
|
||||
{profit >= 0 && '+'}
|
||||
{formatMoney(profit)}
|
||||
</span>{' '}
|
||||
profit and{' '}
|
||||
<span
|
||||
className={clsx(balanceChange >= 0 ? 'text-green-500' : 'text-red-500')}
|
||||
>
|
||||
{balanceChange >= 0 && '+'}
|
||||
{formatMoney(balanceChange)}
|
||||
</span>{' '}
|
||||
balance today
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -231,6 +231,7 @@ export default function GroupPage(props: {
|
|||
defaultSort={'newest'}
|
||||
defaultFilter={suggestedFilter}
|
||||
additionalFilter={{ groupSlug: group.slug }}
|
||||
persistPrefix={`group-${group.slug}`}
|
||||
/>
|
||||
)
|
||||
|
||||
|
|
|
@ -390,7 +390,7 @@ function IncomeNotificationItem(props: {
|
|||
reasonText = !simple
|
||||
? `Bonus for ${
|
||||
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
|
||||
} new bettors on`
|
||||
} new traders on`
|
||||
: 'bonus on'
|
||||
} else if (sourceType === 'tip') {
|
||||
reasonText = !simple ? `tipped you on` : `in tips on`
|
||||
|
@ -508,7 +508,7 @@ function IncomeNotificationItem(props: {
|
|||
{(isTip || isUniqueBettorBonus) && (
|
||||
<MultiUserTransactionLink
|
||||
userInfos={userLinks}
|
||||
modalLabel={isTip ? 'Who tipped you' : 'Unique bettors'}
|
||||
modalLabel={isTip ? 'Who tipped you' : 'Unique traders'}
|
||||
/>
|
||||
)}
|
||||
<Row className={'line-clamp-2 flex max-w-xl'}>
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { Page } from 'web/components/page'
|
||||
|
||||
import { postPath, getPostBySlug } from 'web/lib/firebase/posts'
|
||||
import { postPath, getPostBySlug, updatePost } from 'web/lib/firebase/posts'
|
||||
import { Post } from 'common/post'
|
||||
import { Title } from 'web/components/title'
|
||||
import { Spacer } from 'web/components/layout/spacer'
|
||||
import { Content } from 'web/components/editor'
|
||||
import { Content, TextEditor, useTextEditor } from 'web/components/editor'
|
||||
import { getUser, User } from 'web/lib/firebase/users'
|
||||
import { ShareIcon } from '@heroicons/react/solid'
|
||||
import { PencilIcon, ShareIcon } from '@heroicons/react/solid'
|
||||
import clsx from 'clsx'
|
||||
import { Button } from 'web/components/button'
|
||||
import { useState } from 'react'
|
||||
|
@ -16,17 +16,27 @@ import { Col } from 'web/components/layout/col'
|
|||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import Custom404 from 'web/pages/404'
|
||||
import { UserLink } from 'web/components/user-link'
|
||||
import { listAllCommentsOnPost } from 'web/lib/firebase/comments'
|
||||
import { PostComment } from 'common/comment'
|
||||
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
|
||||
import { groupBy, sortBy } from 'lodash'
|
||||
import { PostCommentInput, PostCommentThread } from 'web/posts/post-comments'
|
||||
import { useCommentsOnPost } from 'web/hooks/use-comments'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { usePost } from 'web/hooks/use-post'
|
||||
|
||||
export async function getStaticProps(props: { params: { slugs: string[] } }) {
|
||||
const { slugs } = props.params
|
||||
|
||||
const post = await getPostBySlug(slugs[0])
|
||||
const creator = post ? await getUser(post.creatorId) : null
|
||||
const comments = post && (await listAllCommentsOnPost(post.id))
|
||||
|
||||
return {
|
||||
props: {
|
||||
post: post,
|
||||
creator: creator,
|
||||
comments: comments,
|
||||
},
|
||||
|
||||
revalidate: 60, // regenerate after a minute
|
||||
|
@ -37,32 +47,41 @@ export async function getStaticPaths() {
|
|||
return { paths: [], fallback: 'blocking' }
|
||||
}
|
||||
|
||||
export default function PostPage(props: { post: Post; creator: User }) {
|
||||
export default function PostPage(props: {
|
||||
post: Post
|
||||
creator: User
|
||||
comments: PostComment[]
|
||||
}) {
|
||||
const [isShareOpen, setShareOpen] = useState(false)
|
||||
const { creator } = props
|
||||
const post = usePost(props.post.id) ?? props.post
|
||||
|
||||
if (props.post == null) {
|
||||
const tips = useTipTxns({ postId: post.id })
|
||||
const shareUrl = `https://${ENV_CONFIG.domain}${postPath(post.slug)}`
|
||||
const updatedComments = useCommentsOnPost(post.id)
|
||||
const comments = updatedComments ?? props.comments
|
||||
const user = useUser()
|
||||
|
||||
if (post == null) {
|
||||
return <Custom404 />
|
||||
}
|
||||
|
||||
const shareUrl = `https://${ENV_CONFIG.domain}${postPath(props?.post.slug)}`
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="mx-auto w-full max-w-3xl ">
|
||||
<Spacer h={1} />
|
||||
<Title className="!mt-0" text={props.post.title} />
|
||||
<Title className="!mt-0 py-4 px-2" text={post.title} />
|
||||
<Row>
|
||||
<Col className="flex-1">
|
||||
<Col className="flex-1 px-2">
|
||||
<div className={'inline-flex'}>
|
||||
<div className="mr-1 text-gray-500">Created by</div>
|
||||
<UserLink
|
||||
className="text-neutral"
|
||||
name={props.creator.name}
|
||||
username={props.creator.username}
|
||||
name={creator.name}
|
||||
username={creator.username}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col>
|
||||
<Col className="px-2">
|
||||
<Button
|
||||
size="lg"
|
||||
color="gray-white"
|
||||
|
@ -88,10 +107,121 @@ export default function PostPage(props: { post: Post; creator: User }) {
|
|||
<Spacer h={2} />
|
||||
<div className="rounded-lg bg-white px-6 py-4 sm:py-0">
|
||||
<div className="form-control w-full py-2">
|
||||
<Content content={props.post.content} />
|
||||
{user && user.id === post.creatorId ? (
|
||||
<RichEditPost post={post} />
|
||||
) : (
|
||||
<Content content={post.content} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Spacer h={4} />
|
||||
<div className="rounded-lg bg-white px-6 py-4 sm:py-0">
|
||||
<PostCommentsActivity
|
||||
post={post}
|
||||
comments={comments}
|
||||
tips={tips}
|
||||
user={creator}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export function PostCommentsActivity(props: {
|
||||
post: Post
|
||||
comments: PostComment[]
|
||||
tips: CommentTipMap
|
||||
user: User | null | undefined
|
||||
}) {
|
||||
const { post, comments, user, tips } = props
|
||||
const commentsByUserId = groupBy(comments, (c) => c.userId)
|
||||
const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_')
|
||||
const topLevelComments = sortBy(
|
||||
commentsByParentId['_'] ?? [],
|
||||
(c) => -c.createdTime
|
||||
)
|
||||
|
||||
return (
|
||||
<Col className="p-2">
|
||||
<PostCommentInput post={post} />
|
||||
{topLevelComments.map((parent) => (
|
||||
<PostCommentThread
|
||||
key={parent.id}
|
||||
user={user}
|
||||
post={post}
|
||||
parentComment={parent}
|
||||
threadComments={sortBy(
|
||||
commentsByParentId[parent.id] ?? [],
|
||||
(c) => c.createdTime
|
||||
)}
|
||||
tips={tips}
|
||||
commentsByUserId={commentsByUserId}
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function RichEditPost(props: { post: Post }) {
|
||||
const { post } = props
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const { editor, upload } = useTextEditor({
|
||||
defaultValue: post.content,
|
||||
disabled: isSubmitting,
|
||||
})
|
||||
|
||||
async function savePost() {
|
||||
if (!editor) return
|
||||
|
||||
await updatePost(post, {
|
||||
content: editor.getJSON(),
|
||||
})
|
||||
}
|
||||
|
||||
return editing ? (
|
||||
<>
|
||||
<TextEditor editor={editor} upload={upload} />
|
||||
<Spacer h={2} />
|
||||
<Row className="gap-2">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setIsSubmitting(true)
|
||||
await savePost()
|
||||
setEditing(false)
|
||||
setIsSubmitting(false)
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button color="gray" onClick={() => setEditing(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Row>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative">
|
||||
<div className="absolute top-0 right-0 z-10 space-x-2">
|
||||
<Button
|
||||
color="gray"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setEditing(true)
|
||||
editor?.commands.focus('end')
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="inline h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Content content={post.content} />
|
||||
<Spacer h={2} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -77,13 +77,21 @@ const Salem = {
|
|||
|
||||
const tourneys: Tourney[] = [
|
||||
{
|
||||
title: 'Cause Exploration Prizes',
|
||||
title: 'Manifold F2P Tournament',
|
||||
blurb:
|
||||
'Which new charity ideas will Open Philanthropy find most promising?',
|
||||
award: 'M$100k',
|
||||
endTime: toDate('Sep 9, 2022'),
|
||||
groupId: 'cMcpBQ2p452jEcJD2SFw',
|
||||
'Who can amass the most mana starting from a free-to-play (F2P) account?',
|
||||
award: 'Poem',
|
||||
endTime: toDate('Sep 15, 2022'),
|
||||
groupId: '6rrIja7tVW00lUVwtsYS',
|
||||
},
|
||||
// {
|
||||
// title: 'Cause Exploration Prizes',
|
||||
// blurb:
|
||||
// 'Which new charity ideas will Open Philanthropy find most promising?',
|
||||
// award: 'M$100k',
|
||||
// endTime: toDate('Sep 9, 2022'),
|
||||
// groupId: 'cMcpBQ2p452jEcJD2SFw',
|
||||
// },
|
||||
{
|
||||
title: 'Fantasy Football Stock Exchange',
|
||||
blurb: 'How many points will each NFL player score this season?',
|
||||
|
@ -91,13 +99,6 @@ const tourneys: Tourney[] = [
|
|||
endTime: toDate('Jan 6, 2023'),
|
||||
groupId: 'SxGRqXRpV3RAQKudbcNb',
|
||||
},
|
||||
{
|
||||
title: 'SF 2022 Ballot',
|
||||
blurb: 'Which ballot initiatives will pass this year in SF and CA?',
|
||||
award: '',
|
||||
endTime: toDate('Nov 8, 2022'),
|
||||
groupId: 'VkWZyS5yxs8XWUJrX9eq',
|
||||
},
|
||||
// {
|
||||
// title: 'Clearer Thinking Regrant Project',
|
||||
// blurb: 'Something amazing',
|
||||
|
@ -105,6 +106,27 @@ const tourneys: Tourney[] = [
|
|||
// endTime: toDate('Sep 22, 2022'),
|
||||
// groupId: '2VsVVFGhKtIdJnQRAXVb',
|
||||
// },
|
||||
|
||||
// Tournaments without awards get featured belows
|
||||
{
|
||||
title: 'SF 2022 Ballot',
|
||||
blurb: 'Which ballot initiatives will pass this year in SF and CA?',
|
||||
endTime: toDate('Nov 8, 2022'),
|
||||
groupId: 'VkWZyS5yxs8XWUJrX9eq',
|
||||
},
|
||||
|
||||
{
|
||||
title: '2024 Democratic Nominees',
|
||||
blurb: 'How would different Democratic candidates fare in 2024?',
|
||||
endTime: toDate('Nov 2, 2024'),
|
||||
groupId: 'gFhjgFVrnYeFYfxhoLNn',
|
||||
},
|
||||
{
|
||||
title: 'Private Tech Companies',
|
||||
blurb: 'What will these companies exit for?',
|
||||
endTime: toDate('Dec 31, 2030'),
|
||||
groupId: 'faNUnphw6Eoq7OJBRJds',
|
||||
},
|
||||
]
|
||||
|
||||
type SectionInfo = {
|
||||
|
@ -135,20 +157,23 @@ export default function TournamentPage(props: { sections: SectionInfo[] }) {
|
|||
title="Tournaments"
|
||||
description="Win money by betting in forecasting touraments on current events, sports, science, and more"
|
||||
/>
|
||||
<Col className="mx-4 mt-4 gap-10 sm:mx-10 xl:w-[125%]">
|
||||
{sections.map(({ tourney, slug, numPeople }) => (
|
||||
<div key={slug}>
|
||||
<SectionHeader
|
||||
url={groupPath(slug)}
|
||||
title={tourney.title}
|
||||
ppl={numPeople}
|
||||
award={tourney.award}
|
||||
endTime={tourney.endTime}
|
||||
/>
|
||||
<span>{tourney.blurb}</span>
|
||||
<MarketCarousel slug={slug} />
|
||||
</div>
|
||||
))}
|
||||
<Col className="m-4 gap-10 sm:mx-10 sm:gap-24 xl:w-[125%]">
|
||||
{sections.map(
|
||||
({ tourney, slug, numPeople }) =>
|
||||
tourney.award && (
|
||||
<div key={slug}>
|
||||
<SectionHeader
|
||||
url={groupPath(slug, 'about')}
|
||||
title={tourney.title}
|
||||
ppl={numPeople}
|
||||
award={tourney.award}
|
||||
endTime={tourney.endTime}
|
||||
/>
|
||||
<span className="text-gray-500">{tourney.blurb}</span>
|
||||
<MarketCarousel slug={slug} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<div>
|
||||
<SectionHeader
|
||||
url={Salem.url}
|
||||
|
@ -156,9 +181,52 @@ export default function TournamentPage(props: { sections: SectionInfo[] }) {
|
|||
award={Salem.award}
|
||||
endTime={Salem.endTime}
|
||||
/>
|
||||
<span>{Salem.blurb}</span>
|
||||
<span className="text-gray-500">{Salem.blurb}</span>
|
||||
<ImageCarousel url={Salem.url} images={Salem.images} />
|
||||
</div>
|
||||
|
||||
{/* Title break */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-gray-50 px-3 text-lg font-medium text-gray-900">
|
||||
Featured Groups
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sections.map(
|
||||
({ tourney, slug, numPeople }) =>
|
||||
!tourney.award && (
|
||||
<div key={slug}>
|
||||
<SectionHeader
|
||||
url={groupPath(slug, 'about')}
|
||||
title={tourney.title}
|
||||
ppl={numPeople}
|
||||
award={tourney.award}
|
||||
endTime={tourney.endTime}
|
||||
/>
|
||||
<span className="text-gray-500">{tourney.blurb}</span>
|
||||
<MarketCarousel slug={slug} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<p className="pb-10 italic text-gray-500">
|
||||
We'd love to sponsor more tournaments and groups. Have an idea? Ping{' '}
|
||||
<SiteLink
|
||||
className="font-semibold"
|
||||
href="https://discord.com/invite/eHQBNBqXuh"
|
||||
>
|
||||
Austin on Discord
|
||||
</SiteLink>
|
||||
!
|
||||
</p>
|
||||
</Col>
|
||||
</Page>
|
||||
)
|
||||
|
@ -175,9 +243,7 @@ const SectionHeader = (props: {
|
|||
return (
|
||||
<Link href={url}>
|
||||
<a className="group mb-3 flex flex-wrap justify-between">
|
||||
<h2 className="text-xl font-semibold group-hover:underline md:text-3xl">
|
||||
{title}
|
||||
</h2>
|
||||
<h2 className="text-xl group-hover:underline md:text-3xl">{title}</h2>
|
||||
<Row className="my-2 items-center gap-4 whitespace-nowrap rounded-full bg-gray-200 px-6">
|
||||
{!!award && <span className="flex items-center">🏆 {award}</span>}
|
||||
{!!ppl && (
|
||||
|
@ -237,7 +303,7 @@ const MarketCarousel = (props: { slug: string }) => {
|
|||
key={m.id}
|
||||
contract={m}
|
||||
hideGroupLink
|
||||
className="mb-2 max-h-[200px] w-96 shrink-0"
|
||||
className="mb-2 max-h-[200px] w-96 shrink-0 snap-start scroll-m-4 md:snap-align-none"
|
||||
questionClass="line-clamp-3"
|
||||
trackingPostfix=" tournament"
|
||||
/>
|
||||
|
|
172
web/posts/post-comments.tsx
Normal file
172
web/posts/post-comments.tsx
Normal file
|
@ -0,0 +1,172 @@
|
|||
import { track } from '@amplitude/analytics-browser'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import clsx from 'clsx'
|
||||
import { PostComment } from 'common/comment'
|
||||
import { Post } from 'common/post'
|
||||
import { User } from 'common/user'
|
||||
import { Dictionary } from 'lodash'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Avatar } from 'web/components/avatar'
|
||||
import { CommentInput } from 'web/components/comment-input'
|
||||
import { Content } from 'web/components/editor'
|
||||
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Tipper } from 'web/components/tipper'
|
||||
import { UserLink } from 'web/components/user-link'
|
||||
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { createCommentOnPost } from 'web/lib/firebase/comments'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
|
||||
export function PostCommentThread(props: {
|
||||
user: User | null | undefined
|
||||
post: Post
|
||||
threadComments: PostComment[]
|
||||
tips: CommentTipMap
|
||||
parentComment: PostComment
|
||||
commentsByUserId: Dictionary<PostComment[]>
|
||||
}) {
|
||||
const { post, threadComments, tips, parentComment } = props
|
||||
const [showReply, setShowReply] = useState(false)
|
||||
const [replyTo, setReplyTo] = useState<{ id: string; username: string }>()
|
||||
|
||||
function scrollAndOpenReplyInput(comment: PostComment) {
|
||||
setReplyTo({ id: comment.userId, username: comment.userUsername })
|
||||
setShowReply(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Col className="relative w-full items-stretch gap-3 pb-4">
|
||||
<span
|
||||
className="absolute top-5 left-4 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{[parentComment].concat(threadComments).map((comment, commentIdx) => (
|
||||
<PostComment
|
||||
key={comment.id}
|
||||
indent={commentIdx != 0}
|
||||
post={post}
|
||||
comment={comment}
|
||||
tips={tips[comment.id]}
|
||||
onReplyClick={scrollAndOpenReplyInput}
|
||||
/>
|
||||
))}
|
||||
{showReply && (
|
||||
<Col className="-pb-2 relative ml-6">
|
||||
<span
|
||||
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<PostCommentInput
|
||||
post={post}
|
||||
parentCommentId={parentComment.id}
|
||||
replyToUser={replyTo}
|
||||
onSubmitComment={() => setShowReply(false)}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
export function PostCommentInput(props: {
|
||||
post: Post
|
||||
parentCommentId?: string
|
||||
replyToUser?: { id: string; username: string }
|
||||
onSubmitComment?: () => void
|
||||
}) {
|
||||
const user = useUser()
|
||||
|
||||
const { post, parentCommentId, replyToUser } = props
|
||||
|
||||
async function onSubmitComment(editor: Editor) {
|
||||
if (!user) {
|
||||
track('sign in to comment')
|
||||
return await firebaseLogin()
|
||||
}
|
||||
await createCommentOnPost(post.id, editor.getJSON(), user, parentCommentId)
|
||||
props.onSubmitComment?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<CommentInput
|
||||
replyToUser={replyToUser}
|
||||
parentCommentId={parentCommentId}
|
||||
onSubmitComment={onSubmitComment}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PostComment(props: {
|
||||
post: Post
|
||||
comment: PostComment
|
||||
tips: CommentTips
|
||||
indent?: boolean
|
||||
probAtCreatedTime?: number
|
||||
onReplyClick?: (comment: PostComment) => void
|
||||
}) {
|
||||
const { post, comment, tips, indent, onReplyClick } = props
|
||||
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
|
||||
comment
|
||||
|
||||
const [highlighted, setHighlighted] = useState(false)
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
if (router.asPath.endsWith(`#${comment.id}`)) {
|
||||
setHighlighted(true)
|
||||
}
|
||||
}, [comment.id, router.asPath])
|
||||
|
||||
return (
|
||||
<Row
|
||||
id={comment.id}
|
||||
className={clsx(
|
||||
'relative',
|
||||
indent ? 'ml-6' : '',
|
||||
highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : ''
|
||||
)}
|
||||
>
|
||||
{/*draw a gray line from the comment to the left:*/}
|
||||
{indent ? (
|
||||
<span
|
||||
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<Avatar size="sm" username={userUsername} avatarUrl={userAvatarUrl} />
|
||||
<div className="ml-1.5 min-w-0 flex-1 pl-0.5 sm:ml-3">
|
||||
<div className="mt-0.5 text-sm text-gray-500">
|
||||
<UserLink
|
||||
className="text-gray-500"
|
||||
username={userUsername}
|
||||
name={userName}
|
||||
/>{' '}
|
||||
<CopyLinkDateTimeComponent
|
||||
prefix={comment.userName}
|
||||
slug={post.slug}
|
||||
createdTime={createdTime}
|
||||
elementId={comment.id}
|
||||
/>
|
||||
</div>
|
||||
<Content
|
||||
className="mt-2 text-[15px] text-gray-700"
|
||||
content={content || text}
|
||||
smallImage
|
||||
/>
|
||||
<Row className="mt-2 items-center gap-6 text-xs text-gray-500">
|
||||
<Tipper comment={comment} tips={tips ?? {}} />
|
||||
{onReplyClick && (
|
||||
<button
|
||||
className="font-bold hover:underline"
|
||||
onClick={() => onReplyClick(comment)}
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
</Row>
|
||||
</div>
|
||||
</Row>
|
||||
)
|
||||
}
|
|
@ -64,6 +64,8 @@ function putIntoMapAndFetch(data) {
|
|||
document.getElementById('guess-type').innerText = 'Finding Fantastic Beasts'
|
||||
} else if (whichGuesser === 'basic') {
|
||||
document.getElementById('guess-type').innerText = 'How Basic'
|
||||
} else if (whichGuesser === 'commander') {
|
||||
document.getElementById('guess-type').innerText = 'General Knowledge'
|
||||
}
|
||||
setUpNewGame()
|
||||
}
|
||||
|
@ -156,8 +158,8 @@ function determineIfSkip(card) {
|
|||
if (card.flavor_name) {
|
||||
return true
|
||||
}
|
||||
// don't include racist cards
|
||||
return card.content_warning
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function putIntoMap(data) {
|
||||
|
|
|
@ -3,16 +3,16 @@ import requests
|
|||
import json
|
||||
|
||||
# add category name here
|
||||
allCategories = ['counterspell', 'beast', 'burn'] #, 'terror', 'wrath']
|
||||
allCategories = ['counterspell', 'beast', 'burn', 'commander', 'artist'] #, 'terror', 'wrath', 'zombie', 'artifact']
|
||||
specialCategories = ['set', 'basic']
|
||||
|
||||
|
||||
def generate_initial_query(category):
|
||||
string_query = 'https://api.scryfall.com/cards/search?q='
|
||||
if category == 'counterspell':
|
||||
string_query += 'otag%3Acounterspell+t%3Ainstant+not%3Aadventure'
|
||||
string_query += 'otag%3Acounterspell+t%3Ainstant+not%3Aadventure+not%3Adfc'
|
||||
elif category == 'beast':
|
||||
string_query += '-type%3Alegendary+type%3Abeast+-type%3Atoken'
|
||||
string_query += '-type%3Alegendary+type%3Abeast+-type%3Atoken+not%3Adfc'
|
||||
# elif category == 'terror':
|
||||
# string_query += 'otag%3Acreature-removal+o%3A%2Fdestroy+target.%2A+%28creature%7Cpermanent%29%2F+%28t' \
|
||||
# '%3Ainstant+or+t%3Asorcery%29+o%3Atarget+not%3Aadventure'
|
||||
|
@ -22,11 +22,19 @@ def generate_initial_query(category):
|
|||
string_query += '%28c>%3Dr+or+mana>%3Dr%29+%28o%3A%2Fdamage+to+them%2F+or+%28o%3Adeals+o%3Adamage+o%3A' \
|
||||
'%2Fcontroller%28%5C.%7C+%29%2F%29+or+o%3A%2F~+deals+%28.%7C..%29+damage+to+%28any+target%7C' \
|
||||
'.*player%28%5C.%7C+or+planeswalker%29%7C.*opponent%28%5C.%7C+or+planeswalker%29%29%2F%29' \
|
||||
'+%28type%3Ainstant+or+type%3Asorcery%29+not%3Aadventure'
|
||||
'+%28type%3Ainstant+or+type%3Asorcery%29+not%3Aadventure+not%3Adfc'
|
||||
elif category == 'commander':
|
||||
string_query += 'is%3Acommander+%28not%3Adigital+-banned%3Acommander+or+is%3Adigital+legal%3Ahistoricbrawl+or+legal%3Acommander+or+legal%3Abrawl%29'
|
||||
# elif category == 'zombie':
|
||||
# string_query += '-type%3Alegendary+type%3Azombie+-type%3Atoken'
|
||||
# elif category == 'artifact':
|
||||
# string_query += 't%3Aartifact&order=released&dir=asc&unique=prints&page='
|
||||
# elif category == 'artist':
|
||||
# string_query+= 'a%3A"Wylie+Beckert"+or+a%3A“Ernanda+Souza”+or+a%3A"randy+gallegos"+or+a%3A“Amy+Weber”+or+a%3A“Dan+Frazier”+or+a%3A“Thomas+M.+Baxa”+or+a%3A“Phil+Foglio”+or+a%3A“DiTerlizzi”+or+a%3A"steve+argyle"+or+a%3A"Veronique+Meignaud"+or+a%3A"Magali+Villeneuve"+or+a%3A"Michael+Sutfin"+or+a%3A“Volkan+Baǵa”+or+a%3A“Franz+Vohwinkel”+or+a%3A"Nils+Hamm"+or+a%3A"Mark+Poole"+or+a%3A"Carl+Critchlow"+or+a%3A"rob+alexander"+or+a%3A"igor+kieryluk"+or+a%3A“Victor+Adame+Minguez”+or+a%3A"johannes+voss"+or+a%3A"Svetlin+Velinov"+or+a%3A"ron+spencer"+or+a%3A"rk+post"+or+a%3A"kev+walker"+or+a%3A"rebecca+guay"+or+a%3A"seb+mckinnon"+or+a%3A"pete+venters"+or+a%3A"greg+staples"+or+a%3A"Christopher+Moeller"+or+a%3A"christopher+rush"+or+a%3A"Mark+Tedin"'
|
||||
# add category string query here
|
||||
string_query += '+-%28set%3Asld+%28%28cn>%3D231+cn<%3D233%29+or+%28cn>%3D321+cn<%3D324%29+or+%28cn>%3D185+cn' \
|
||||
'<%3D189%29+or+%28cn>%3D138+cn<%3D142%29+or+%28cn>%3D364+cn<%3D368%29+or+cn%3A669+or+cn%3A670%29' \
|
||||
'%29+-name%3A%2F%5EA-%2F+not%3Adfc+not%3Asplit+-set%3Acmb2+-set%3Acmb1+-set%3Aplist+-set%3Adbl' \
|
||||
'%29+-name%3A%2F%5EA-%2F+not%3Asplit+-set%3Acmb2+-set%3Acmb1+-set%3Aplist+-st%3Amemorabilia' \
|
||||
'+language%3Aenglish&order=released&dir=asc&unique=prints&page='
|
||||
print(string_query)
|
||||
return string_query
|
||||
|
@ -89,22 +97,35 @@ def fetch_special(query):
|
|||
|
||||
|
||||
def to_compact_write_form(smallJson, art_names, response, category):
|
||||
fieldsInCard = ['name', 'image_uris', 'content_warning', 'flavor_name', 'reprint', 'frame_effects', 'digital',
|
||||
'set_type']
|
||||
fieldsInCard = ['name', 'image_uris', 'flavor_name', 'reprint', 'frame_effects', 'digital', 'set_type']
|
||||
data = []
|
||||
# write all fields needed in card
|
||||
for card in response['data']:
|
||||
# do not include racist cards
|
||||
if 'content_warning' in card and card['content_warning'] == True:
|
||||
continue
|
||||
# do not repeat art
|
||||
if 'illustration_id' not in card or card['illustration_id'] in art_names:
|
||||
if 'card_faces' in card:
|
||||
card_face = card['card_faces'][0]
|
||||
if 'illustration_id' not in card_face or card_face['illustration_id'] in art_names:
|
||||
continue
|
||||
else:
|
||||
art_names.add(card_face['illustration_id'])
|
||||
elif 'illustration_id' not in card or card['illustration_id'] in art_names:
|
||||
continue
|
||||
else:
|
||||
art_names.add(card['illustration_id'])
|
||||
write_card = dict()
|
||||
for field in fieldsInCard:
|
||||
# if field == 'name' and category == 'artifact':
|
||||
# write_card['name'] = card['released_at'].split('-')[0]
|
||||
if field == 'name' and 'card_faces' in card:
|
||||
write_card['name'] = card['card_faces'][0]['name']
|
||||
elif field == 'image_uris':
|
||||
write_card['image_uris'] = write_image_uris(card['image_uris'])
|
||||
if 'card_faces' in card and 'image_uris' in card['card_faces'][0]:
|
||||
write_card['image_uris'] = write_image_uris(card['card_faces'][0]['image_uris'])
|
||||
else:
|
||||
write_card['image_uris'] = write_image_uris(card['image_uris'])
|
||||
elif field in card:
|
||||
write_card[field] = card[field]
|
||||
data.append(write_card)
|
||||
|
@ -115,6 +136,9 @@ def to_compact_write_form_special(smallJson, art_names, response, category):
|
|||
data = []
|
||||
# write all fields needed in card
|
||||
for card in response['data']:
|
||||
# do not include racist cards
|
||||
if 'content_warning' in card and card['content_warning'] == True:
|
||||
continue
|
||||
if category == 'basic':
|
||||
write_card = dict()
|
||||
# do not repeat art
|
||||
|
@ -152,9 +176,9 @@ def write_image_uris(card_image_uris):
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# for category in allCategories:
|
||||
# print(category)
|
||||
# fetch_and_write_all(category, generate_initial_query(category))
|
||||
for category in allCategories:
|
||||
print(category)
|
||||
fetch_and_write_all(category, generate_initial_query(category))
|
||||
for category in specialCategories:
|
||||
print(category)
|
||||
fetch_and_write_all_special(category, generate_initial_special_query(category))
|
||||
|
|
|
@ -17,6 +17,14 @@
|
|||
f.parentNode.insertBefore(j, f)
|
||||
})(window, document, 'script', 'dataLayer', 'GTM-M3MBVGG')
|
||||
</script>
|
||||
<script>
|
||||
function updateSettingDefault(digital, un, original) {
|
||||
window.console.log(digital, un, original)
|
||||
document.getElementById('digital').checked = digital
|
||||
document.getElementById('un').checked = un
|
||||
document.getElementById('original').checked = original
|
||||
}
|
||||
</script>
|
||||
<!-- End Google Tag Manager -->
|
||||
<meta charset="UTF-8" />
|
||||
<style type="text/css">
|
||||
|
@ -105,6 +113,18 @@
|
|||
list-style: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.option-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding-left: 65px;
|
||||
}
|
||||
.level-badge {
|
||||
display: block;
|
||||
width: 65px;
|
||||
padding-left: 8px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
@ -125,48 +145,115 @@
|
|||
action="guess.html"
|
||||
style="display: flex; flex-direction: column; align-items: center"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id="counterspell"
|
||||
name="whichguesser"
|
||||
value="counterspell"
|
||||
checked
|
||||
/>
|
||||
<label class="radio-label" for="counterspell">
|
||||
<img
|
||||
class="thumbnail"
|
||||
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/1/71cfcba5-1571-48b8-a3db-55dca135506e.jpg?1562843855"
|
||||
<div class="option-row">
|
||||
<input
|
||||
type="radio"
|
||||
id="counterspell"
|
||||
name="whichguesser"
|
||||
value="counterspell"
|
||||
onchange="updateSettingDefault(true, true, false)"
|
||||
checked
|
||||
/>
|
||||
<h3>Counterspell Guesser</h3></label
|
||||
><br />
|
||||
|
||||
<input type="radio" id="burn" name="whichguesser" value="burn" />
|
||||
<label class="radio-label" for="burn">
|
||||
<label class="radio-label" for="counterspell">
|
||||
<img
|
||||
class="thumbnail"
|
||||
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/1/71cfcba5-1571-48b8-a3db-55dca135506e.jpg?1562843855"
|
||||
/>
|
||||
<h3>Counterspell Guesser</h3></label
|
||||
>
|
||||
<img
|
||||
class="thumbnail"
|
||||
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/0/60b2fae1-242b-45e0-a757-b1adc02c06f3.jpg?1562760596"
|
||||
class="level-badge"
|
||||
src="https://static.wikia.nocookie.net/mtgsalvation_gamepedia/images/a/a8/Advanced_level.jpg"
|
||||
/>
|
||||
<h3>Match With Hot Singles</h3></label
|
||||
><br />
|
||||
|
||||
<input type="radio" id="beast" name="whichguesser" value="beast" />
|
||||
<label class="radio-label" for="beast">
|
||||
<img
|
||||
class="thumbnail"
|
||||
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/3/33f7e788-8fc7-49f3-804b-2d7f96852d4b.jpg?1562905469"
|
||||
/>
|
||||
<h3>Finding Fantastic Beasts</h3></label
|
||||
>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<input type="radio" id="basic" name="whichguesser" value="basic" />
|
||||
<label class="radio-label" for="basic">
|
||||
<img
|
||||
class="thumbnail"
|
||||
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/3/03683fbb-9843-4c14-bb95-387150e97c90.jpg?1642161346"
|
||||
<div class="option-row">
|
||||
<input
|
||||
type="radio"
|
||||
id="burn"
|
||||
name="whichguesser"
|
||||
value="burn"
|
||||
onchange="updateSettingDefault(true, true, false)"
|
||||
/>
|
||||
<h3>How Basic</h3></label
|
||||
>
|
||||
<label class="radio-label" for="burn">
|
||||
<img
|
||||
class="thumbnail"
|
||||
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/0/60b2fae1-242b-45e0-a757-b1adc02c06f3.jpg?1562760596"
|
||||
/>
|
||||
<h3>Match With Hot Singles</h3></label
|
||||
>
|
||||
<img
|
||||
class="level-badge"
|
||||
src="https://static.wikia.nocookie.net/mtgsalvation_gamepedia/images/a/a8/Advanced_level.jpg"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<div class="option-row">
|
||||
<input
|
||||
type="radio"
|
||||
id="beast"
|
||||
name="whichguesser"
|
||||
value="beast"
|
||||
onchange="updateSettingDefault(true, true, false)"
|
||||
/>
|
||||
<label class="radio-label" for="beast">
|
||||
<img
|
||||
class="thumbnail"
|
||||
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/3/33f7e788-8fc7-49f3-804b-2d7f96852d4b.jpg?1562905469"
|
||||
/>
|
||||
<h3>Finding Fantastic Beasts</h3></label
|
||||
>
|
||||
<img
|
||||
class="level-badge"
|
||||
src="https://static.wikia.nocookie.net/mtgsalvation_gamepedia/images/a/a8/Advanced_level.jpg"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<div class="option-row">
|
||||
<input
|
||||
type="radio"
|
||||
id="basic"
|
||||
name="whichguesser"
|
||||
value="basic"
|
||||
onchange="updateSettingDefault(true, true, true)"
|
||||
/>
|
||||
<label class="radio-label" for="basic">
|
||||
<img
|
||||
class="thumbnail"
|
||||
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/5/e52ed647-bd30-40a5-b648-0b98d1a3fd4a.jpg?1562949575"
|
||||
/>
|
||||
<h3>How Basic</h3></label
|
||||
>
|
||||
<img
|
||||
class="level-badge"
|
||||
src="https://static.wikia.nocookie.net/mtgsalvation_gamepedia/images/a/af/Expert_level.jpg"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<div class="option-row">
|
||||
<input
|
||||
type="radio"
|
||||
id="commander"
|
||||
name="whichguesser"
|
||||
value="commander"
|
||||
onchange="updateSettingDefault(false, false, false)"
|
||||
/>
|
||||
<label class="radio-label" for="commander">
|
||||
<img
|
||||
class="thumbnail"
|
||||
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/9/d9631cb2-d53b-4401-b53b-29d27bdefc44.jpg?1562770627"
|
||||
/>
|
||||
<h3>General Knowledge</h3></label
|
||||
>
|
||||
<img
|
||||
class="level-badge"
|
||||
src="https://static.wikia.nocookie.net/mtgsalvation_gamepedia/images/0/00/Starter_level.jpg"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<details id="addl-options">
|
||||
|
|
1
web/public/mtg/jsons/artist.json
Normal file
1
web/public/mtg/jsons/artist.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
web/public/mtg/jsons/commander.json
Normal file
1
web/public/mtg/jsons/commander.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -60,6 +60,18 @@ module.exports = {
|
|||
'overflow-wrap': 'anywhere',
|
||||
'word-break': 'break-word', // for Safari
|
||||
},
|
||||
'.only-thumb': {
|
||||
'pointer-events': 'none',
|
||||
'&::-webkit-slider-thumb': {
|
||||
'pointer-events': 'auto !important',
|
||||
},
|
||||
'&::-moz-range-thumb': {
|
||||
'pointer-events': 'auto !important',
|
||||
},
|
||||
'&::-ms-thumb': {
|
||||
'pointer-events': 'auto !important',
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
],
|
||||
|
|
Loading…
Reference in New Issue
Block a user