Merge remote-tracking branch 'origin/main' into fix-large-groups

This commit is contained in:
Pico2x 2022-09-12 12:14:38 +01:00
commit 82772cb733
87 changed files with 1685 additions and 933 deletions

View File

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

View File

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

View File

@ -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',

View File

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

View File

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

View File

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

View File

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

View File

@ -34,7 +34,7 @@ export type User = {
followerCountCached: number
followedCategories?: string[]
homeSections?: { visible: string[]; hidden: string[] }
homeSections?: string[]
referredByUserId?: string
referredByContractId?: string

View File

@ -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.
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.
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.
Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/markets`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
</>
)
}

View File

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

View File

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

View File

@ -294,7 +294,7 @@ export function ExtraMobileContractDetails(props: {
<Tooltip
text={`${formatMoney(
volume
)} bet - ${uniqueBettors} unique bettors`}
)} bet - ${uniqueBettors} unique traders`}
>
{volumeTranslation}
</Tooltip>

View File

@ -135,7 +135,7 @@ export function ContractInfoDialog(props: {
</tr> */}
<tr>
<td>Bettors</td>
<td>Traders</td>
<td>{bettorsCount}</td>
</tr>

View File

@ -49,7 +49,7 @@ export function ContractLeaderboard(props: {
return users && users.length > 0 ? (
<Leaderboard
title="🏅 Top bettors"
title="🏅 Top traders"
users={users || []}
columns={[
{

View File

@ -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 ? (

View File

@ -114,6 +114,7 @@ export function CreatorContractsList(props: {
additionalFilter={{
creatorId: creator.id,
}}
persistPrefix={`user-${creator.id}`}
/>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -203,7 +203,7 @@ function NumericBuyPanel(props: {
)}
onClick={betDisabled ? undefined : submitBet}
>
{isSubmitting ? 'Submitting...' : 'Submit bet'}
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
)}

View File

@ -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!"
/>
)

View File

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

View File

@ -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}. */}

View File

@ -19,7 +19,7 @@ export function BetSignUpPrompt(props: {
size={size}
color="gradient"
>
{label ?? 'Sign up to bet!'}
{label ?? 'Sign up to predict!'}
</Button>
) : null
}

View File

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

View File

@ -260,7 +260,7 @@ export function UserPage(props: { user: User }) {
),
},
{
title: 'Bets',
title: 'Trades',
content: (
<>
<BetsList user={user} />

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

View File

@ -193,7 +193,7 @@ export function BuyButton(props: { className?: string; onClick?: () => void }) {
)}
onClick={onClick}
>
Bet
Buy
</button>
)
}

View File

@ -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), [])

View File

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

View File

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

View File

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

View File

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

View File

@ -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) => {

View File

@ -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'],

View File

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

View File

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

View File

@ -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[]>([])

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

View File

@ -12,3 +12,7 @@ export function isIOS() {
(navigator.userAgent.includes('Mac') && 'ontouchend' in document)
)
}
export function isAndroid() {
return navigator.userAgent.includes('Android')
}

View File

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

View File

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

View File

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

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

View File

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

View File

@ -231,6 +231,7 @@ export default function GroupPage(props: {
defaultSort={'newest'}
defaultFilter={suggestedFilter}
additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`}
/>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -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',
},
},
})
}),
],