Merge branch 'main' into inga/manalink-table-replacement

This commit is contained in:
ingawei 2022-07-20 18:58:04 -07:00 committed by GitHub
commit 976b294869
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 2007 additions and 652 deletions

View File

@ -1,6 +1,7 @@
import { difference } from 'lodash' import { difference } from 'lodash'
export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default' export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default'
export const CATEGORIES = { export const CATEGORIES = {
politics: 'Politics', politics: 'Politics',
technology: 'Technology', technology: 'Technology',
@ -37,3 +38,8 @@ export const EXCLUDED_CATEGORIES: category[] = [
] ]
export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES) export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES)
export const DEFAULT_CATEGORY_GROUPS = DEFAULT_CATEGORIES.map((c) => ({
slug: c.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX,
name: CATEGORIES[c as category],
}))

View File

@ -48,6 +48,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
groupSlugs?: string[] groupSlugs?: string[]
uniqueBettorIds?: string[] uniqueBettorIds?: string[]
uniqueBettorCount?: number uniqueBettorCount?: number
popularityScore?: number
} & T } & T
export type BinaryContract = Contract & Binary export type BinaryContract = Contract & Binary

View File

@ -22,6 +22,7 @@ export * from './on-update-user'
export * from './on-create-comment-on-group' export * from './on-create-comment-on-group'
export * from './on-create-txn' export * from './on-create-txn'
export * from './on-delete-group' export * from './on-delete-group'
export * from './score-contracts'
// v2 // v2
export * from './health' export * from './health'

View File

@ -0,0 +1,54 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Bet } from 'common/bet'
import { uniq } from 'lodash'
import { Contract } from 'common/contract'
import { log } from './utils'
export const scoreContracts = functions.pubsub
.schedule('every 1 hours')
.onRun(async () => {
await scoreContractsInternal()
})
const firestore = admin.firestore()
async function scoreContractsInternal() {
const now = Date.now()
const lastHour = now - 60 * 60 * 1000
const last3Days = now - 1000 * 60 * 60 * 24 * 3
const activeContractsSnap = await firestore
.collection('contracts')
.where('lastUpdatedTime', '>', lastHour)
.get()
const activeContracts = activeContractsSnap.docs.map(
(doc) => doc.data() as Contract
)
// We have to downgrade previously active contracts to allow the new ones to bubble up
const previouslyActiveContractsSnap = await firestore
.collection('contracts')
.where('popularityScore', '>', 0)
.get()
const activeContractIds = activeContracts.map((c) => c.id)
const previouslyActiveContracts = previouslyActiveContractsSnap.docs
.map((doc) => doc.data() as Contract)
.filter((c) => !activeContractIds.includes(c.id))
const contracts = activeContracts.concat(previouslyActiveContracts)
log(`Found ${contracts.length} contracts to score`)
for (const contract of contracts) {
const bets = await firestore
.collection(`contracts/${contract.id}/bets`)
.where('createdTime', '>', last3Days)
.get()
const bettors = bets.docs
.map((doc) => doc.data() as Bet)
.map((bet) => bet.userId)
const score = uniq(bettors).length
if (contract.popularityScore !== score)
await firestore
.collection('contracts')
.doc(contract.id)
.update({ popularityScore: score })
}
}

View File

@ -1,3 +1,4 @@
# Ignore Next artifacts # Ignore Next artifacts
.next/ .next/
out/ out/
public/**/*.json

View File

@ -0,0 +1,210 @@
import { useUser } from 'web/hooks/use-user'
import React, { useEffect, useState } from 'react'
import { notification_subscribe_types, PrivateUser } from 'common/user'
import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users'
import toast from 'react-hot-toast'
import { track } from '@amplitude/analytics-browser'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { CheckIcon, XIcon } from '@heroicons/react/outline'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
export function NotificationSettings() {
const user = useUser()
const [notificationSettings, setNotificationSettings] =
useState<notification_subscribe_types>('all')
const [emailNotificationSettings, setEmailNotificationSettings] =
useState<notification_subscribe_types>('all')
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
useEffect(() => {
if (user) listenForPrivateUser(user.id, setPrivateUser)
}, [user])
useEffect(() => {
if (!privateUser) return
if (privateUser.notificationPreferences) {
setNotificationSettings(privateUser.notificationPreferences)
}
if (
privateUser.unsubscribedFromResolutionEmails &&
privateUser.unsubscribedFromCommentEmails &&
privateUser.unsubscribedFromAnswerEmails
) {
setEmailNotificationSettings('none')
} else if (
!privateUser.unsubscribedFromResolutionEmails &&
!privateUser.unsubscribedFromCommentEmails &&
!privateUser.unsubscribedFromAnswerEmails
) {
setEmailNotificationSettings('all')
} else {
setEmailNotificationSettings('less')
}
}, [privateUser])
const loading = 'Changing Notifications Settings'
const success = 'Notification Settings Changed!'
function changeEmailNotifications(newValue: notification_subscribe_types) {
if (!privateUser) return
if (newValue === 'all') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: false,
unsubscribedFromCommentEmails: false,
unsubscribedFromAnswerEmails: false,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
} else if (newValue === 'less') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: false,
unsubscribedFromCommentEmails: true,
unsubscribedFromAnswerEmails: true,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
} else if (newValue === 'none') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: true,
unsubscribedFromCommentEmails: true,
unsubscribedFromAnswerEmails: true,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
}
}
function changeInAppNotificationSettings(
newValue: notification_subscribe_types
) {
if (!privateUser) return
track('In-App Notification Preferences Changed', {
newPreference: newValue,
oldPreference: privateUser.notificationPreferences,
})
toast.promise(
updatePrivateUser(privateUser.id, {
notificationPreferences: newValue,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
}
useEffect(() => {
if (privateUser && privateUser.notificationPreferences)
setNotificationSettings(privateUser.notificationPreferences)
else setNotificationSettings('all')
}, [privateUser])
if (!privateUser) {
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
}
function NotificationSettingLine(props: {
label: string
highlight: boolean
}) {
const { label, highlight } = props
return (
<Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}>
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
{label}
</Row>
)
}
return (
<div className={'p-2'}>
<div>In App Notifications</div>
<ChoicesToggleGroup
currentChoice={notificationSettings}
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
setChoice={(choice) =>
changeInAppNotificationSettings(
choice as notification_subscribe_types
)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
<div className={'mt-4 text-sm'}>
<div>
<div className={''}>
You will receive notifications for:
<NotificationSettingLine
label={"Resolution of questions you've interacted with"}
highlight={notificationSettings !== 'none'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={'Activity on your own questions, comments, & answers'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Activity on questions you're betting on"}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Income & referral bonuses you've received"}
/>
<NotificationSettingLine
label={"Activity on questions you've ever bet or commented on"}
highlight={notificationSettings === 'all'}
/>
</div>
</div>
</div>
<div className={'mt-4'}>Email Notifications</div>
<ChoicesToggleGroup
currentChoice={emailNotificationSettings}
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
setChoice={(choice) =>
changeEmailNotifications(choice as notification_subscribe_types)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
<div className={'mt-4 text-sm'}>
<div>
You will receive emails for:
<NotificationSettingLine
label={"Resolution of questions you're betting on"}
highlight={emailNotificationSettings !== 'none'}
/>
<NotificationSettingLine
label={'Closure of your questions'}
highlight={emailNotificationSettings !== 'none'}
/>
<NotificationSettingLine
label={'Activity on your questions'}
highlight={emailNotificationSettings === 'all'}
/>
<NotificationSettingLine
label={"Activity on questions you've answered or commented on"}
highlight={emailNotificationSettings === 'all'}
/>
</div>
</div>
</div>
)
}

View File

@ -50,7 +50,7 @@ import { LimitOrderTable } from './limit-bets'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
const CONTRACTS_PER_PAGE = 20 const CONTRACTS_PER_PAGE = 50
export function BetsList(props: { export function BetsList(props: {
user: User user: User

View File

@ -13,7 +13,7 @@ export function PillButton(props: {
return ( return (
<button <button
className={clsx( className={clsx(
'cursor-pointer select-none rounded-full', 'cursor-pointer select-none whitespace-nowrap rounded-full',
selected selected
? ['text-white', color ?? 'bg-gray-700'] ? ['text-white', color ?? 'bg-gray-700']
: 'bg-gray-100 hover:bg-gray-200', : 'bg-gray-100 hover:bg-gray-200',

View File

@ -25,9 +25,10 @@ import { useFollows } from 'web/hooks/use-follows'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
import ContractSearchFirestore from 'web/pages/contract-search-firestore' import ContractSearchFirestore from 'web/pages/contract-search-firestore'
import { useMemberGroups } from 'web/hooks/use-group' import { useMemberGroups } from 'web/hooks/use-group'
import { NEW_USER_GROUP_SLUGS } from 'common/group' import { Group, NEW_USER_GROUP_SLUGS } from 'common/group'
import { PillButton } from './buttons/pill-button' import { PillButton } from './buttons/pill-button'
import { toPairs } from 'lodash' import { sortBy } from 'lodash'
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
const searchClient = algoliasearch( const searchClient = algoliasearch(
'GJQPAYENIF', 'GJQPAYENIF',
@ -39,22 +40,16 @@ const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
const sortIndexes = [ const sortIndexes = [
{ label: 'Newest', value: indexPrefix + 'contracts-newest' }, { label: 'Newest', value: indexPrefix + 'contracts-newest' },
{ label: 'Oldest', value: indexPrefix + 'contracts-oldest' }, { label: 'Oldest', value: indexPrefix + 'contracts-oldest' },
{ label: 'Most popular', value: indexPrefix + 'contracts-most-popular' }, { label: 'Most popular', value: indexPrefix + 'contracts-score' },
{ label: 'Most traded', value: indexPrefix + 'contracts-most-traded' }, { label: 'Most traded', value: indexPrefix + 'contracts-most-traded' },
{ label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' }, { label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' },
{ label: 'Last updated', value: indexPrefix + 'contracts-last-updated' }, { label: 'Last updated', value: indexPrefix + 'contracts-last-updated' },
{ label: 'Close date', value: indexPrefix + 'contracts-close-date' }, { label: 'Close date', value: indexPrefix + 'contracts-close-date' },
{ label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' }, { label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' },
] ]
export const DEFAULT_SORT = 'score'
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
const filterOptions: { [label: string]: filter } = {
All: 'all',
Open: 'open',
Closed: 'closed',
Resolved: 'resolved',
'For you': 'personal',
}
export function ContractSearch(props: { export function ContractSearch(props: {
querySortOptions?: { querySortOptions?: {
@ -85,9 +80,24 @@ export function ContractSearch(props: {
} = props } = props
const user = useUser() const user = useUser()
const memberGroupSlugs = useMemberGroups(user?.id) const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
?.map((g) => g.slug) (group) => !NEW_USER_GROUP_SLUGS.includes(group.slug)
.filter((s) => !NEW_USER_GROUP_SLUGS.includes(s)) )
const memberGroupSlugs =
memberGroups.length > 0
? memberGroups.map((g) => g.slug)
: DEFAULT_CATEGORY_GROUPS.map((g) => g.slug)
const memberPillGroups = sortBy(
memberGroups.filter((group) => group.contractIds.length > 0),
(group) => group.contractIds.length
).reverse()
const defaultPillGroups = DEFAULT_CATEGORY_GROUPS as Group[]
const pillGroups =
memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups
const follows = useFollows(user?.id) const follows = useFollows(user?.id)
const { initialSort } = useInitialQueryAndSort(querySortOptions) const { initialSort } = useInitialQueryAndSort(querySortOptions)
@ -95,29 +105,19 @@ export function ContractSearch(props: {
.map(({ value }) => value) .map(({ value }) => value)
.includes(`${indexPrefix}contracts-${initialSort ?? ''}`) .includes(`${indexPrefix}contracts-${initialSort ?? ''}`)
? initialSort ? initialSort
: querySortOptions?.defaultSort ?? 'most-popular' : querySortOptions?.defaultSort ?? DEFAULT_SORT
const [filter, setFilter] = useState<filter>( const [filter, setFilter] = useState<filter>(
querySortOptions?.defaultFilter ?? 'open' querySortOptions?.defaultFilter ?? 'open'
) )
const pillsEnabled = !additionalFilter
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined)
const { filters, numericFilters } = useMemo(() => { const { filters, numericFilters } = useMemo(() => {
let filters = [ let filters = [
filter === 'open' ? 'isResolved:false' : '', filter === 'open' ? 'isResolved:false' : '',
filter === 'closed' ? 'isResolved:false' : '', filter === 'closed' ? 'isResolved:false' : '',
filter === 'resolved' ? 'isResolved:true' : '', filter === 'resolved' ? 'isResolved:true' : '',
filter === 'personal'
? // Show contracts in groups that the user is a member of
(memberGroupSlugs?.map((slug) => `groupSlugs:${slug}`) ?? [])
// Show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
// Show contracts bet on by users the user follows
.concat(
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
// Show contracts bet on by the user
)
.concat(user ? `uniqueBettorIds:${user.id}` : [])
: '',
additionalFilter?.creatorId additionalFilter?.creatorId
? `creatorId:${additionalFilter.creatorId}` ? `creatorId:${additionalFilter.creatorId}`
: '', : '',
@ -125,6 +125,26 @@ export function ContractSearch(props: {
additionalFilter?.groupSlug additionalFilter?.groupSlug
? `groupSlugs:${additionalFilter.groupSlug}` ? `groupSlugs:${additionalFilter.groupSlug}`
: '', : '',
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets'
? `groupSlugs:${pillFilter}`
: '',
pillFilter === 'personal'
? // Show contracts in groups that the user is a member of
memberGroupSlugs
.map((slug) => `groupSlugs:${slug}`)
// Show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
// Show contracts bet on by users the user follows
.concat(
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
)
: '',
// Subtract contracts you bet on from For you.
pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '',
pillFilter === 'your-bets' && user
? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}`
: '',
].filter((f) => f) ].filter((f) => f)
// Hack to make Algolia work. // Hack to make Algolia work.
filters = ['', ...filters] filters = ['', ...filters]
@ -138,8 +158,9 @@ export function ContractSearch(props: {
}, [ }, [
filter, filter,
Object.values(additionalFilter ?? {}).join(','), Object.values(additionalFilter ?? {}).join(','),
(memberGroupSlugs ?? []).join(','), memberGroupSlugs.join(','),
(follows ?? []).join(','), (follows ?? []).join(','),
pillFilter,
]) ])
const indexName = `${indexPrefix}contracts-${sort}` const indexName = `${indexPrefix}contracts-${sort}`
@ -166,6 +187,17 @@ export function ContractSearch(props: {
}} }}
/> />
{/*// TODO track WHICH filter users are using*/} {/*// TODO track WHICH filter users are using*/}
<select
className="!select !select-bordered"
value={filter}
onChange={(e) => setFilter(e.target.value as filter)}
onBlur={trackCallback('select search filter')}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
{!hideOrderSelector && ( {!hideOrderSelector && (
<SortBy <SortBy
items={sortIndexes} items={sortIndexes}
@ -185,25 +217,50 @@ export function ContractSearch(props: {
<Spacer h={3} /> <Spacer h={3} />
<Row className="gap-2"> {pillsEnabled && (
{toPairs<filter>(filterOptions).map(([label, f]) => { <Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
return ( <PillButton
<PillButton key={'all'}
key={f} selected={pillFilter === undefined}
selected={filter === f} onSelect={() => setPillFilter(undefined)}
onSelect={() => setFilter(f)} >
> All
{label} </PillButton>
</PillButton> <PillButton
) key={'personal'}
})} selected={pillFilter === 'personal'}
</Row> onSelect={() => setPillFilter('personal')}
>
For you
</PillButton>
<PillButton
key={'your-bets'}
selected={pillFilter === 'your-bets'}
onSelect={() => setPillFilter('your-bets')}
>
Your bets
</PillButton>
{pillGroups.map(({ name, slug }) => {
return (
<PillButton
key={slug}
selected={pillFilter === slug}
onSelect={() => setPillFilter(slug)}
>
{name}
</PillButton>
)
})}
</Row>
)}
<Spacer h={3} /> <Spacer h={3} />
{filter === 'personal' && {filter === 'personal' &&
(follows ?? []).length === 0 && (follows ?? []).length === 0 &&
(memberGroupSlugs ?? []).length === 0 ? ( memberGroupSlugs.length === 0 ? (
<>You're not following anyone, nor in any of your own groups yet.</> <>You're not following anyone, nor in any of your own groups yet.</>
) : ( ) : (
<ContractSearchInner <ContractSearchInner

View File

@ -16,7 +16,6 @@ import { ShareEmbedButton } from '../share-embed-button'
import { Title } from '../title' import { Title } from '../title'
import { TweetButton } from '../tweet-button' import { TweetButton } from '../tweet-button'
import { InfoTooltip } from '../info-tooltip' import { InfoTooltip } from '../info-tooltip'
import { TagsInput } from 'web/components/tags-input'
import { DuplicateContractButton } from '../copy-contract-button' import { DuplicateContractButton } from '../copy-contract-button'
export const contractDetailsButtonClassName = export const contractDetailsButtonClassName =
@ -141,9 +140,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
</tbody> </tbody>
</table> </table>
<div>Tags</div>
<TagsInput contract={contract} />
<div />
{contract.mechanism === 'cpmm-1' && !contract.resolution && ( {contract.mechanism === 'cpmm-1' && !contract.resolution && (
<LiquidityPanel contract={contract} /> <LiquidityPanel contract={contract} />
)} )}

View File

@ -150,7 +150,8 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
enableSlices="x" enableSlices="x"
enableGridX={!!width && width >= 800} enableGridX={!!width && width >= 800}
enableArea enableArea
margin={{ top: 20, right: 20, bottom: 25, left: 40 }} areaBaselineValue={isBinary || isLogScale ? 0 : contract.min}
margin={{ top: 20, right: 20, bottom: 65, left: 40 }}
animate={false} animate={false}
sliceTooltip={SliceTooltip} sliceTooltip={SliceTooltip}
/> />

View File

@ -3,58 +3,63 @@ import { LinkIcon } from '@heroicons/react/outline'
import { Menu, Transition } from '@headlessui/react' import { Menu, Transition } from '@headlessui/react'
import clsx from 'clsx' import clsx from 'clsx'
import { Contract } from 'common/contract'
import { copyToClipboard } from 'web/lib/util/copy' import { copyToClipboard } from 'web/lib/util/copy'
import { contractPath } from 'web/lib/firebase/contracts'
import { ENV_CONFIG } from 'common/envs/constants'
import { ToastClipboard } from 'web/components/toast-clipboard' import { ToastClipboard } from 'web/components/toast-clipboard'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { Row } from './layout/row'
function copyContractUrl(contract: Contract) {
copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`)
}
export function CopyLinkButton(props: { export function CopyLinkButton(props: {
contract: Contract url: string
displayUrl?: string
tracking?: string
buttonClassName?: string buttonClassName?: string
toastClassName?: string toastClassName?: string
}) { }) {
const { contract, buttonClassName, toastClassName } = props const { url, displayUrl, tracking, buttonClassName, toastClassName } = props
return ( return (
<Menu <Row className="w-full">
as="div" <input
className="relative z-10 flex-shrink-0" className="input input-bordered flex-1 rounded-r-none text-gray-500"
onMouseUp={() => { readOnly
copyContractUrl(contract) type="text"
track('copy share link') value={displayUrl ?? url}
}} />
>
<Menu.Button
className={clsx(
'btn btn-xs border-2 border-green-600 bg-white normal-case text-green-600 hover:border-green-600 hover:bg-white',
buttonClassName
)}
>
<LinkIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
Copy link
</Menu.Button>
<Transition <Menu
as={Fragment} as="div"
enter="transition ease-out duration-100" className="relative z-10 flex-shrink-0"
enterFrom="transform opacity-0 scale-95" onMouseUp={() => {
enterTo="transform opacity-100 scale-100" copyToClipboard(url)
leave="transition ease-in duration-75" track(tracking ?? 'copy share link')
leaveFrom="transform opacity-100 scale-100" }}
leaveTo="transform opacity-0 scale-95"
> >
<Menu.Items> <Menu.Button
<Menu.Item> className={clsx(
<ToastClipboard className={toastClassName} /> 'btn btn-xs border-2 border-green-600 bg-white normal-case text-green-600 hover:border-green-600 hover:bg-white',
</Menu.Item> buttonClassName
</Menu.Items> )}
</Transition> >
</Menu> <LinkIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
Copy link
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items>
<Menu.Item>
<ToastClipboard className={toastClassName} />
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
</Row>
) )
} }

View File

@ -5,6 +5,7 @@ import React from 'react'
export const createButtonStyle = export const createButtonStyle =
'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0 h-11' 'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0 h-11'
export const CreateQuestionButton = (props: { export const CreateQuestionButton = (props: {
user: User | null | undefined user: User | null | undefined
overrideText?: string overrideText?: string

View File

@ -1,7 +1,6 @@
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Comment } from 'common/comment' import { Comment } from 'common/comment'
import { formatPercent } from 'common/util/format'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
@ -11,8 +10,6 @@ import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-page' import { UserLink } from 'web/components/user-page'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
import clsx from 'clsx' import clsx from 'clsx'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { BuyButton } from 'web/components/yes-no-selector'
import { import {
CommentInput, CommentInput,
CommentRepliesList, CommentRepliesList,
@ -23,7 +20,6 @@ import { useRouter } from 'next/router'
import { groupBy } from 'lodash' import { groupBy } from 'lodash'
import { User } from 'common/user' import { User } from 'common/user'
import { useEvent } from 'web/hooks/use-event' import { useEvent } from 'web/hooks/use-event'
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
import { CommentTipMap } from 'web/hooks/use-tip-txns' import { CommentTipMap } from 'web/hooks/use-tip-txns'
export function FeedAnswerCommentGroup(props: { export function FeedAnswerCommentGroup(props: {
@ -50,11 +46,6 @@ export function FeedAnswerCommentGroup(props: {
const commentsList = comments.filter( const commentsList = comments.filter(
(comment) => comment.answerOutcome === answer.number.toString() (comment) => comment.answerOutcome === answer.number.toString()
) )
const thisAnswerProb = getDpmOutcomeProbability(
contract.totalShares,
answer.id
)
const probPercent = formatPercent(thisAnswerProb)
const betsByCurrentUser = (user && betsByUserId[user.id]) ?? [] const betsByCurrentUser = (user && betsByUserId[user.id]) ?? []
const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? [] const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? []
const isFreeResponseContractPage = !!commentsByCurrentUser const isFreeResponseContractPage = !!commentsByCurrentUser
@ -112,7 +103,7 @@ export function FeedAnswerCommentGroup(props: {
}, [answerElementId, router.asPath]) }, [answerElementId, router.asPath])
return ( return (
<Col className={'relative flex-1 gap-2'} key={answer.id + 'comment'}> <Col className={'relative flex-1 gap-3'} key={answer.id + 'comment'}>
<Modal open={open} setOpen={setOpen}> <Modal open={open} setOpen={setOpen}>
<AnswerBetPanel <AnswerBetPanel
answer={answer} answer={answer}
@ -125,7 +116,7 @@ export function FeedAnswerCommentGroup(props: {
<Row <Row
className={clsx( className={clsx(
'my-4 flex gap-3 space-x-3 transition-all duration-1000', 'mt-4 flex gap-3 space-x-3 transition-all duration-1000',
highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : '' highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : ''
)} )}
id={answerElementId} id={answerElementId}
@ -162,24 +153,6 @@ export function FeedAnswerCommentGroup(props: {
</button> </button>
</div> </div>
)} )}
<div className={'align-items flex w-full justify-end gap-4 '}>
<span
className={clsx(
'text-2xl',
tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
)}
>
{probPercent}
</span>
<BuyButton
className={clsx(
'btn-sm flex-initial !px-6 sm:flex',
tradingAllowed(contract) ? '' : '!hidden'
)}
onClick={() => setOpen(true)}
/>
</div>
</Row> </Row>
</Col> </Col>
{isFreeResponseContractPage && ( {isFreeResponseContractPage && (

View File

@ -93,6 +93,24 @@ export function BetStatusText(props: {
bet.fills?.some((fill) => fill.matchedBetId === null)) ?? bet.fills?.some((fill) => fill.matchedBetId === null)) ??
false false
const fromProb =
hadPoolMatch || isFreeResponse
? isPseudoNumeric
? formatNumericProbability(bet.probBefore, contract)
: formatPercent(bet.probBefore)
: isPseudoNumeric
? formatNumericProbability(bet.limitProb ?? bet.probBefore, contract)
: formatPercent(bet.limitProb ?? bet.probBefore)
const toProb =
hadPoolMatch || isFreeResponse
? isPseudoNumeric
? formatNumericProbability(bet.probAfter, contract)
: formatPercent(bet.probAfter)
: isPseudoNumeric
? formatNumericProbability(bet.limitProb ?? bet.probAfter, contract)
: formatPercent(bet.limitProb ?? bet.probAfter)
return ( return (
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
{bettor ? ( {bettor ? (
@ -112,14 +130,9 @@ export function BetStatusText(props: {
contract={contract} contract={contract}
truncate="short" truncate="short"
/>{' '} />{' '}
{isPseudoNumeric {fromProb === toProb
? ' than ' + formatNumericProbability(bet.probAfter, contract) ? `at ${fromProb}`
: ' at ' + : `from ${fromProb} to ${toProb}`}
formatPercent(
hadPoolMatch || isFreeResponse
? bet.probAfter
: bet.limitProb ?? bet.probAfter
)}
</> </>
)} )}
<RelativeTimestamp time={createdTime} /> <RelativeTimestamp time={createdTime} />

View File

@ -142,7 +142,7 @@ export function CommentRepliesList(props: {
id={comment.id} id={comment.id}
className={clsx( className={clsx(
'relative', 'relative',
!treatFirstIndexEqually && commentIdx === 0 ? '' : 'mt-3 ml-6' !treatFirstIndexEqually && commentIdx === 0 ? '' : 'ml-6'
)} )}
> >
{/*draw a gray line from the comment to the left:*/} {/*draw a gray line from the comment to the left:*/}

View File

@ -76,11 +76,13 @@ export function LimitOrderTable(props: {
return ( return (
<table className="table-compact table w-full rounded text-gray-500"> <table className="table-compact table w-full rounded text-gray-500">
<thead> <thead>
{!isYou && <th></th>} <tr>
<th>Outcome</th> {!isYou && <th></th>}
<th>{isPseudoNumeric ? 'Value' : 'Prob'}</th> <th>Outcome</th>
<th>Amount</th> <th>{isPseudoNumeric ? 'Value' : 'Prob'}</th>
{isYou && <th></th>} <th>Amount</th>
{isYou && <th></th>}
</tr>
</thead> </thead>
<tbody> <tbody>
{limitBets.map((bet) => ( {limitBets.map((bet) => (

View File

@ -235,7 +235,10 @@ export default function Sidebar(props: { className?: string }) {
buttonContent={<MoreButton />} buttonContent={<MoreButton />}
/> />
)} )}
{/* Spacer if there are any groups */}
{memberItems.length > 0 && (
<hr className="!my-4 mr-2 border-gray-300" />
)}
{privateUser && ( {privateUser && (
<GroupsList <GroupsList
currentPage={router.asPath} currentPage={router.asPath}
@ -256,11 +259,7 @@ export default function Sidebar(props: { className?: string }) {
/> />
{/* Spacer if there are any groups */} {/* Spacer if there are any groups */}
{memberItems.length > 0 && ( {memberItems.length > 0 && <hr className="!my-4 border-gray-300" />}
<div className="py-3">
<div className="h-[1px] bg-gray-300" />
</div>
)}
{privateUser && ( {privateUser && (
<GroupsList <GroupsList
currentPage={router.asPath} currentPage={router.asPath}

View File

@ -1,5 +1,8 @@
import clsx from 'clsx' import clsx from 'clsx'
import { Contract, contractUrl } from 'web/lib/firebase/contracts'
import { ENV_CONFIG } from 'common/envs/constants'
import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts'
import { CopyLinkButton } from './copy-link-button' import { CopyLinkButton } from './copy-link-button'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Row } from './layout/row' import { Row } from './layout/row'
@ -7,18 +10,15 @@ import { Row } from './layout/row'
export function ShareMarket(props: { contract: Contract; className?: string }) { export function ShareMarket(props: { contract: Contract; className?: string }) {
const { contract, className } = props const { contract, className } = props
const url = `https://${ENV_CONFIG.domain}${contractPath(contract)}`
return ( return (
<Col className={clsx(className, 'gap-3')}> <Col className={clsx(className, 'gap-3')}>
<div>Share your market</div> <div>Share your market</div>
<Row className="mb-6 items-center"> <Row className="mb-6 items-center">
<input
className="input input-bordered flex-1 rounded-r-none text-gray-500"
readOnly
type="text"
value={contractUrl(contract)}
/>
<CopyLinkButton <CopyLinkButton
contract={contract} url={url}
displayUrl={contractUrl(contract)}
buttonClassName="btn-md rounded-l-none" buttonClassName="btn-md rounded-l-none"
toastClassName={'-left-28 mt-1'} toastClassName={'-left-28 mt-1'}
/> />

View File

@ -39,6 +39,7 @@ import { PortfolioValueSection } from './portfolio/portfolio-value-section'
import { filterDefined } from 'common/util/array' import { filterDefined } from 'common/util/array'
import { useUserBets } from 'web/hooks/use-user-bets' import { useUserBets } from 'web/hooks/use-user-bets'
import { ReferralsButton } from 'web/components/referrals-button' import { ReferralsButton } from 'web/components/referrals-button'
import { formatMoney } from 'common/util/format'
export function UserLink(props: { export function UserLink(props: {
name: string name: string
@ -123,6 +124,7 @@ export function UserPage(props: {
const yourFollows = useFollows(currentUser?.id) const yourFollows = useFollows(currentUser?.id)
const isFollowing = yourFollows?.includes(user.id) const isFollowing = yourFollows?.includes(user.id)
const profit = user.profitCached.allTime
const onFollow = () => { const onFollow = () => {
if (!currentUser) return if (!currentUser) return
@ -187,6 +189,17 @@ export function UserPage(props: {
<Col className="mx-4 -mt-6"> <Col className="mx-4 -mt-6">
<span className="text-2xl font-bold">{user.name}</span> <span className="text-2xl font-bold">{user.name}</span>
<span className="text-gray-500">@{user.username}</span> <span className="text-gray-500">@{user.username}</span>
<span className="text-gray-500">
<span
className={clsx(
'text-md',
profit >= 0 ? 'text-green-600' : 'text-red-400'
)}
>
{formatMoney(profit)}
</span>{' '}
profit
</span>
<Spacer h={4} /> <Spacer h={4} />

View File

@ -3,6 +3,7 @@ import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useSearchBox } from 'react-instantsearch-hooks-web' import { useSearchBox } from 'react-instantsearch-hooks-web'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { DEFAULT_SORT } from 'web/components/contract-search'
const MARKETS_SORT = 'markets_sort' const MARKETS_SORT = 'markets_sort'
@ -10,11 +11,11 @@ export type Sort =
| 'newest' | 'newest'
| 'oldest' | 'oldest'
| 'most-traded' | 'most-traded'
| 'most-popular'
| '24-hour-vol' | '24-hour-vol'
| 'close-date' | 'close-date'
| 'resolve-date' | 'resolve-date'
| 'last-updated' | 'last-updated'
| 'score'
export function getSavedSort() { export function getSavedSort() {
// TODO: this obviously doesn't work with SSR, common sense would suggest // TODO: this obviously doesn't work with SSR, common sense would suggest
@ -31,7 +32,7 @@ export function useInitialQueryAndSort(options?: {
shouldLoadFromStorage?: boolean shouldLoadFromStorage?: boolean
}) { }) {
const { defaultSort, shouldLoadFromStorage } = defaults(options, { const { defaultSort, shouldLoadFromStorage } = defaults(options, {
defaultSort: 'most-popular', defaultSort: DEFAULT_SORT,
shouldLoadFromStorage: true, shouldLoadFromStorage: true,
}) })
const router = useRouter() const router = useRouter()

View File

@ -1,32 +1,28 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { PrivateUser, User } from 'common/user' import { PrivateUser, User } from 'common/user'
import {
listenForAllUsers,
listenForPrivateUsers,
} from 'web/lib/firebase/users'
import { groupBy, sortBy, difference } from 'lodash' import { groupBy, sortBy, difference } from 'lodash'
import { getContractsOfUserBets } from 'web/lib/firebase/bets' import { getContractsOfUserBets } from 'web/lib/firebase/bets'
import { useFollows } from './use-follows' import { useFollows } from './use-follows'
import { useUser } from './use-user' 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'
export const useUsers = () => { export const useUsers = () => {
const [users, setUsers] = useState<User[]>([]) const result = useFirestoreQueryData<DocumentData, User[]>(['users'], users, {
subscribe: true,
useEffect(() => { includeMetadataChanges: true,
listenForAllUsers(setUsers) })
}, []) return result.data ?? []
return users
} }
export const usePrivateUsers = () => { export const usePrivateUsers = () => {
const [users, setUsers] = useState<PrivateUser[]>([]) const result = useFirestoreQueryData<DocumentData, PrivateUser[]>(
['private users'],
useEffect(() => { privateUsers,
listenForPrivateUsers(setUsers) { subscribe: true, includeMetadataChanges: true }
}, []) )
return result.data || []
return users
} }
export const useDiscoverUsers = (userId: string | null | undefined) => { export const useDiscoverUsers = (userId: string | null | undefined) => {

View File

@ -22,7 +22,12 @@ export const groups = coll<Group>('groups')
export function groupPath( export function groupPath(
groupSlug: string, groupSlug: string,
subpath?: 'edit' | 'questions' | 'about' | typeof GROUP_CHAT_SLUG | 'rankings' subpath?:
| 'edit'
| 'questions'
| 'about'
| typeof GROUP_CHAT_SLUG
| 'leaderboards'
) { ) {
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}` return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
} }

View File

@ -52,12 +52,19 @@ export const getServerAuthenticatedUid = async (ctx: RequestContext) => {
if (idToken != null) { if (idToken != null) {
try { try {
return (await auth.verifyIdToken(idToken))?.uid return (await auth.verifyIdToken(idToken))?.uid
} catch {
// plausibly expired; try the refresh token, if it's present
}
}
if (refreshToken != null) {
try {
const resp = await requestFirebaseIdToken(refreshToken)
setAuthCookies(resp.id_token, resp.refresh_token, ctx.res)
return (await auth.verifyIdToken(resp.id_token))?.uid
} catch (e) { } catch (e) {
if (refreshToken != null) { // this is a big unexpected problem -- either their cookies are corrupt
const resp = await requestFirebaseIdToken(refreshToken) // or the refresh token API is down. functionally, they are not logged in
setAuthCookies(resp.id_token, resp.refresh_token, ctx.res) console.error(e)
return (await auth.verifyIdToken(resp.id_token))?.uid
}
} }
} }
return undefined return undefined

View File

@ -258,16 +258,6 @@ export async function listAllUsers() {
return docs.map((doc) => doc.data()) return docs.map((doc) => doc.data())
} }
export function listenForAllUsers(setUsers: (users: User[]) => void) {
listenForValues(users, setUsers)
}
export function listenForPrivateUsers(
setUsers: (users: PrivateUser[]) => void
) {
listenForValues(privateUsers, setUsers)
}
export function getTopTraders(period: Period) { export function getTopTraders(period: Period) {
const topTraders = query( const topTraders = query(
users, users,
@ -275,7 +265,7 @@ export function getTopTraders(period: Period) {
limit(20) limit(20)
) )
return getValues(topTraders) return getValues<User>(topTraders)
} }
export function getTopCreators(period: Period) { export function getTopCreators(period: Period) {
@ -284,7 +274,7 @@ export function getTopCreators(period: Period) {
orderBy('creatorVolumeCached.' + period, 'desc'), orderBy('creatorVolumeCached.' + period, 'desc'),
limit(20) limit(20)
) )
return getValues(topCreators) return getValues<User>(topCreators)
} }
export async function getTopFollowed() { export async function getTopFollowed() {

View File

@ -4,6 +4,7 @@ const API_DOCS_URL = 'https://docs.manifold.markets/api'
module.exports = { module.exports = {
staticPageGenerationTimeout: 600, // e.g. stats page staticPageGenerationTimeout: 600, // e.g. stats page
reactStrictMode: true, reactStrictMode: true,
optimizeFonts: false,
experimental: { experimental: {
externalDir: true, externalDir: true,
optimizeCss: true, optimizeCss: true,

View File

@ -40,7 +40,7 @@
"gridjs-react": "5.0.2", "gridjs-react": "5.0.2",
"lodash": "4.17.21", "lodash": "4.17.21",
"nanoid": "^3.3.4", "nanoid": "^3.3.4",
"next": "12.1.2", "next": "12.2.2",
"node-fetch": "3.2.4", "node-fetch": "3.2.4",
"react": "17.0.2", "react": "17.0.2",
"react-confetti": "6.0.1", "react-confetti": "6.0.1",

View File

@ -6,16 +6,15 @@ export default function Document() {
<Html data-theme="mantic" className="min-h-screen"> <Html data-theme="mantic" className="min-h-screen">
<Head> <Head>
<link rel="icon" href={ENV_CONFIG.faviconPath} /> <link rel="icon" href={ENV_CONFIG.faviconPath} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link <link
rel="preconnect" rel="preconnect"
href="https://fonts.gstatic.com" href="https://fonts.gstatic.com"
crossOrigin="true" crossOrigin="anonymous"
/> />
<link <link
href="https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@300;400;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@300;400;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
crossOrigin="anonymous"
/> />
<link <link
rel="stylesheet" rel="stylesheet"
@ -24,7 +23,6 @@ export default function Document() {
crossOrigin="anonymous" crossOrigin="anonymous"
/> />
</Head> </Head>
<body className="font-readex-pro bg-base-200 min-h-screen"> <body className="font-readex-pro bg-base-200 min-h-screen">
<Main /> <Main />
<NextScript /> <NextScript />

View File

@ -54,10 +54,8 @@ export default function ContractSearchFirestore(props: {
) )
} else if (sort === 'most-traded') { } else if (sort === 'most-traded') {
matches.sort((a, b) => b.volume - a.volume) matches.sort((a, b) => b.volume - a.volume)
} else if (sort === 'most-popular') { } else if (sort === 'score') {
matches.sort( matches.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
(a, b) => (b.uniqueBettorCount ?? 0) - (a.uniqueBettorCount ?? 0)
)
} else if (sort === '24-hour-vol') { } else if (sort === '24-hour-vol') {
// Use lodash for stable sort, so previous sort breaks all ties. // Use lodash for stable sort, so previous sort breaks all ties.
matches = sortBy(matches, ({ volume7Days }) => -1 * volume7Days) matches = sortBy(matches, ({ volume7Days }) => -1 * volume7Days)
@ -104,7 +102,7 @@ export default function ContractSearchFirestore(props: {
> >
<option value="newest">Newest</option> <option value="newest">Newest</option>
<option value="oldest">Oldest</option> <option value="oldest">Oldest</option>
<option value="most-popular">Most popular</option> <option value="score">Most popular</option>
<option value="most-traded">Most traded</option> <option value="most-traded">Most traded</option>
<option value="24-hour-vol">24h volume</option> <option value="24-hour-vol">24h volume</option>
<option value="close-date">Closing soon</option> <option value="close-date">Closing soon</option>

View File

@ -1,4 +1,5 @@
import { take, sortBy, debounce } from 'lodash' import { take, sortBy, debounce } from 'lodash'
import PlusSmIcon from '@heroicons/react/solid/PlusSmIcon'
import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Group, GROUP_CHAT_SLUG } from 'common/group'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
@ -32,10 +33,7 @@ import { SEO } from 'web/components/SEO'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
import { fromPropz, usePropz } from 'web/hooks/use-propz' import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { Tabs } from 'web/components/layout/tabs' import { Tabs } from 'web/components/layout/tabs'
import { import { CreateQuestionButton } from 'web/components/create-question-button'
createButtonStyle,
CreateQuestionButton,
} from 'web/components/create-question-button'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { GroupChat } from 'web/components/groups/group-chat' import { GroupChat } from 'web/components/groups/group-chat'
import { LoadingIndicator } from 'web/components/loading-indicator' import { LoadingIndicator } from 'web/components/loading-indicator'
@ -44,7 +42,6 @@ import { getSavedSort } from 'web/hooks/use-sort-and-query-params'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { useCommentsOnGroup } from 'web/hooks/use-comments' import { useCommentsOnGroup } from 'web/hooks/use-comments'
import { ShareIconButton } from 'web/components/share-icon-button'
import { REFERRAL_AMOUNT } from 'common/user' import { REFERRAL_AMOUNT } from 'common/user'
import { ContractSearch } from 'web/components/contract-search' import { ContractSearch } from 'web/components/contract-search'
import clsx from 'clsx' import clsx from 'clsx'
@ -54,6 +51,7 @@ import { useTipTxns } from 'web/hooks/use-tip-txns'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
import { searchInAny } from 'common/util/parse' import { searchInAny } from 'common/util/parse'
import { useWindowSize } from 'web/hooks/use-window-size' import { useWindowSize } from 'web/hooks/use-window-size'
import { CopyLinkButton } from 'web/components/copy-link-button'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
@ -116,7 +114,7 @@ const groupSubpages = [
undefined, undefined,
GROUP_CHAT_SLUG, GROUP_CHAT_SLUG,
'questions', 'questions',
'rankings', 'leaderboards',
'about', 'about',
] as const ] as const
@ -239,9 +237,9 @@ export default function GroupPage(props: {
href: groupPath(group.slug, 'questions'), href: groupPath(group.slug, 'questions'),
}, },
{ {
title: 'Rankings', title: 'Leaderboards',
content: leaderboard, content: leaderboard,
href: groupPath(group.slug, 'rankings'), href: groupPath(group.slug, 'leaderboards'),
}, },
{ {
title: 'About', title: 'About',
@ -255,7 +253,7 @@ export default function GroupPage(props: {
<Page <Page
rightSidebar={showChatSidebar ? chatTab : undefined} rightSidebar={showChatSidebar ? chatTab : undefined}
rightSidebarClassName={showChatSidebar ? '!top-0' : ''} rightSidebarClassName={showChatSidebar ? '!top-0' : ''}
className={showChatSidebar ? '!max-w-none !pb-0' : ''} className={showChatSidebar ? '!max-w-7xl !pb-0' : ''}
> >
<SEO <SEO
title={group.name} title={group.name}
@ -266,9 +264,7 @@ export default function GroupPage(props: {
<Row className={'items-center justify-between gap-4'}> <Row className={'items-center justify-between gap-4'}>
<div className={'sm:mb-1'}> <div className={'sm:mb-1'}>
<div <div
className={ className={'line-clamp-1 my-2 text-2xl text-indigo-700 sm:my-3'}
'line-clamp-1 my-1 text-lg text-indigo-700 sm:my-3 sm:text-2xl'
}
> >
{group.name} {group.name}
</div> </div>
@ -276,7 +272,7 @@ export default function GroupPage(props: {
<Linkify text={group.about} /> <Linkify text={group.about} />
</div> </div>
</div> </div>
<div className="hidden sm:block xl:hidden"> <div className="mt-2">
<JoinOrAddQuestionsButtons <JoinOrAddQuestionsButtons
group={group} group={group}
user={user} user={user}
@ -284,13 +280,6 @@ export default function GroupPage(props: {
/> />
</div> </div>
</Row> </Row>
<div className="block sm:hidden">
<JoinOrAddQuestionsButtons
group={group}
user={user}
isMember={!!isMember}
/>
</div>
</Col> </Col>
<Tabs <Tabs
currentPageForAnalytics={groupPath(group.slug)} currentPageForAnalytics={groupPath(group.slug)}
@ -309,21 +298,7 @@ function JoinOrAddQuestionsButtons(props: {
}) { }) {
const { group, user, isMember } = props const { group, user, isMember } = props
return user && isMember ? ( return user && isMember ? (
<Row <Row className={'mt-0 justify-end'}>
className={'-mt-2 justify-between sm:mt-0 sm:flex-col sm:justify-center'}
>
<CreateQuestionButton
user={user}
overrideText={'Add a new question'}
className={'hidden w-48 flex-shrink-0 sm:block'}
query={`?groupId=${group.id}`}
/>
<CreateQuestionButton
user={user}
overrideText={'New question'}
className={'block w-40 flex-shrink-0 sm:hidden'}
query={`?groupId=${group.id}`}
/>
<AddContractButton group={group} user={user} /> <AddContractButton group={group} user={user} />
</Row> </Row>
) : group.anyoneCanJoin ? ( ) : group.anyoneCanJoin ? (
@ -354,6 +329,11 @@ function GroupOverview(props: {
}) })
} }
const postFix = user ? '?referrer=' + user.username : ''
const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
group.slug
)}${postFix}`
return ( return (
<> <>
<Col className="gap-2 rounded-b bg-white p-2"> <Col className="gap-2 rounded-b bg-white p-2">
@ -398,22 +378,25 @@ function GroupOverview(props: {
</span> </span>
)} )}
</Row> </Row>
{anyoneCanJoin && user && ( {anyoneCanJoin && user && (
<Row className={'flex-wrap items-center gap-1'}> <Col className="my-4 px-2">
<span className={'text-gray-500'}>Share</span> <div className="text-lg">Invite</div>
<ShareIconButton <div className={'mb-2 text-gray-500'}>
copyPayload={`https://${ENV_CONFIG.domain}${groupPath( Invite a friend to this group and get M${REFERRAL_AMOUNT} if they
group.slug sign up!
)}${user?.username ? '?referrer=' + user?.username : ''}`} </div>
buttonClassName={'hover:bg-gray-300 mt-1 !text-gray-700'}
> <CopyLinkButton
<span className={'mx-2'}> url={shareUrl}
Invite a friend and get M${REFERRAL_AMOUNT} if they sign up! tracking="copy group share link"
</span> buttonClassName="btn-md rounded-l-none"
</ShareIconButton> toastClassName={'-left-28 mt-1'}
</Row> />
)} )}
<Col className={'mt-2'}> <Col className={'mt-2'}>
<div className="mb-2 text-lg">Members</div>
<GroupMemberSearch members={members} group={group} /> <GroupMemberSearch members={members} group={group} />
</Col> </Col>
</Col> </Col>
@ -514,14 +497,14 @@ function GroupLeaderboards(props: {
<SortedLeaderboard <SortedLeaderboard
users={members} users={members}
scoreFunction={(user) => traderScores[user.id] ?? 0} scoreFunction={(user) => traderScores[user.id] ?? 0}
title="🏅 Bettor rankings" title="🏅 Top bettors"
header="Profit" header="Profit"
maxToShow={maxToShow} maxToShow={maxToShow}
/> />
<SortedLeaderboard <SortedLeaderboard
users={members} users={members}
scoreFunction={(user) => creatorScores[user.id] ?? 0} scoreFunction={(user) => creatorScores[user.id] ?? 0}
title="🏅 Creator rankings" title="🏅 Top creators"
header="Market volume" header="Market volume"
maxToShow={maxToShow} maxToShow={maxToShow}
/> />
@ -561,7 +544,7 @@ function GroupLeaderboards(props: {
} }
function AddContractButton(props: { group: Group; user: User }) { function AddContractButton(props: { group: Group; user: User }) {
const { group } = props const { group, user } = props
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
async function addContractToCurrentGroup(contract: Contract) { async function addContractToCurrentGroup(contract: Contract) {
@ -571,16 +554,39 @@ function AddContractButton(props: { group: Group; user: User }) {
return ( return (
<> <>
<div className={'flex justify-center'}>
<button
className={clsx('btn btn-sm btn-outline')}
onClick={() => setOpen(true)}
>
<PlusSmIcon className="h-6 w-6" aria-hidden="true" /> question
</button>
</div>
<Modal open={open} setOpen={setOpen} className={'sm:p-0'}> <Modal open={open} setOpen={setOpen} className={'sm:p-0'}>
<Col <Col
className={ className={
'max-h-[60vh] min-h-[60vh] w-full gap-4 rounded-md bg-white p-8' 'max-h-[60vh] min-h-[60vh] w-full gap-4 rounded-md bg-white'
} }
> >
<div className={'text-lg text-indigo-700'}> <Col className="p-8 pb-0">
Add a question to your group <div className={'text-xl text-indigo-700'}>
</div> Add a question to your group
<div className={'overflow-y-scroll p-1'}> </div>
<Col className="items-center">
<CreateQuestionButton
user={user}
overrideText={'New question'}
className={'w-48 flex-shrink-0 '}
query={`?groupId=${group.id}`}
/>
<div className={'mt-2 text-lg text-indigo-700'}>or</div>
</Col>
</Col>
<div className={'overflow-y-scroll sm:px-8'}>
<ContractSearch <ContractSearch
hideOrderSelector={true} hideOrderSelector={true}
onContractClick={addContractToCurrentGroup} onContractClick={addContractToCurrentGroup}
@ -592,26 +598,6 @@ function AddContractButton(props: { group: Group; user: User }) {
</div> </div>
</Col> </Col>
</Modal> </Modal>
<div className={'flex justify-center'}>
<button
className={clsx(
createButtonStyle,
'hidden w-48 whitespace-nowrap border border-black text-black hover:bg-black hover:text-white sm:block'
)}
onClick={() => setOpen(true)}
>
Add an old question
</button>
<button
className={clsx(
createButtonStyle,
'block w-40 whitespace-nowrap border border-black text-black hover:bg-black hover:text-white sm:hidden'
)}
onClick={() => setOpen(true)}
>
Old question
</button>
</div>
</> </>
) )
} }

View File

@ -5,7 +5,7 @@ import { PlusSmIcon } from '@heroicons/react/solid'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { getSavedSort } from 'web/hooks/use-sort-and-query-params' import { getSavedSort } from 'web/hooks/use-sort-and-query-params'
import { ContractSearch } from 'web/components/contract-search' import { ContractSearch, DEFAULT_SORT } from 'web/components/contract-search'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { ContractPageContent } from './[username]/[contractSlug]' import { ContractPageContent } from './[username]/[contractSlug]'
import { getContractFromSlug } from 'web/lib/firebase/contracts' import { getContractFromSlug } from 'web/lib/firebase/contracts'
@ -28,7 +28,7 @@ const Home = () => {
<ContractSearch <ContractSearch
querySortOptions={{ querySortOptions={{
shouldLoadFromStorage: true, shouldLoadFromStorage: true,
defaultSort: getSavedSort() ?? 'most-popular', defaultSort: getSavedSort() ?? DEFAULT_SORT,
}} }}
onContractClick={(c) => { onContractClick={(c) => {
// Show contract without navigating to contract page. // Show contract without navigating to contract page.

View File

@ -1,5 +1,6 @@
import React from 'react' import React, { useEffect } from 'react'
import { useRouter } from 'next/router'
import { useUser } from 'web/hooks/use-user'
import { Contract, getContractsBySlugs } from 'web/lib/firebase/contracts' import { Contract, getContractsBySlugs } from 'web/lib/firebase/contracts'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { LandingPagePanel } from 'web/components/landing-page-panel' import { LandingPagePanel } from 'web/components/landing-page-panel'
@ -26,6 +27,17 @@ export const getServerSideProps = redirectIfLoggedIn('/home', async (_) => {
export default function Home(props: { hotContracts: Contract[] }) { export default function Home(props: { hotContracts: Contract[] }) {
const { hotContracts } = props const { hotContracts } = props
// for now this redirect in the component is how we handle the case where they are
// on this page and they log in -- in the future we will make some cleaner way
const user = useUser()
const router = useRouter()
useEffect(() => {
if (user != null) {
router.replace('/home')
}
}, [router, user])
return ( return (
<Page> <Page>
<div className="px-4 pt-2 md:mt-0 lg:hidden"> <div className="px-4 pt-2 md:mt-0 lg:hidden">

View File

@ -9,97 +9,96 @@ import {
User, User,
} from 'web/lib/firebase/users' } from 'web/lib/firebase/users'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { Tabs } from 'web/components/layout/tabs' import { Tabs } from 'web/components/layout/tabs'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticProps() {
export async function getStaticPropz() { const props = await fetchProps()
return queryLeaderboardUsers('allTime')
}
const queryLeaderboardUsers = async (period: Period) => {
const [topTraders, topCreators, topFollowed] = await Promise.all([
getTopTraders(period).catch(() => {}),
getTopCreators(period).catch(() => {}),
getTopFollowed().catch(() => {}),
])
return { return {
props: { props,
topTraders,
topCreators,
topFollowed,
},
revalidate: 60, // regenerate after a minute revalidate: 60, // regenerate after a minute
} }
} }
export default function Leaderboards(props: { const fetchProps = async () => {
const [allTime, monthly, weekly, daily] = await Promise.all([
queryLeaderboardUsers('allTime'),
queryLeaderboardUsers('monthly'),
queryLeaderboardUsers('weekly'),
queryLeaderboardUsers('daily'),
])
const topFollowed = await getTopFollowed()
return {
allTime,
monthly,
weekly,
daily,
topFollowed,
}
}
const queryLeaderboardUsers = async (period: Period) => {
const [topTraders, topCreators] = await Promise.all([
getTopTraders(period),
getTopCreators(period),
])
return {
topTraders,
topCreators,
}
}
type leaderboard = {
topTraders: User[] topTraders: User[]
topCreators: User[] topCreators: User[]
}
export default function Leaderboards(_props: {
allTime: leaderboard
monthly: leaderboard
weekly: leaderboard
daily: leaderboard
topFollowed: User[] topFollowed: User[]
}) { }) {
props = usePropz(props, getStaticPropz) ?? { const [props, setProps] = useState<Parameters<typeof Leaderboards>[0]>(_props)
topTraders: [],
topCreators: [],
topFollowed: [],
}
const { topFollowed } = props
const [topTradersState, setTopTraders] = useState(props.topTraders)
const [topCreatorsState, setTopCreators] = useState(props.topCreators)
const [isLoading, setLoading] = useState(false)
const [period, setPeriod] = useState<Period>('allTime')
useEffect(() => { useEffect(() => {
setLoading(true) fetchProps().then((props) => setProps(props))
queryLeaderboardUsers(period).then((res) => { }, [])
setTopTraders(res.props.topTraders as User[])
setTopCreators(res.props.topCreators as User[]) const { topFollowed } = props
setLoading(false)
})
}, [period])
const LeaderboardWithPeriod = (period: Period) => { const LeaderboardWithPeriod = (period: Period) => {
const { topTraders, topCreators } = props[period]
return ( return (
<> <>
<Col className="mx-4 items-center gap-10 lg:flex-row"> <Col className="mx-4 items-center gap-10 lg:flex-row">
{!isLoading ? ( <Leaderboard
<> title="🏅 Top bettors"
{period === 'allTime' || users={topTraders}
period == 'weekly' || columns={[
period === 'daily' ? ( //TODO: show other periods once they're available {
<Leaderboard header: 'Total profit',
title="🏅 Top bettors" renderCell: (user) => formatMoney(user.profitCached[period]),
users={topTradersState} },
columns={[ ]}
{ />
header: 'Total profit',
renderCell: (user) =>
formatMoney(user.profitCached[period]),
},
]}
/>
) : (
<></>
)}
<Leaderboard <Leaderboard
title="🏅 Top creators" title="🏅 Top creators"
users={topCreatorsState} users={topCreators}
columns={[ columns={[
{ {
header: 'Total bet', header: 'Total bet',
renderCell: (user) => renderCell: (user) =>
formatMoney(user.creatorVolumeCached[period]), formatMoney(user.creatorVolumeCached[period]),
}, },
]} ]}
/> />
</>
) : (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</Col> </Col>
{period === 'allTime' ? ( {period === 'allTime' ? (
<Col className="mx-4 my-10 items-center gap-10 lg:mx-0 lg:w-1/2 lg:flex-row"> <Col className="mx-4 my-10 items-center gap-10 lg:mx-0 lg:w-1/2 lg:flex-row">
@ -127,20 +126,17 @@ export default function Leaderboards(props: {
<Title text={'Leaderboards'} className={'hidden md:block'} /> <Title text={'Leaderboards'} className={'hidden md:block'} />
<Tabs <Tabs
currentPageForAnalytics={'leaderboards'} currentPageForAnalytics={'leaderboards'}
defaultIndex={0} defaultIndex={1}
onClick={(title, index) => {
const period = ['allTime', 'monthly', 'weekly', 'daily'][index]
setPeriod(period as Period)
}}
tabs={[ tabs={[
{ {
title: 'All Time', title: 'All Time',
content: LeaderboardWithPeriod('allTime'), content: LeaderboardWithPeriod('allTime'),
}, },
{ // TODO: Enable this near the end of July!
title: 'Monthly', // {
content: LeaderboardWithPeriod('monthly'), // title: 'Monthly',
}, // content: LeaderboardWithPeriod('monthly'),
// },
{ {
title: 'Weekly', title: 'Weekly',
content: LeaderboardWithPeriod('weekly'), content: LeaderboardWithPeriod('weekly'),

View File

@ -1,5 +1,5 @@
import { Tabs } from 'web/components/layout/tabs' import { Tabs } from 'web/components/layout/tabs'
import { usePrivateUser, useUser } from 'web/hooks/use-user' import { usePrivateUser } from 'web/hooks/use-user'
import React, { useEffect, useMemo, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { Notification, notification_source_types } from 'common/notification' import { Notification, notification_source_types } from 'common/notification'
import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Avatar, EmptyAvatar } from 'web/components/avatar'
@ -12,17 +12,10 @@ import { UserLink } from 'web/components/user-page'
import { import {
MANIFOLD_AVATAR_URL, MANIFOLD_AVATAR_URL,
MANIFOLD_USERNAME, MANIFOLD_USERNAME,
notification_subscribe_types,
PrivateUser, PrivateUser,
User, User,
} from 'common/user' } from 'common/user'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { getUser } from 'web/lib/firebase/users'
import {
getUser,
listenForPrivateUser,
updatePrivateUser,
} from 'web/lib/firebase/users'
import { LoadingIndicator } from 'web/components/loading-indicator'
import clsx from 'clsx' import clsx from 'clsx'
import { RelativeTimestamp } from 'web/components/relative-timestamp' import { RelativeTimestamp } from 'web/components/relative-timestamp'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
@ -37,8 +30,7 @@ import {
NotificationGroup, NotificationGroup,
usePreferredGroupedNotifications, usePreferredGroupedNotifications,
} from 'web/hooks/use-notifications' } from 'web/hooks/use-notifications'
import { CheckIcon, TrendingUpIcon, XIcon } from '@heroicons/react/outline' import { TrendingUpIcon } from '@heroicons/react/outline'
import toast from 'react-hot-toast'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { groupPath } from 'web/lib/firebase/groups' import { groupPath } from 'web/lib/firebase/groups'
import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants'
@ -53,6 +45,7 @@ import {
redirectIfLoggedOut, redirectIfLoggedOut,
} from 'web/lib/firebase/server-auth' } from 'web/lib/firebase/server-auth'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { NotificationSettings } from 'web/components/NotificationSettings'
export const NOTIFICATIONS_PER_PAGE = 30 export const NOTIFICATIONS_PER_PAGE = 30
const MULTIPLE_USERS_KEY = 'multipleUsers' const MULTIPLE_USERS_KEY = 'multipleUsers'
@ -100,15 +93,12 @@ export default function Notifications(props: { user: User }) {
privateUser={privateUser} privateUser={privateUser}
cachedNotifications={localNotifications} cachedNotifications={localNotifications}
/> />
) : localNotificationGroups && ) : (
localNotificationGroups.length > 0 ? (
<div className={'min-h-[100vh]'}> <div className={'min-h-[100vh]'}>
<RenderNotificationGroups <RenderNotificationGroups
notificationGroups={localNotificationGroups} notificationGroups={localNotificationGroups}
/> />
</div> </div>
) : (
<LoadingIndicator />
), ),
}, },
{ {
@ -986,203 +976,3 @@ function getReasonForShowingNotification(
} }
return reasonText return reasonText
} }
// TODO: where should we put referral bonus notifications?
function NotificationSettings() {
const user = useUser()
const [notificationSettings, setNotificationSettings] =
useState<notification_subscribe_types>('all')
const [emailNotificationSettings, setEmailNotificationSettings] =
useState<notification_subscribe_types>('all')
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
useEffect(() => {
if (user) listenForPrivateUser(user.id, setPrivateUser)
}, [user])
useEffect(() => {
if (!privateUser) return
if (privateUser.notificationPreferences) {
setNotificationSettings(privateUser.notificationPreferences)
}
if (
privateUser.unsubscribedFromResolutionEmails &&
privateUser.unsubscribedFromCommentEmails &&
privateUser.unsubscribedFromAnswerEmails
) {
setEmailNotificationSettings('none')
} else if (
!privateUser.unsubscribedFromResolutionEmails &&
!privateUser.unsubscribedFromCommentEmails &&
!privateUser.unsubscribedFromAnswerEmails
) {
setEmailNotificationSettings('all')
} else {
setEmailNotificationSettings('less')
}
}, [privateUser])
const loading = 'Changing Notifications Settings'
const success = 'Notification Settings Changed!'
function changeEmailNotifications(newValue: notification_subscribe_types) {
if (!privateUser) return
if (newValue === 'all') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: false,
unsubscribedFromCommentEmails: false,
unsubscribedFromAnswerEmails: false,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
} else if (newValue === 'less') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: false,
unsubscribedFromCommentEmails: true,
unsubscribedFromAnswerEmails: true,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
} else if (newValue === 'none') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: true,
unsubscribedFromCommentEmails: true,
unsubscribedFromAnswerEmails: true,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
}
}
function changeInAppNotificationSettings(
newValue: notification_subscribe_types
) {
if (!privateUser) return
track('In-App Notification Preferences Changed', {
newPreference: newValue,
oldPreference: privateUser.notificationPreferences,
})
toast.promise(
updatePrivateUser(privateUser.id, {
notificationPreferences: newValue,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
}
useEffect(() => {
if (privateUser && privateUser.notificationPreferences)
setNotificationSettings(privateUser.notificationPreferences)
else setNotificationSettings('all')
}, [privateUser])
if (!privateUser) {
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
}
function NotificationSettingLine(props: {
label: string
highlight: boolean
}) {
const { label, highlight } = props
return (
<Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}>
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
{label}
</Row>
)
}
return (
<div className={'p-2'}>
<div>In App Notifications</div>
<ChoicesToggleGroup
currentChoice={notificationSettings}
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
setChoice={(choice) =>
changeInAppNotificationSettings(
choice as notification_subscribe_types
)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
<div className={'mt-4 text-sm'}>
<div>
<div className={''}>
You will receive notifications for:
<NotificationSettingLine
label={"Resolution of questions you've interacted with"}
highlight={notificationSettings !== 'none'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={'Activity on your own questions, comments, & answers'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Activity on questions you're betting on"}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Income & referral bonuses you've received"}
/>
<NotificationSettingLine
label={"Activity on questions you've ever bet or commented on"}
highlight={notificationSettings === 'all'}
/>
</div>
</div>
</div>
<div className={'mt-4'}>Email Notifications</div>
<ChoicesToggleGroup
currentChoice={emailNotificationSettings}
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
setChoice={(choice) =>
changeEmailNotifications(choice as notification_subscribe_types)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
<div className={'mt-4 text-sm'}>
<div>
You will receive emails for:
<NotificationSettingLine
label={"Resolution of questions you're betting on"}
highlight={emailNotificationSettings !== 'none'}
/>
<NotificationSettingLine
label={'Closure of your questions'}
highlight={emailNotificationSettings !== 'none'}
/>
<NotificationSettingLine
label={'Activity on your questions'}
highlight={emailNotificationSettings === 'all'}
/>
<NotificationSettingLine
label={"Activity on questions you've answered or commented on"}
highlight={emailNotificationSettings === 'all'}
/>
</div>
</div>
</div>
)
}

354
web/public/mtg/app.js Normal file
View File

@ -0,0 +1,354 @@
mode = 'PLAY'
allData = {}
total = 0
unseenTotal = 0
probList = []
nameList = []
k = 12
extra = 3
artDict = {}
totalCorrect = 0
totalSeen = 0
wordsLeft = k + extra
imagesLeft = k
maxRounds = 20
whichGuesser = 'counterspell'
un = false
online = false
firstPrint = false
flag = true
page = 1
document.location.search.split('&').forEach((pair) => {
let v = pair.split('=')
if (v[0] === '?whichguesser') {
whichGuesser = v[1]
} else if (v[0] === 'un') {
un = v[1]
} else if (v[0] === 'digital') {
online = v[1]
} else if (v[0] === 'original') {
firstPrint = v[1]
}
})
let firstFetch = fetch('jsons/' + whichGuesser + page + '.json')
fetchToResponse(firstFetch)
function putIntoMapAndFetch(data) {
putIntoMap(data.data)
if (data.has_more) {
page += 1
window.setTimeout(() =>
fetchToResponse(fetch('jsons/' + whichGuesser + page + '.json'))
)
} else {
for (const [key, value] of Object.entries(allData)) {
nameList.push(key)
probList.push(
value.length +
(probList.length === 0 ? 0 : probList[probList.length - 1])
)
unseenTotal = total
}
window.console.log(allData)
window.console.log(total)
window.console.log(probList)
window.console.log(nameList)
if (whichGuesser === 'counterspell') {
document.getElementById('guess-type').innerText = 'Counterspell Guesser'
} else if (whichGuesser === 'burn') {
document.getElementById('guess-type').innerText = 'Match With Hot Singles'
}
setUpNewGame()
}
}
function getKSamples() {
let usedCounters = new Set()
let currentTotal = unseenTotal
let samples = {}
let i = 0
while (i < k) {
let rand = Math.floor(Math.random() * currentTotal)
let count = 0
for (const [key, value] of Object.entries(allData)) {
if (usedCounters.has(key)) {
continue
} else if (count >= rand) {
usedCounters.add(key)
currentTotal -= value.length
unseenTotal--
let randIndex = Math.floor(Math.random() * value.length)
let arts = allData[key].splice(randIndex, 1)
samples[arts[0].artImg] = [key, arts[0].normalImg]
i++
break
} else {
count += value.length
}
}
}
for (const key of usedCounters) {
if (allData[key].length === 0) {
delete allData[key]
}
}
let count = 0
while (count < extra) {
let rand = Math.floor(Math.random() * total)
for (let j = 0; j < nameList.length; j++) {
if (j >= rand) {
if (usedCounters.has(nameList[j])) {
break
}
usedCounters.add(nameList[j])
count += 1
break
}
}
}
return [samples, usedCounters]
}
function fetchToResponse(fetch) {
return fetch
.then((response) => response.json())
.then((json) => {
putIntoMapAndFetch(json)
})
}
function determineIfSkip(card) {
if (!un) {
if (card.set_type === 'funny') {
return true
}
}
if (!online) {
if (card.digital) {
return true
}
}
if (firstPrint) {
if (
card.reprint === true ||
(card.frame_effects && card.frame_effects.includes('showcase'))
) {
return true
}
}
// reskinned card names show in art crop
if (card.flavor_name) {
return true
}
// don't include racist cards
return card.content_warning
}
function putIntoMap(data) {
for (let i = 0; i < data.length; i++) {
let card = data[i]
if (determineIfSkip(card)) {
continue
}
let name = card.name
// remove slashes from adventure cards
if (card.card_faces) {
name = card.card_faces[0].name
}
let normalImg = ''
if (card.image_uris.normal) {
normalImg = card.image_uris.normal
} else if (card.image_uris.large) {
normalImg = card.image_uris.large
} else if (card.image_uris.small) {
normalImg = card.image_uris.small
} else {
continue
}
let artImg = ''
if (card.image_uris.art_crop) {
artImg = card.image_uris.art_crop
} else {
continue
}
total += 1
if (!allData[name]) {
allData[name] = [{ artImg: artImg, normalImg: normalImg }]
} else {
allData[name].push({ artImg: artImg, normalImg: normalImg })
}
}
}
function shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
let j = Math.floor(Math.random() * (i + 1))
let temp = array[i]
array[i] = array[j]
array[j] = temp
}
}
function setUpNewGame() {
wordsLeft = k + extra
imagesLeft = k
let currentRound = totalSeen / k
if (currentRound + 1 === maxRounds) {
document.getElementById('round-number').innerText = 'Final Round'
} else {
document.getElementById('round-number').innerText =
'Round ' + (1 + currentRound)
}
setWordsLeft()
// select new cards
let sampledData = getKSamples()
artDict = sampledData[0]
let randomImages = Object.keys(artDict)
shuffleArray(randomImages)
let namesList = Array.from(sampledData[1]).sort()
// fill in the new cards and names
for (let cardIndex = 1; cardIndex <= k; cardIndex++) {
let currCard = document.getElementById('card-' + cardIndex)
currCard.classList.remove('incorrect')
currCard.dataset.name = ''
currCard.dataset.url = randomImages[cardIndex - 1]
currCard.style.backgroundImage = "url('" + currCard.dataset.url + "')"
}
const nameBank = document.querySelector('.names-bank')
for (nameIndex = 1; nameIndex <= k + extra; nameIndex++) {
currName = document.getElementById('name-' + nameIndex)
// window.console.log(currName)
currName.innerText = namesList[nameIndex - 1]
nameBank.appendChild(currName)
}
}
function checkAnswers() {
let score = k
// show the correct full cards
for (cardIndex = 1; cardIndex <= k; cardIndex++) {
currCard = document.getElementById('card-' + cardIndex)
let incorrect = true
if (currCard.dataset.name) {
let guess = document.getElementById(currCard.dataset.name).innerText
// window.console.log(artDict[currCard.dataset.url][0], guess);
incorrect = artDict[currCard.dataset.url][0] !== guess
// decide if their guess was correct
}
if (incorrect) currCard.classList.add('incorrect')
// tally some kind of score
if (incorrect) score--
// show the correct card
currCard.style.backgroundImage =
"url('" + artDict[currCard.dataset.url][1] + "')"
}
totalSeen += k
totalCorrect += score
document.getElementById('score-amount').innerText = score + '/' + k
document.getElementById('score-percent').innerText = Math.round(
(totalCorrect * 100) / totalSeen
)
document.getElementById('score-amount-total').innerText =
totalCorrect + '/' + totalSeen
}
function toggleMode() {
event.preventDefault()
if (mode === 'PLAY') {
mode = 'ANSWER'
document.querySelector('.play-page').classList.add('answer-page')
window.console.log(totalSeen)
if (totalSeen / k === maxRounds - 1) {
document.getElementById('submit').style.display = 'none'
} else {
document.getElementById('submit').value = 'Next Round'
}
checkAnswers()
} else {
mode = 'PLAY'
document.querySelector('.play-page').classList.remove('answer-page')
document.getElementById('submit').value = 'Submit'
setUpNewGame()
}
}
function allowDrop(ev, id) {
ev.preventDefault()
}
function drag(ev) {
ev.dataTransfer.setData('text', ev.target.id)
let nameEl = document.querySelector('.selected')
if (nameEl) nameEl.classList.remove('selected')
}
function drop(ev, id) {
ev.preventDefault()
var data = ev.dataTransfer.getData('text')
dropOnCard(id, data)
}
function returnDrop(ev) {
ev.preventDefault()
var data = ev.dataTransfer.getData('text')
returnToNameBank(data)
}
function returnToNameBank(name) {
document
.querySelector('.names-bank')
.appendChild(document.getElementById(name))
let prevContainer = document.querySelector('[data-name=' + name + ']')
if (prevContainer) {
prevContainer.dataset.name = ''
wordsLeft += 1
imagesLeft += 1
setWordsLeft()
}
}
function selectName(ev) {
if (ev.target.parentNode.classList.contains('names-bank')) {
let nameEl = document.querySelector('.selected')
if (nameEl) nameEl.classList.remove('selected')
ev.target.classList.add('selected')
} else {
returnToNameBank(ev.target.id)
}
}
function dropSelected(ev, id) {
ev.preventDefault()
let nameEl = document.querySelector('.selected')
window.console.log('drop selected', nameEl)
if (!nameEl) return
nameEl.classList.remove('selected')
dropOnCard(id, nameEl.id)
}
function dropOnCard(id, data) {
let target = document.getElementById('card-' + id)
target.appendChild(document.getElementById(data))
// if this already has a name, remove that name
if (target.dataset.name) {
returnToNameBank(target.dataset.name)
}
// remove name data from a previous card if there is one
let prevContainer = document.querySelector('[data-name=' + data + ']')
if (prevContainer) {
prevContainer.dataset.name = ''
} else {
wordsLeft -= 1
imagesLeft -= 1
setWordsLeft()
}
target.dataset.name = data
}
function setWordsLeft() {
document.getElementById('words-left').innerText =
'Unused Card Names: ' + wordsLeft + '/Images: ' + imagesLeft
}

559
web/public/mtg/guess.html Normal file
View File

@ -0,0 +1,559 @@
<!DOCTYPE html>
<html>
<head>
<!-- Google Tag Manager -->
<script>
;(function (w, d, s, l, i) {
w[l] = w[l] || []
w[l].push({
'gtm.start': new Date().getTime(),
event: 'gtm.js',
})
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
dl = l !== 'dataLayer' ? '&l=' + l : ''
j.async = true
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl
f.parentNode.insertBefore(j, f)
})(window, document, 'script', 'dataLayer', 'GTM-M3MBVGG')
</script>
<!-- End Google Tag Manager -->
<meta charset="UTF-8" />
<script type="text/javascript" src="app.js"></script>
<style type="text/css">
body {
position: relative;
}
.play-page {
display: flex;
flex-direction: row-reverse;
font-family: Georgia, 'Times New Roman', Times, serif;
}
h1 {
font-family: Verdana, Geneva, Tahoma, sans-serif;
text-align: center;
}
form {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-right: 240px;
}
.cards-container {
display: flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: center;
}
.card {
width: 230px;
height: 208px;
border: 5px solid lightgrey;
margin: 5px;
align-items: flex-end;
box-sizing: border-box;
border-radius: 11px;
position: relative;
display: flex;
justify-content: center;
/*background-size: contain;*/
background-size: 220px;
background-repeat: no-repeat;
transition: height 1s, background-image 1s, border 0.4s 0.6s;
background-position-y: calc(50% - 18px);
}
.card:not([data-name^='name'])::after {
content: '';
height: 34px;
background: white;
width: 100%;
}
.answer-page .card {
height: 350px;
/*padding-top: 310px;*/
/*background-size: cover;*/
overflow: hidden;
border-color: rgb(0, 146, 156);
}
.answer-page .card.incorrect {
border-color: rgb(216, 27, 96);
}
.names-bank {
position: fixed;
padding: 10px 10px 40px;
}
.names-bank .name {
margin: 6px 0;
}
.answer-page .names-bank .name {
display: none;
}
.answer-page .names-bank .word-count {
display: none;
}
.word-count {
text-align: center;
font-style: italic;
color: #444;
}
.score {
width: 100%;
text-align: center;
background-color: rgb(255, 193, 7);
width: 200px;
font-family: Verdana, Geneva, Tahoma, sans-serif;
opacity: 0;
}
.names-bank .score {
overflow: hidden;
height: 0;
}
.answer-page .names-bank .score {
height: auto;
display: block;
opacity: 1;
transition: opacity 1.2s 0.2s;
padding: 20px;
}
.name {
width: 230px;
min-height: 36px;
border-radius: 2px;
background-color: lightgrey;
padding: 8px 12px 2px;
box-sizing: border-box;
}
.card .name {
border-radius: 0 0 5px 5px;
}
#submit {
margin-top: 10px;
padding: 8px 20px;
background-color: cadetblue;
border: none;
border-radius: 3px;
font-size: 1.1em;
color: white;
cursor: pointer;
}
#submit:hover {
background-color: rgb(0, 146, 156);
}
#newGame {
padding: 8px 20px;
background-color: lightpink;
border: none;
position: absolute;
top: 5px;
left: 20px;
border-radius: 3px;
font-size: 0.7em;
cursor: pointer;
}
#newGame:hover {
background-color: coral;
}
.selected {
background-color: orange;
}
@media screen and (orientation: landscape) and (max-height: 680px) {
/* CSS applied when the device is in landscape mode*/
.names-bank {
padding: 0;
top: 0;
max-height: 100vh;
overflow: scroll;
}
body {
font-size: 20px;
}
.word-count {
font-size: 14px;
}
h1 {
margin-right: 240px;
}
}
@media screen and (orientation: portrait) and (max-width: 1100px) {
body {
font-size: 1.8em;
}
.play-page {
flex-direction: column;
}
.names-bank {
flex-direction: row;
display: flex;
flex-wrap: wrap;
/* position: fixed; */
padding: 10px 10px 40px;
position: sticky;
top: 0;
z-index: 100;
background: white;
}
.answer-page .names-bank {
min-width: 100%;
justify-content: center;
}
form {
margin: 0;
}
.names-bank .name {
margin: 6px;
}
.names-bank .score {
width: 0;
}
.answer-page .names-bank .score {
width: auto;
}
.word-count {
position: absolute;
margin-top: -20px;
}
.name {
width: 300px;
}
.card {
width: 300px;
background-size: 300px;
height: 266px;
}
.answer-page .card {
height: 454px;
}
}
</style>
</head>
<body>
<!-- Google Tag Manager (noscript) -->
<noscript>
<iframe
src="https://www.googletagmanager.com/ns.html?id=GTM-M3MBVGG"
height="0"
width="0"
style="display: none; visibility: hidden"
></iframe>
</noscript>
<!-- End Google Tag Manager (noscript) -->
<h1><span id="guess-type"></span>: <span id="round-number"></span></h1>
<div class="play-page">
<div
class="names-bank"
ondrop="returnDrop(event)"
ondragover="event.preventDefault()"
>
<div class="score">
YOUR SCORE
<div>Correct Answers This Round: <span id="score-amount"></span></div>
<div>
Correct Answers In Total: <span id="score-amount-total"></span>
</div>
<div>Overall Percent: <span id="score-percent"></span>%</div>
</div>
<div class="word-count"><span id="words-left"></span></div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-1"
>
Name 1
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-2"
>
Name 2
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-3"
>
Name 3
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-4"
>
Name 4
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-5"
>
Name 5
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-6"
>
Name 6
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-7"
>
Name 7
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-8"
>
Name 8
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-9"
>
Name 9
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-10"
>
Name 10
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-11"
>
Name 11
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-12"
>
Name 12
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-13"
>
Name 13
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-14"
>
Name 14
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-15"
>
Name 15
</div>
</div>
<form onsubmit="toggleMode(event)">
<div class="cards-container">
<div
class="card"
ondrop="drop(event,1)"
ondragover="allowDrop(event,1)"
onclick="dropSelected(event, 1)"
id="card-1"
></div>
<div
class="card"
ondrop="drop(event,2)"
ondragover="allowDrop(event,2)"
onclick="dropSelected(event, 2)"
id="card-2"
></div>
<div
class="card"
ondrop="drop(event,3)"
ondragover="allowDrop(event,3)"
onclick="dropSelected(event, 3)"
id="card-3"
></div>
<div
class="card"
ondrop="drop(event,4)"
ondragover="allowDrop(event,4)"
onclick="dropSelected(event, 4)"
id="card-4"
></div>
<div
class="card"
ondrop="drop(event,5)"
ondragover="allowDrop(event,5)"
onclick="dropSelected(event, 5)"
id="card-5"
></div>
<div
class="card"
ondrop="drop(event, 6)"
ondragover="allowDrop(event,6)"
onclick="dropSelected(event,6)"
id="card-6"
></div>
<div
class="card"
ondrop="drop(event,7)"
ondragover="allowDrop(event,7)"
onclick="dropSelected(event, 7)"
id="card-7"
></div>
<div
class="card"
ondrop="drop(event,8)"
ondragover="allowDrop(event,8)"
onclick="dropSelected(event, 8)"
id="card-8"
></div>
<div
class="card"
ondrop="drop(event,9)"
ondragover="allowDrop(event,9)"
onclick="dropSelected(event, 9)"
id="card-9"
></div>
<div
class="card"
ondrop="drop(event,10)"
ondragover="allowDrop(event,10)"
onclick="dropSelected(event, 10)"
id="card-10"
></div>
<div
class="card"
ondrop="drop(event,11)"
ondragover="allowDrop(event,11)"
onclick="dropSelected(event, 11)"
id="card-11"
></div>
<div
class="card"
ondrop="drop(event,12)"
ondragover="allowDrop(event,12)"
onclick="dropSelected(event, 12)"
id="card-12"
></div>
</div>
<input type="submit" id="submit" value="Submit" />
</form>
</div>
<div style="position: absolute; top: 0; left: 0; right: 0; color: grey">
<form method="get" action="index.html">
<input type="submit" id="newGame" value="New Game" />
</form>
</div>
<div style="margin: -40px 0 0; height: 60px">
<a href="https://paypal.me/idamayer">Donate, buy us a boba 🧋</a>
</div>
<div
style="
font-size: 0.9em;
position: absolute;
bottom: 0;
left: 0;
right: 0;
color: grey;
font-style: italic;
"
>
made by
<a
style="color: rgb(0, 146, 156); font-style: italic"
href="https://idamayer.com"
>Ida Mayer</a
>
&
<a
style="color: rgb(0, 146, 156); font-style: italic"
href="mailto:alexlien.alien@gmail.com"
>Alex Lien</a
>, 2022
</div>
</body>
</html>

View File

@ -0,0 +1,92 @@
import time
import requests
import json
# add category name here
allCategories = ['counterspell', 'beast', 'terror', 'wrath', 'burn']
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'
elif category == 'beast':
string_query += '-type%3Alegendary+type%3Abeast+-type%3Atoken'
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'
elif category == 'wrath':
string_query += 'otag%3Asweeper-creature+%28t%3Ainstant+or+t%3Asorcery%29+not%3Aadventure'
elif category == 'burn':
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'
# 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' \
'+-frame%3Aextendedart+language%3Aenglish&unique=art&page='
print(string_query)
return string_query
def fetch_and_write_all(category, query):
count = 1
will_repeat = True
while will_repeat:
will_repeat = fetch_and_write(category, query, count)
count += 1
def fetch_and_write(category, query, count):
query += str(count)
response = requests.get(f"{query}").json()
time.sleep(0.1)
with open('jsons/' + category + str(count) + '.json', 'w') as f:
json.dump(to_compact_write_form(response), f)
return response['has_more']
def to_compact_write_form(response):
fieldsToUse = ['has_more']
fieldsInCard = ['name', 'image_uris', 'content_warning', 'flavor_name', 'reprint', 'frame_effects', 'digital',
'set_type']
smallJson = dict()
data = []
# write all fields needed in response
for field in fieldsToUse:
smallJson[field] = response[field]
# write all fields needed in card
for card in response['data']:
write_card = dict()
for field in fieldsInCard:
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'])
elif field in card:
write_card[field] = card[field]
data.append(write_card)
smallJson['data'] = data
return smallJson
# only write images needed
def write_image_uris(card_image_uris):
image_uris = dict()
if 'normal' in card_image_uris:
image_uris['normal'] = card_image_uris['normal']
elif 'large' in card_image_uris:
image_uris['normal'] = card_image_uris['large']
elif 'small' in card_image_uris:
image_uris['normal'] = card_image_uris['small']
if card_image_uris:
image_uris['art_crop'] = card_image_uris['art_crop']
return image_uris
if __name__ == "__main__":
for category in allCategories:
print(category)
fetch_and_write_all(category, generate_initial_query(category))

202
web/public/mtg/index.html Normal file
View File

@ -0,0 +1,202 @@
<!DOCTYPE html>
<html>
<head>
<!-- Google Tag Manager -->
<script>
;(function (w, d, s, l, i) {
w[l] = w[l] || []
w[l].push({
'gtm.start': new Date().getTime(),
event: 'gtm.js',
})
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
dl = l !== 'dataLayer' ? '&l=' + l : ''
j.async = true
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl
f.parentNode.insertBefore(j, f)
})(window, document, 'script', 'dataLayer', 'GTM-M3MBVGG')
</script>
<!-- End Google Tag Manager -->
<meta charset="UTF-8" />
<style type="text/css">
body {
position: relative;
}
.play-page {
display: flex;
flex-direction: row-reverse;
font-family: Georgia, 'Times New Roman', Times, serif;
min-height: 200px;
}
h1,
h3 {
font-family: Verdana, Geneva, Tahoma, sans-serif;
text-align: center;
}
#submit {
margin-top: 10px;
padding: 8px 20px;
background-color: cadetblue;
border: none;
border-radius: 3px;
font-size: 1.1em;
color: white;
cursor: pointer;
}
#submit:hover {
background-color: rgb(0, 146, 156);
}
[type='radio'] {
display: none;
}
[type='radio'] + label.radio-label {
background: lightgrey;
display: block;
padding: 10px;
border-radius: 4px;
cursor: pointer;
}
label.radio-label:hover {
background: darkgrey;
}
[type='radio']:checked + label.radio-label {
background: lightcoral;
}
.radio-label h3 {
margin: 0;
display: inline-block;
vertical-align: middle;
width: 220px;
}
.thumbnail {
display: inline-block;
vertical-align: middle;
width: 67px;
height: 48px;
margin-right: 4px;
}
body {
padding: 70px 0 30px;
}
#addl-options {
position: absolute;
top: 30px;
right: 30px;
background-color: white;
padding: 10px;
cursor: pointer;
width: 200px;
}
#addl-options > summary {
list-style: none;
text-align: right;
}
</style>
</head>
<body>
<!-- Google Tag Manager (noscript) -->
<noscript>
<iframe
src="https://www.googletagmanager.com/ns.html?id=GTM-M3MBVGG"
height="0"
width="0"
style="display: none; visibility: hidden"
></iframe>
</noscript>
<!-- End Google Tag Manager (noscript) -->
<h1>Magic the Guessering</h1>
<div class="play-page" style="justify-content: center">
<form
method="get"
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"
/>
<h3>Counterspell Guesser</h3></label
><br />
<input type="radio" id="burn" name="whichguesser" value="burn" />
<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
><br />
<details id="addl-options">
<summary>
<img
src="http://mythicspoiler.com/images/buttons/ustset.png"
style="width: 32px; vertical-align: top"
/>
Options
</summary>
<input type="checkbox" name="digital" id="digital" checked />
<label for="digital">include digital cards</label>
<br />
<input type="checkbox" name="un" id="un" checked />
<label for="un">include un-cards</label>
<br />
<input type="checkbox" name="original" id="original" />
<label for="original">restrict to only original printing</label>
</details>
<input type="submit" id="submit" value="Play" />
</form>
</div>
<div style="margin: -40px 0 0; height: 60px">
<a href="https://paypal.me/idamayer">Donate, buy us a boba 🧋</a>
</div>
<div
style="
font-size: 0.9em;
position: absolute;
bottom: 0;
left: 0;
right: 0;
color: grey;
font-style: italic;
"
>
made by
<a
style="color: rgb(0, 146, 156); font-style: italic"
href="https://idamayer.com"
>Ida Mayer</a
>
&
<a
style="color: rgb(0, 146, 156); font-style: italic"
href="mailto:alexlien.alien@gmail.com"
>Alex Lien</a
>, 2022
</div>
</body>
</html>

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

@ -1,4 +1,5 @@
const defaultTheme = require('tailwindcss/defaultTheme') const defaultTheme = require('tailwindcss/defaultTheme')
const plugin = require('tailwindcss/plugin')
module.exports = { module.exports = {
content: [ content: [
@ -32,6 +33,22 @@ module.exports = {
require('@tailwindcss/typography'), require('@tailwindcss/typography'),
require('@tailwindcss/line-clamp'), require('@tailwindcss/line-clamp'),
require('daisyui'), require('daisyui'),
plugin(function ({ addUtilities }) {
addUtilities({
'.scrollbar-hide': {
/* IE and Edge */
'-ms-overflow-style': 'none',
/* Firefox */
'scrollbar-width': 'none',
/* Safari and Chrome */
'&::-webkit-scrollbar': {
display: 'none',
},
},
})
}),
], ],
daisyui: { daisyui: {
themes: [ themes: [

178
yarn.lock
View File

@ -2385,10 +2385,10 @@
resolved "https://registry.yarnpkg.com/@mdx-js/util/-/util-1.6.22.tgz#219dfd89ae5b97a8801f015323ffa4b62f45718b" resolved "https://registry.yarnpkg.com/@mdx-js/util/-/util-1.6.22.tgz#219dfd89ae5b97a8801f015323ffa4b62f45718b"
integrity sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA== integrity sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA==
"@next/env@12.1.2": "@next/env@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/env/-/env-12.1.2.tgz#4b0f5fd448ac60b821d2486d2987948e3a099f03" resolved "https://registry.yarnpkg.com/@next/env/-/env-12.2.2.tgz#cc1a0a445bd254499e30f632968c03192455f4cc"
integrity sha512-A/P4ysmFScBFyu1ZV0Mr1Y89snyQhqGwsCrkEpK+itMF+y+pMqBoPVIyakUf4LXqGWJGiGFuIerihvSG70Ad8Q== integrity sha512-BqDwE4gDl1F608TpnNxZqrCn6g48MBjvmWFEmeX5wEXDXh3IkAOw6ASKUgjT8H4OUePYFqghDFUss5ZhnbOUjw==
"@next/eslint-plugin-next@12.1.6": "@next/eslint-plugin-next@12.1.6":
version "12.1.6" version "12.1.6"
@ -2397,65 +2397,70 @@
dependencies: dependencies:
glob "7.1.7" glob "7.1.7"
"@next/swc-android-arm-eabi@12.1.2": "@next/swc-android-arm-eabi@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.1.2.tgz#675e952d9032ac7bec02f3f413c17d33bbd90857" resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.2.2.tgz#f6c4111e6371f73af6bf80c9accb3d96850a92cd"
integrity sha512-iwalfLBhYmCIlj09czFbovj1SmTycf0AGR8CB357wgmEN8xIuznIwSsCH87AhwQ9apfNtdeDhxvuKmhS9T3FqQ== integrity sha512-VHjuCHeq9qCprUZbsRxxM/VqSW8MmsUtqB5nEpGEgUNnQi/BTm/2aK8tl7R4D0twGKRh6g1AAeFuWtXzk9Z/vQ==
"@next/swc-android-arm64@12.1.2": "@next/swc-android-arm64@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.1.2.tgz#d9710c50853235f258726b19a649df9c29a49682" resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.2.2.tgz#b69de59c51e631a7600439e7a8993d6e82f3369e"
integrity sha512-ZoR0Vx7czJhTgRAcFbzTKQc2n2ChC036/uc6PbgYiI/LreEnfmsV/CiREP0pUVs5ndntOX8kBA3BSbh4zCO5tQ== integrity sha512-v5EYzXUOSv0r9mO/2PX6mOcF53k8ndlu9yeFHVAWW1Dhw2jaJcvTRcCAwYYN8Q3tDg0nH3NbEltJDLKmcJOuVA==
"@next/swc-darwin-arm64@12.1.2": "@next/swc-darwin-arm64@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.1.2.tgz#aadd21b711c82b3efa9b4ecf7665841259e1fa7e" resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.2.2.tgz#80157c91668eff95b72d052428c353eab0fc4c50"
integrity sha512-VXv7lpqFjHwkK65CZHkjvBxlSBTG+l3O0Zl2zHniHj0xHzxJZvR8VFjV2zIMZCYSfVqeQ5yt2rjwuQ9zbpGtXQ== integrity sha512-JCoGySHKGt+YBk7xRTFGx1QjrnCcwYxIo3yGepcOq64MoiocTM3yllQWeOAJU2/k9MH0+B5E9WUSme4rOCBbpA==
"@next/swc-darwin-x64@12.1.2": "@next/swc-darwin-x64@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.1.2.tgz#3b1a389828f5c88ecb828a6394692fdeaf175081" resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.2.2.tgz#12be2f58e676fccff3d48a62921b9927ed295133"
integrity sha512-evXxJQnXEnU+heWyun7d0UV6bhBcmoiyFGR3O3v9qdhGbeXh+SXYVxRO69juuh6V7RWRdlb1KQ0rGUNa1k0XSw== integrity sha512-dztDtvfkhUqiqpXvrWVccfGhLe44yQ5tQ7B4tBfnsOR6vxzI9DNPHTlEOgRN9qDqTAcFyPxvg86mn4l8bB9Jcw==
"@next/swc-linux-arm-gnueabihf@12.1.2": "@next/swc-freebsd-x64@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.1.2.tgz#db4371ca716bf94c94d4f6b001ac3c9d08d97d79" resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.2.2.tgz#de1363431a49059f1efb8c0f86ce6a79c53b3a95"
integrity sha512-LJV/wo6R0Ot7Y/20bZs00aBG4J333RT6H/5Q2AROE4Hnx7cenSktSnfU6WCnJgzYLSIHdbLs549LcZMULuVquw== integrity sha512-JUnXB+2xfxqsAvhFLPJpU1NeyDsvJrKoOjpV7g3Dxbno2Riu4tDKn3kKF886yleAuD/1qNTUCpqubTvbbT2VoA==
"@next/swc-linux-arm64-gnu@12.1.2": "@next/swc-linux-arm-gnueabihf@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.1.2.tgz#0e71db03b8b12ed315c8be7d15392ecefe562b7c" resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.2.2.tgz#d5b8e0d1bb55bbd9db4d2fec018217471dc8b9e6"
integrity sha512-fjlYU1Y8kVjjRKyuyQBYLHPxjGOS2ox7U8TqAvtgKvd2PxqdsgW4sP+VDovRVPrZlGXNllKoJiqMO1OoR9fB6w== integrity sha512-XeYC/qqPLz58R4pjkb+x8sUUxuGLnx9QruC7/IGkK68yW4G17PHwKI/1njFYVfXTXUukpWjcfBuauWwxp9ke7Q==
"@next/swc-linux-arm64-musl@12.1.2": "@next/swc-linux-arm64-gnu@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.1.2.tgz#f1b055793da1c12167ed3b6e32aef8289721a1fb" resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.2.2.tgz#3bc75984e1d5ec8f59eb53702cc382d8e1be2061"
integrity sha512-Y1JRDMHqSjLObjyrD1hf6ePrJcOF/mkw+LbAzoNgrHL1dSuIAqcz3jYunJt8T7Yw48xSJy6LPSL9BclAHwEwOA== integrity sha512-d6jT8xgfKYFkzR7J0OHo2D+kFvY/6W8qEo6/hmdrTt6AKAqxs//rbbcdoyn3YQq1x6FVUUd39zzpezZntg9Naw==
"@next/swc-linux-x64-gnu@12.1.2": "@next/swc-linux-arm64-musl@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.1.2.tgz#69764ffaacb3b9b373897fff15d7dd871455efe2" resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.2.2.tgz#270db73e07a18d999f61e79a917943fa5bc1ef56"
integrity sha512-5N4QSRT60ikQqCU8iHfYZzlhg6MFTLsKhMTARmhn8wLtZfN9VVyTFwZrJQWjV64dZc4JFeXDANGao8fm55y6bw== integrity sha512-rIZRFxI9N/502auJT1i7coas0HTHUM+HaXMyJiCpnY8Rimbo0495ir24tzzHo3nQqJwcflcPTwEh/DV17sdv9A==
"@next/swc-linux-x64-musl@12.1.2": "@next/swc-linux-x64-gnu@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.1.2.tgz#0ddaedb5ec578c01771f83be2046dafb2f70df91" resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.2.2.tgz#e6c72fa20478552e898c434f4d4c0c5e89d2ea78"
integrity sha512-b32F/xAgdYG4Pt0foFzhF+2uhvNxnEj7aJNp1R4EhZotdej2PzvFWcP/dGkc7MJl205pBz5oC3gHyILIIlW6XA== integrity sha512-ir1vNadlUDj7eQk15AvfhG5BjVizuCHks9uZwBfUgT5jyeDCeRvaDCo1+Q6+0CLOAnYDR/nqSCvBgzG2UdFh9A==
"@next/swc-win32-arm64-msvc@12.1.2": "@next/swc-linux-x64-musl@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.1.2.tgz#9e17ed56d5621f8c6961193da3a0b155cea511c9" resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.2.2.tgz#b9ef9efe2c401839cdefa5e70402386aafdce15a"
integrity sha512-hVOcGmWDeVwO00Aclopsj6MoYhfJl5zA4vjAai9KjgclQTFZa/DC0vQjgKAHHKGT5oMHgjiq/G7L6P1/UfwYnw== integrity sha512-bte5n2GzLN3O8JdSFYWZzMgEgDHZmRz5wiispiiDssj4ik3l8E7wq/czNi8RmIF+ioj2sYVokUNa/ekLzrESWw==
"@next/swc-win32-ia32-msvc@12.1.2": "@next/swc-win32-arm64-msvc@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.1.2.tgz#ddd260cbe8bc4002fb54415b80baccf37f8db783" resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.2.2.tgz#18fa7ec7248da3a7926a0601d9ececc53ac83157"
integrity sha512-wnVDGIVz2pR3vIkyN6IE+1NvMSBrBj1jba11iR16m8TAPzZH/PrNsxr0a9N5VavEXXLcQpoUVvT+N7nflbRAHg== integrity sha512-ZUGCmcDmdPVSAlwJ/aD+1F9lYW8vttseiv4n2+VCDv5JloxiX9aY32kYZaJJO7hmTLNrprvXkb4OvNuHdN22Jg==
"@next/swc-win32-x64-msvc@12.1.2": "@next/swc-win32-ia32-msvc@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.1.2.tgz#37412a314bcf4c6006a74e1ef9764048344f3848" resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.2.2.tgz#54936e84f4a219441d051940354da7cd3eafbb4f"
integrity sha512-MLNcurEpQp0+7OU9261f7PkN52xTGkfrt4IYTIXau7DO/aHj927oK6piIJdl9EOHdX/KN5W6qlyErj170PSHtw== integrity sha512-v7ykeEDbr9eXiblGSZiEYYkWoig6sRhAbLKHUHQtk8vEWWVEqeXFcxmw6LRrKu5rCN1DY357UlYWToCGPQPCRA==
"@next/swc-win32-x64-msvc@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.2.2.tgz#7460be700a60d75816f01109400b51fe929d7e89"
integrity sha512-2D2iinWUL6xx8D9LYVZ5qi7FP6uLAoWymt8m8aaG2Ld/Ka8/k723fJfiklfuAcwOxfufPJI+nRbT5VcgHGzHAQ==
"@nivo/annotations@0.74.0": "@nivo/annotations@0.74.0":
version "0.74.0" version "0.74.0"
@ -2837,6 +2842,13 @@
"@svgr/plugin-jsx" "^6.2.1" "@svgr/plugin-jsx" "^6.2.1"
"@svgr/plugin-svgo" "^6.2.0" "@svgr/plugin-svgo" "^6.2.0"
"@swc/helpers@0.4.2":
version "0.4.2"
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.2.tgz#ed1f6997ffbc22396665d9ba74e2a5c0a2d782f8"
integrity sha512-556Az0VX7WR6UdoTn4htt/l3zPQ7bsQWK+HqdG4swV7beUCxo/BqmvbOpUkTIm/9ih86LIf1qsUnywNL3obGHw==
dependencies:
tslib "^2.4.0"
"@szmarczak/http-timer@^1.1.2": "@szmarczak/http-timer@^1.1.2":
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421"
@ -4290,7 +4302,7 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001335:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001344.tgz#8a1e7fdc4db9c2ec79a05e9fd68eb93a761888bb" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001344.tgz#8a1e7fdc4db9c2ec79a05e9fd68eb93a761888bb"
integrity sha512-0ZFjnlCaXNOAYcV7i+TtdKBp0L/3XEU2MF/x6Du1lrh+SRX4IfzIVL4HNJg5pB2PmFb8rszIGyOvsZnqqRoc2g== integrity sha512-0ZFjnlCaXNOAYcV7i+TtdKBp0L/3XEU2MF/x6Du1lrh+SRX4IfzIVL4HNJg5pB2PmFb8rszIGyOvsZnqqRoc2g==
caniuse-lite@^1.0.30001230, caniuse-lite@^1.0.30001283, caniuse-lite@^1.0.30001332: caniuse-lite@^1.0.30001230, caniuse-lite@^1.0.30001332:
version "1.0.30001341" version "1.0.30001341"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001341.tgz#59590c8ffa8b5939cf4161f00827b8873ad72498" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001341.tgz#59590c8ffa8b5939cf4161f00827b8873ad72498"
integrity sha512-2SodVrFFtvGENGCv0ChVJIDQ0KPaS1cg7/qtfMaICgeMolDdo/Z2OD32F0Aq9yl6F4YFwGPBS5AaPqNYiW4PoA== integrity sha512-2SodVrFFtvGENGCv0ChVJIDQ0KPaS1cg7/qtfMaICgeMolDdo/Z2OD32F0Aq9yl6F4YFwGPBS5AaPqNYiW4PoA==
@ -8320,29 +8332,31 @@ next-sitemap@^2.5.14:
"@corex/deepmerge" "^2.6.148" "@corex/deepmerge" "^2.6.148"
minimist "^1.2.6" minimist "^1.2.6"
next@12.1.2: next@12.2.2:
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/next/-/next-12.1.2.tgz#c5376a8ae17d3e404a2b691c01f94c8943306f29" resolved "https://registry.yarnpkg.com/next/-/next-12.2.2.tgz#029bf5e4a18a891ca5d05b189b7cd983fd22c072"
integrity sha512-JHPCsnFTBO0Z4SQxSYc611UA1WA+r/3y3Neg66AH5/gSO/oksfRnFw/zGX/FZ9+oOUHS9y3wJFawNpVYR2gJSQ== integrity sha512-zAYFY45aBry/PlKONqtlloRFqU/We3zWYdn2NoGvDZkoYUYQSJC8WMcalS5C19MxbCZLUVCX7D7a6gTGgl2yLg==
dependencies: dependencies:
"@next/env" "12.1.2" "@next/env" "12.2.2"
caniuse-lite "^1.0.30001283" "@swc/helpers" "0.4.2"
caniuse-lite "^1.0.30001332"
postcss "8.4.5" postcss "8.4.5"
styled-jsx "5.0.1" styled-jsx "5.0.2"
use-subscription "1.5.1" use-sync-external-store "1.1.0"
optionalDependencies: optionalDependencies:
"@next/swc-android-arm-eabi" "12.1.2" "@next/swc-android-arm-eabi" "12.2.2"
"@next/swc-android-arm64" "12.1.2" "@next/swc-android-arm64" "12.2.2"
"@next/swc-darwin-arm64" "12.1.2" "@next/swc-darwin-arm64" "12.2.2"
"@next/swc-darwin-x64" "12.1.2" "@next/swc-darwin-x64" "12.2.2"
"@next/swc-linux-arm-gnueabihf" "12.1.2" "@next/swc-freebsd-x64" "12.2.2"
"@next/swc-linux-arm64-gnu" "12.1.2" "@next/swc-linux-arm-gnueabihf" "12.2.2"
"@next/swc-linux-arm64-musl" "12.1.2" "@next/swc-linux-arm64-gnu" "12.2.2"
"@next/swc-linux-x64-gnu" "12.1.2" "@next/swc-linux-arm64-musl" "12.2.2"
"@next/swc-linux-x64-musl" "12.1.2" "@next/swc-linux-x64-gnu" "12.2.2"
"@next/swc-win32-arm64-msvc" "12.1.2" "@next/swc-linux-x64-musl" "12.2.2"
"@next/swc-win32-ia32-msvc" "12.1.2" "@next/swc-win32-arm64-msvc" "12.2.2"
"@next/swc-win32-x64-msvc" "12.1.2" "@next/swc-win32-ia32-msvc" "12.2.2"
"@next/swc-win32-x64-msvc" "12.2.2"
no-case@^3.0.4: no-case@^3.0.4:
version "3.0.4" version "3.0.4"
@ -10892,10 +10906,10 @@ style-to-object@0.3.0, style-to-object@^0.3.0:
dependencies: dependencies:
inline-style-parser "0.1.1" inline-style-parser "0.1.1"
styled-jsx@5.0.1: styled-jsx@5.0.2:
version "5.0.1" version "5.0.2"
resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.1.tgz#78fecbbad2bf95ce6cd981a08918ce4696f5fc80" resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.2.tgz#ff230fd593b737e9e68b630a694d460425478729"
integrity sha512-+PIZ/6Uk40mphiQJJI1202b+/dYeTVd9ZnMPR80pgiWbjIwvN2zIp4r9et0BgqBuShh48I0gttPlAXA7WVvBxw== integrity sha512-LqPQrbBh3egD57NBcHET4qcgshPks+yblyhPlH2GY8oaDgKs8SK4C3dBh3oSJjgzJ3G5t1SYEZGHkP+QEpX9EQ==
stylehacks@^5.1.0: stylehacks@^5.1.0:
version "5.1.0" version "5.1.0"
@ -11437,12 +11451,10 @@ use-latest@^1.2.1:
dependencies: dependencies:
use-isomorphic-layout-effect "^1.1.1" use-isomorphic-layout-effect "^1.1.1"
use-subscription@1.5.1: use-sync-external-store@1.1.0:
version "1.5.1" version "1.1.0"
resolved "https://registry.yarnpkg.com/use-subscription/-/use-subscription-1.5.1.tgz#73501107f02fad84c6dd57965beb0b75c68c42d1" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.1.0.tgz#3343c3fe7f7e404db70f8c687adf5c1652d34e82"
integrity sha512-Xv2a1P/yReAjAbhylMfFplFKj9GssgTwN7RlcTxBujFQcloStWNDQdc4g4NRWH9xS4i/FDk04vQBptAXoF3VcA== integrity sha512-SEnieB2FPKEVne66NpXPd1Np4R1lTNKfjuy3XdIoPQKYBAFdzbzSZlSn1KJZUiihQLQC5Znot4SBz1EOTBwQAQ==
dependencies:
object-assign "^4.1.1"
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
version "1.0.2" version "1.0.2"