Merge branch 'main' into challenges

This commit is contained in:
Ian Philips 2022-07-21 15:35:16 -06:00 committed by GitHub
commit 87df5a0783
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 2145 additions and 707 deletions

View File

@ -1,6 +1,7 @@
import { difference } from 'lodash'
export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default'
export const CATEGORIES = {
politics: 'Politics',
technology: 'Technology',
@ -37,3 +38,8 @@ export const EXCLUDED_CATEGORIES: category[] = [
]
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

@ -3,4 +3,4 @@ export const NUMERIC_FIXED_VAR = 0.005
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
export const UNIQUE_BETTOR_BONUS_AMOUNT = 5
export const UNIQUE_BETTOR_BONUS_AMOUNT = 10

View File

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

View File

@ -0,0 +1,77 @@
import { createContext, useEffect } from 'react'
import { User } from 'common/user'
import { onIdTokenChanged } from 'firebase/auth'
import {
auth,
listenForUser,
getUser,
setCachedReferralInfoForUser,
} from 'web/lib/firebase/users'
import { deleteAuthCookies, setAuthCookies } from 'web/lib/firebase/auth'
import { createUser } from 'web/lib/firebase/api'
import { randomString } from 'common/util/random'
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
import { useStateCheckEquality } from 'web/hooks/use-state-check-equality'
// Either we haven't looked up the logged in user yet (undefined), or we know
// the user is not logged in (null), or we know the user is logged in (User).
type AuthUser = undefined | null | User
const CACHED_USER_KEY = 'CACHED_USER_KEY'
const ensureDeviceToken = () => {
let deviceToken = localStorage.getItem('device-token')
if (!deviceToken) {
deviceToken = randomString()
localStorage.setItem('device-token', deviceToken)
}
return deviceToken
}
export const AuthContext = createContext<AuthUser>(null)
export function AuthProvider({ children }: any) {
const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(undefined)
useEffect(() => {
const cachedUser = localStorage.getItem(CACHED_USER_KEY)
setAuthUser(cachedUser && JSON.parse(cachedUser))
}, [setAuthUser])
useEffect(() => {
return onIdTokenChanged(auth, async (fbUser) => {
if (fbUser) {
let user = await getUser(fbUser.uid)
if (!user) {
const deviceToken = ensureDeviceToken()
user = (await createUser({ deviceToken })) as User
}
setAuthUser(user)
// Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(user))
setCachedReferralInfoForUser(user)
setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken)
} else {
// User logged out; reset to null
setAuthUser(null)
localStorage.removeItem(CACHED_USER_KEY)
deleteAuthCookies()
}
})
}, [setAuthUser])
const authUserId = authUser?.id
const authUsername = authUser?.username
useEffect(() => {
if (authUserId && authUsername) {
identifyUser(authUserId)
setUserProperty('username', authUsername)
return listenForUser(authUserId, setAuthUser)
}
}, [authUserId, authUsername, setAuthUser])
return (
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>
)
}

View File

@ -78,10 +78,10 @@ export function BetsList(props: {
const getTime = useTimeSinceFirstRender()
useEffect(() => {
if (bets && contractsById) {
trackLatency('portfolio', getTime())
if (bets && contractsById && signedInUser) {
trackLatency(signedInUser.id, 'portfolio', getTime())
}
}, [bets, contractsById, getTime])
}, [signedInUser, bets, contractsById, getTime])
if (!bets || !contractsById) {
return <LoadingIndicator />

View File

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

View File

@ -22,12 +22,13 @@ import { Spacer } from './layout/spacer'
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useUser } from 'web/hooks/use-user'
import { useFollows } from 'web/hooks/use-follows'
import { trackCallback } from 'web/lib/service/analytics'
import { track, trackCallback } from 'web/lib/service/analytics'
import ContractSearchFirestore from 'web/pages/contract-search-firestore'
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 { toPairs } from 'lodash'
import { sortBy } from 'lodash'
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
const searchClient = algoliasearch(
'GJQPAYENIF',
@ -49,13 +50,6 @@ const sortIndexes = [
export const DEFAULT_SORT = 'score'
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: {
querySortOptions?: {
@ -86,9 +80,24 @@ export function ContractSearch(props: {
} = props
const user = useUser()
const memberGroupSlugs = useMemberGroups(user?.id)
?.map((g) => g.slug)
.filter((s) => !NEW_USER_GROUP_SLUGS.includes(s))
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
(group) => !NEW_USER_GROUP_SLUGS.includes(group.slug)
)
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 { initialSort } = useInitialQueryAndSort(querySortOptions)
@ -101,24 +110,20 @@ export function ContractSearch(props: {
const [filter, setFilter] = useState<filter>(
querySortOptions?.defaultFilter ?? 'open'
)
const pillsEnabled = !additionalFilter
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined)
const selectFilter = (pill: string | undefined) => () => {
setPillFilter(pill)
track('select search category', { category: pill ?? 'all' })
}
const { filters, numericFilters } = useMemo(() => {
let filters = [
filter === 'open' ? 'isResolved:false' : '',
filter === 'closed' ? 'isResolved:false' : '',
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
? `creatorId:${additionalFilter.creatorId}`
: '',
@ -126,6 +131,26 @@ export function ContractSearch(props: {
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)
// Hack to make Algolia work.
filters = ['', ...filters]
@ -139,8 +164,9 @@ export function ContractSearch(props: {
}, [
filter,
Object.values(additionalFilter ?? {}).join(','),
(memberGroupSlugs ?? []).join(','),
memberGroupSlugs.join(','),
(follows ?? []).join(','),
pillFilter,
])
const indexName = `${indexPrefix}contracts-${sort}`
@ -167,13 +193,24 @@ export function ContractSearch(props: {
}}
/>
{/*// 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', { filter })}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
{!hideOrderSelector && (
<SortBy
items={sortIndexes}
classNames={{
select: '!select !select-bordered',
}}
onBlur={trackCallback('select search sort')}
onBlur={trackCallback('select search sort', { sort })}
/>
)}
<Configure
@ -186,25 +223,50 @@ export function ContractSearch(props: {
<Spacer h={3} />
<Row className="gap-2">
{toPairs<filter>(filterOptions).map(([label, f]) => {
return (
<PillButton
key={f}
selected={filter === f}
onSelect={() => setFilter(f)}
>
{label}
</PillButton>
)
})}
</Row>
{pillsEnabled && (
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
<PillButton
key={'all'}
selected={pillFilter === undefined}
onSelect={selectFilter(undefined)}
>
All
</PillButton>
<PillButton
key={'personal'}
selected={pillFilter === 'personal'}
onSelect={selectFilter('personal')}
>
For you
</PillButton>
<PillButton
key={'your-bets'}
selected={pillFilter === 'your-bets'}
onSelect={selectFilter('your-bets')}
>
Your bets
</PillButton>
{pillGroups.map(({ name, slug }) => {
return (
<PillButton
key={slug}
selected={pillFilter === slug}
onSelect={selectFilter(slug)}
>
{name}
</PillButton>
)
})}
</Row>
)}
<Spacer h={3} />
{filter === 'personal' &&
(follows ?? []).length === 0 &&
(memberGroupSlugs ?? []).length === 0 ? (
memberGroupSlugs.length === 0 ? (
<>You're not following anyone, nor in any of your own groups yet.</>
) : (
<ContractSearchInner

View File

@ -11,6 +11,7 @@ import { UserLink } from '../user-page'
import {
Contract,
contractMetrics,
contractPath,
contractPool,
updateContract,
} from 'web/lib/firebase/contracts'
@ -33,6 +34,7 @@ import { ShareIconButton } from 'web/components/share-icon-button'
import { useUser } from 'web/hooks/use-user'
import { Editor } from '@tiptap/react'
import { exhibitExts } from 'common/util/parse'
import { ENV_CONFIG } from 'common/envs/constants'
export type ShowTime = 'resolve-date' | 'close-date'
@ -222,9 +224,12 @@ export function ContractDetails(props: {
<div className="whitespace-nowrap">{volumeLabel}</div>
</Row>
<ShareIconButton
contract={contract}
copyPayload={`https://${ENV_CONFIG.domain}${contractPath(contract)}${
user?.username && contract.creatorUsername !== user?.username
? '?referrer=' + user?.username
: ''
}`}
toastClassName={'sm:-left-40 -left-24 min-w-[250%]'}
username={user?.username}
/>
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}

View File

@ -16,11 +16,10 @@ import { ShareEmbedButton } from '../share-embed-button'
import { Title } from '../title'
import { TweetButton } from '../tweet-button'
import { InfoTooltip } from '../info-tooltip'
import { TagsInput } from 'web/components/tags-input'
import { DuplicateContractButton } from '../copy-contract-button'
export const contractDetailsButtonClassName =
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
@ -141,9 +140,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
</tbody>
</table>
<div>Tags</div>
<TagsInput contract={contract} />
<div />
{contract.mechanism === 'cpmm-1' && !contract.resolution && (
<LiquidityPanel contract={contract} />
)}

View File

@ -2,56 +2,65 @@ import React, { Fragment, useEffect } from 'react'
import { LinkIcon } from '@heroicons/react/outline'
import { Menu, Transition } from '@headlessui/react'
import clsx from 'clsx'
import { ToastClipboard } from 'web/components/toast-clipboard'
import { copyToClipboard } from 'web/lib/util/copy'
import { ToastClipboard } from 'web/components/toast-clipboard'
import { track } from 'web/lib/service/analytics'
import { Row } from './layout/row'
export function CopyLinkButton(props: {
link: string
onCopy?: () => void
url: string
displayUrl?: string
tracking?: string
buttonClassName?: string
toastClassName?: string
icon?: React.ComponentType<{ className?: string }>
label?: string
}) {
const { onCopy, link, buttonClassName, toastClassName, label } = props
const { url, displayUrl, tracking, buttonClassName, toastClassName } = props
return (
<Menu
as="div"
className="relative z-10 flex-shrink-0"
onMouseUp={() => {
copyToClipboard(link)
onCopy?.()
}}
>
<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
)}
>
{!props.icon && (
<LinkIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
)}
{props.icon && <props.icon className={'h-4 w-4'} />}
{label ?? 'Copy link'}
</Menu.Button>
<Row className="w-full">
<input
className="input input-bordered flex-1 rounded-r-none text-gray-500"
readOnly
type="text"
value={displayUrl ?? url}
/>
<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
as="div"
className="relative z-10 flex-shrink-0"
onMouseUp={() => {
copyToClipboard(url)
track(tracking ?? 'copy share link')
}}
>
<Menu.Items>
<Menu.Item>
<ToastClipboard className={toastClassName} />
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
<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
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

@ -1,18 +1,13 @@
import { Answer } from 'common/answer'
import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
import { formatPercent } from 'common/util/format'
import React, { useEffect, useState } from 'react'
import { Col } from 'web/components/layout/col'
import { Modal } from 'web/components/layout/modal'
import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-page'
import { Linkify } from 'web/components/linkify'
import clsx from 'clsx'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { BuyButton } from 'web/components/yes-no-selector'
import {
CommentInput,
CommentRepliesList,
@ -23,7 +18,6 @@ import { useRouter } from 'next/router'
import { groupBy } from 'lodash'
import { User } from 'common/user'
import { useEvent } from 'web/hooks/use-event'
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
export function FeedAnswerCommentGroup(props: {
@ -38,7 +32,6 @@ export function FeedAnswerCommentGroup(props: {
const { username, avatarUrl, name, text } = answer
const [replyToUsername, setReplyToUsername] = useState('')
const [open, setOpen] = useState(false)
const [showReply, setShowReply] = useState(false)
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
const [highlighted, setHighlighted] = useState(false)
@ -50,11 +43,6 @@ export function FeedAnswerCommentGroup(props: {
const commentsList = comments.filter(
(comment) => comment.answerOutcome === answer.number.toString()
)
const thisAnswerProb = getDpmOutcomeProbability(
contract.totalShares,
answer.id
)
const probPercent = formatPercent(thisAnswerProb)
const betsByCurrentUser = (user && betsByUserId[user.id]) ?? []
const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? []
const isFreeResponseContractPage = !!commentsByCurrentUser
@ -112,27 +100,16 @@ export function FeedAnswerCommentGroup(props: {
}, [answerElementId, router.asPath])
return (
<Col className={'relative flex-1 gap-2'} key={answer.id + 'comment'}>
<Modal open={open} setOpen={setOpen}>
<AnswerBetPanel
answer={answer}
contract={contract}
closePanel={() => setOpen(false)}
className="sm:max-w-84 !rounded-md bg-white !px-8 !py-6"
isModal={true}
/>
</Modal>
<Col className={'relative flex-1 gap-3'} key={answer.id + 'comment'}>
<Row
className={clsx(
'my-4 flex gap-3 space-x-3 transition-all duration-1000',
'flex gap-3 space-x-3 pt-4 transition-all duration-1000',
highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : ''
)}
id={answerElementId}
>
<div className="px-1">
<Avatar username={username} avatarUrl={avatarUrl} />
</div>
<Avatar username={username} avatarUrl={avatarUrl} />
<Col className="min-w-0 flex-1 lg:gap-1">
<div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered
@ -144,43 +121,21 @@ export function FeedAnswerCommentGroup(props: {
/>
</div>
<Col className="align-items justify-between gap-4 sm:flex-row">
<Col className="align-items justify-between gap-2 sm:flex-row">
<span className="whitespace-pre-line text-lg">
<Linkify text={text} />
</span>
<Row className="items-center justify-center gap-4">
{isFreeResponseContractPage && (
<div className={'sm:hidden'}>
<button
className={
'text-xs font-bold text-gray-500 hover:underline'
}
onClick={() => scrollAndOpenReplyInput(undefined, answer)}
>
Reply
</button>
</div>
)}
<div className={'align-items flex w-full justify-end gap-4 '}>
<span
className={clsx(
'text-2xl',
tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
)}
{isFreeResponseContractPage && (
<div className={'sm:hidden'}>
<button
className={'text-xs font-bold text-gray-500 hover:underline'}
onClick={() => scrollAndOpenReplyInput(undefined, answer)}
>
{probPercent}
</span>
<BuyButton
className={clsx(
'btn-sm flex-initial !px-6 sm:flex',
tradingAllowed(contract) ? '' : '!hidden'
)}
onClick={() => setOpen(true)}
/>
Reply
</button>
</div>
</Row>
)}
</Col>
{isFreeResponseContractPage && (
<div className={'justify-initial hidden sm:block'}>
@ -207,9 +162,9 @@ export function FeedAnswerCommentGroup(props: {
/>
{showReply && (
<div className={'ml-6 pt-4'}>
<div className={'ml-6'}>
<span
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
className="absolute -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<CommentInput

View File

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

View File

@ -70,7 +70,7 @@ export function FeedCommentThread(props: {
if (showReply && inputRef) inputRef.focus()
}, [inputRef, showReply])
return (
<div className={'w-full flex-col pr-1'}>
<Col className={'w-full gap-3 pr-1'}>
<span
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
aria-hidden="true"
@ -86,7 +86,7 @@ export function FeedCommentThread(props: {
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
/>
{showReply && (
<div className={'-pb-2 ml-6 flex flex-col pt-5'}>
<Col className={'-pb-2 ml-6'}>
<span
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
@ -106,9 +106,9 @@ export function FeedCommentThread(props: {
setReplyToUsername('')
}}
/>
</div>
</Col>
)}
</div>
</Col>
)
}
@ -142,7 +142,7 @@ export function CommentRepliesList(props: {
id={comment.id}
className={clsx(
'relative',
!treatFirstIndexEqually && commentIdx === 0 ? '' : 'mt-3 ml-6'
!treatFirstIndexEqually && commentIdx === 0 ? '' : 'ml-6'
)}
>
{/*draw a gray line from the comment to the left:*/}

View File

@ -23,6 +23,7 @@ import BetRow from '../bet-row'
import { Avatar } from '../avatar'
import { ActivityItem } from './activity-items'
import { useSaveSeenContract } from 'web/hooks/use-seen-contracts'
import { useUser } from 'web/hooks/use-user'
import { trackClick } from 'web/lib/firebase/tracking'
import { DAY_MS } from 'common/util/time'
import NewContractBadge from '../new-contract-badge'
@ -118,6 +119,7 @@ export function FeedQuestion(props: {
const { volumeLabel } = contractMetrics(contract)
const isBinary = outcomeType === 'BINARY'
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
const user = useUser()
return (
<div className={'flex gap-2'}>
@ -149,7 +151,7 @@ export function FeedQuestion(props: {
href={
props.contractPath ? props.contractPath : contractPath(contract)
}
onClick={() => trackClick(contract.id)}
onClick={() => user && trackClick(user.id, contract.id)}
className="text-lg text-indigo-700 sm:text-xl"
>
{question}

View File

@ -0,0 +1,30 @@
import clsx from 'clsx'
import { InformationCircleIcon } from '@heroicons/react/solid'
import { Linkify } from './linkify'
export function InfoBox(props: {
title: string
text: string
className?: string
}) {
const { title, text, className } = props
return (
<div className={clsx('rounded-md bg-gray-50 p-4', className)}>
<div className="flex">
<div className="flex-shrink-0">
<InformationCircleIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-black">{title}</h3>
<div className="mt-2 text-sm text-black">
<Linkify text={text} />
</div>
</div>
</div>
</div>
)
}

View File

@ -3,9 +3,13 @@ import { formatMoney } from 'common/util/format'
import { fromNow } from 'web/lib/util/time'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { User } from 'web/lib/firebase/users'
import { Button } from './button'
import { Claim, Manalink } from 'common/manalink'
import { useState } from 'react'
import { ShareIconButton } from './share-icon-button'
import { DotsHorizontalIcon } from '@heroicons/react/solid'
import { contractDetailsButtonClassName } from './contract/contract-info-dialog'
import { useUserById } from 'web/hooks/use-user'
import getManalinkUrl from 'web/get-manalink-url'
export type ManalinkInfo = {
expiresTime: number | null
maxUses: number | null
@ -15,94 +19,202 @@ export type ManalinkInfo = {
}
export function ManalinkCard(props: {
user: User | null | undefined
className?: string
info: ManalinkInfo
isClaiming: boolean
onClaim?: () => void
className?: string
preview?: boolean
}) {
const { user, className, isClaiming, info, onClaim } = props
const { className, info, preview = false } = props
const { expiresTime, maxUses, uses, amount, message } = info
return (
<div
<Col>
<div
className={clsx(
className,
'min-h-20 group flex flex-col rounded-xl bg-gradient-to-br shadow-lg transition-all',
getManalinkGradient(info.amount)
)}
>
<Col className="mx-4 mt-2 -mb-4 text-right text-sm text-gray-100">
<div>
{maxUses != null
? `${maxUses - uses}/${maxUses} uses left`
: `Unlimited use`}
</div>
<div>
{expiresTime != null
? `Expires ${fromNow(expiresTime)}`
: 'Never expires'}
</div>
</Col>
<img
className={clsx(
'block h-1/3 w-1/3 self-center transition-all group-hover:rotate-12',
preview ? 'my-2' : 'w-1/2 md:mb-6 md:h-1/2'
)}
src="/logo-white.svg"
/>
<Row className="rounded-b-xl bg-white p-4">
<Col>
<div
className={clsx(
'mb-1 text-xl text-indigo-500',
getManalinkAmountColor(amount)
)}
>
{formatMoney(amount)}
</div>
<div>{message}</div>
</Col>
</Row>
</div>
</Col>
)
}
export function ManalinkCardFromView(props: {
className?: string
link: Manalink
highlightedSlug: string
}) {
const { className, link, highlightedSlug } = props
const { message, amount, expiresTime, maxUses, claims } = link
const [details, setDetails] = useState(false)
return (
<Col
className={clsx(
'group z-10 rounded-lg drop-shadow-sm transition-all hover:drop-shadow-lg',
className,
'min-h-20 group flex flex-col rounded-xl bg-gradient-to-br from-indigo-200 via-indigo-400 to-indigo-800 shadow-lg transition-all'
link.slug === highlightedSlug ? 'animate-pulse' : ''
)}
>
<Col className="mx-4 mt-2 -mb-4 text-right text-sm text-gray-100">
<div>
{maxUses != null
? `${maxUses - uses}/${maxUses} uses left`
: `Unlimited use`}
</div>
<div>
{expiresTime != null
? `Expires ${fromNow(expiresTime)}`
: 'Never expires'}
</div>
</Col>
<img
className="mb-6 block self-center transition-all group-hover:rotate-12"
src="/logo-white.svg"
width={200}
height={200}
/>
<Row className="justify-end rounded-b-xl bg-white p-4">
<Col>
<div className="mb-1 text-xl text-indigo-500">
<div
className={clsx(
'relative flex flex-col rounded-t-lg bg-gradient-to-br transition-all',
getManalinkGradient(link.amount)
)}
onClick={() => setDetails(!details)}
>
{details && (
<ClaimsList
className="absolute h-full w-full bg-white opacity-90"
link={link}
/>
)}
<Col className="mx-4 mt-2 -mb-4 text-right text-xs text-gray-100">
<div>
{maxUses != null
? `${maxUses - claims.length}/${maxUses} uses left`
: `Unlimited use`}
</div>
<div>
{expiresTime != null
? `Expires ${fromNow(expiresTime)}`
: 'Never expires'}
</div>
</Col>
<img
className={clsx('my-auto block w-1/3 select-none self-center py-3')}
src="/logo-white.svg"
/>
</div>
<Col className="w-full rounded-b-lg bg-white px-4 py-2 text-lg">
<Row className="relative gap-1">
<div
className={clsx(
'my-auto mb-1 w-full',
getManalinkAmountColor(amount)
)}
>
{formatMoney(amount)}
</div>
<div>{message}</div>
</Col>
<div className="ml-auto">
<Button onClick={onClaim} disabled={isClaiming}>
{user ? 'Claim' : 'Login'}
</Button>
</div>
</Row>
</div>
<ShareIconButton
toastClassName={'-left-48 min-w-[250%]'}
buttonClassName={'transition-colors'}
onCopyButtonClassName={
'bg-gray-200 text-gray-600 transition-none hover:bg-gray-200 hover:text-gray-600'
}
copyPayload={getManalinkUrl(link.slug)}
/>
<button
onClick={() => setDetails(!details)}
className={clsx(
contractDetailsButtonClassName,
details
? 'bg-gray-200 text-gray-600 hover:bg-gray-200 hover:text-gray-600'
: ''
)}
>
<DotsHorizontalIcon className="h-[24px] w-5" />
</button>
</Row>
<div className="my-2 text-xs md:text-sm">{message || '\n\n'}</div>
</Col>
</Col>
)
}
export function ManalinkCardPreview(props: {
className?: string
info: ManalinkInfo
}) {
const { className, info } = props
const { expiresTime, maxUses, uses, amount, message } = info
function ClaimsList(props: { link: Manalink; className: string }) {
const { link, className } = props
return (
<div
className={clsx(
className,
' group flex flex-col rounded-lg bg-gradient-to-br from-indigo-200 via-indigo-400 to-indigo-800 shadow-lg transition-all'
)}
>
<Col className="mx-4 mt-2 -mb-4 text-right text-xs text-gray-100">
<div>
{maxUses != null
? `${maxUses - uses}/${maxUses} uses left`
: `Unlimited use`}
<>
<Col className={clsx('px-4 py-2', className)}>
<div className="text-md mb-1 mt-2 w-full font-semibold">
Claimed by...
</div>
<div>
{expiresTime != null
? `Expires ${fromNow(expiresTime)}`
: 'Never expires'}
<div className="overflow-auto">
{link.claims.length > 0 ? (
<>
{link.claims.map((claim) => (
<Row key={claim.txnId}>
<Claim claim={claim} />
</Row>
))}
</>
) : (
<div className="h-full">
No one has claimed this manalink yet! Share your manalink to start
spreading the wealth.
</div>
)}
</div>
</Col>
<img
className="my-2 block h-1/3 w-1/3 self-center transition-all group-hover:rotate-12"
src="/logo-white.svg"
/>
<Row className="rounded-b-lg bg-white p-2">
<Col className="text-md">
<div className="mb-1 text-indigo-500">{formatMoney(amount)}</div>
<div className="text-xs">{message}</div>
</Col>
</Row>
</div>
</>
)
}
function Claim(props: { claim: Claim }) {
const { claim } = props
const who = useUserById(claim.toId)
return (
<Row className="my-1 gap-2 text-xs">
<div>{who?.name || 'Loading...'}</div>
<div className="text-gray-500">{fromNow(claim.claimedTime)}</div>
</Row>
)
}
function getManalinkGradient(amount: number) {
if (amount < 20) {
return 'from-indigo-200 via-indigo-500 to-indigo-800'
} else if (amount >= 20 && amount < 50) {
return 'from-fuchsia-200 via-fuchsia-500 to-fuchsia-800'
} else if (amount >= 50 && amount < 100) {
return 'from-rose-100 via-rose-400 to-rose-700'
} else if (amount >= 100) {
return 'from-amber-200 via-amber-500 to-amber-700'
}
}
function getManalinkAmountColor(amount: number) {
if (amount < 20) {
return 'text-indigo-500'
} else if (amount >= 20 && amount < 50) {
return 'text-fuchsia-600'
} else if (amount >= 50 && amount < 100) {
return 'text-rose-600'
} else if (amount >= 100) {
return 'text-amber-600'
}
}

View File

@ -4,7 +4,7 @@ import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { Title } from '../title'
import { User } from 'common/user'
import { ManalinkCardPreview, ManalinkInfo } from 'web/components/manalink-card'
import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card'
import { createManalink } from 'web/lib/firebase/manalinks'
import { Modal } from 'web/components/layout/modal'
import Textarea from 'react-expanding-textarea'
@ -37,6 +37,7 @@ export function CreateLinksButton(props: {
message: newManalink.message,
})
setHighlightedSlug(slug || '')
setTimeout(() => setHighlightedSlug(''), 3700)
}}
/>
</Col>
@ -191,7 +192,7 @@ function CreateManalinkForm(props: {
{finishedCreating && (
<>
<Title className="!my-0" text="Manalink Created!" />
<ManalinkCardPreview className="my-4" info={newManalink} />
<ManalinkCard className="my-4" info={newManalink} preview />
<Row
className={clsx(
'rounded border bg-gray-50 py-2 px-3 text-sm text-gray-500 transition-colors duration-700',

View File

@ -63,10 +63,11 @@ function getMoreNavigation(user?: User | null) {
}
return [
{ name: 'Send M$', href: '/links' },
{ name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Challenges', href: '/challenges' },
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
{
@ -112,15 +113,17 @@ const signedInMobileNavigation = [
function getMoreMobileNav() {
return [
{ name: 'Leaderboards', href: '/leaderboards' },
...(IS_PRIVATE_MANIFOLD
? []
: [
{ name: 'Send M$', href: '/links' },
{ name: 'Challenges', href: '/challenges' },
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
]),
{ name: 'Leaderboards', href: '/leaderboards' },
{
name: 'Sign out',
href: '#',
@ -238,7 +241,10 @@ export default function Sidebar(props: { className?: string }) {
buttonContent={<MoreButton />}
/>
)}
{/* Spacer if there are any groups */}
{memberItems.length > 0 && (
<hr className="!my-4 mr-2 border-gray-300" />
)}
{privateUser && (
<GroupsList
currentPage={router.asPath}
@ -259,11 +265,7 @@ export default function Sidebar(props: { className?: string }) {
/>
{/* Spacer if there are any groups */}
{memberItems.length > 0 && (
<div className="py-3">
<div className="h-[1px] bg-gray-300" />
</div>
)}
{memberItems.length > 0 && <hr className="!my-4 border-gray-300" />}
{privateUser && (
<GroupsList
currentPage={router.asPath}

View File

@ -1,9 +1,12 @@
import clsx from 'clsx'
export function Pagination(props: {
page: number
itemsPerPage: number
totalItems: number
setPage: (page: number) => void
scrollToTop?: boolean
className?: string
nextTitle?: string
prevTitle?: string
}) {
@ -15,13 +18,17 @@ export function Pagination(props: {
scrollToTop,
nextTitle,
prevTitle,
className,
} = props
const maxPage = Math.ceil(totalItems / itemsPerPage) - 1
return (
<nav
className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6"
className={clsx(
'flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6',
className
)}
aria-label="Pagination"
>
<div className="hidden sm:block">

View File

@ -2,65 +2,48 @@ import React, { useState } from 'react'
import { ShareIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { Contract } from 'common/contract'
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 { track } from 'web/lib/service/analytics'
import { contractDetailsButtonClassName } from 'web/components/contract/contract-info-dialog'
import { Group } from 'common/group'
import { groupPath } from 'web/lib/firebase/groups'
function copyContractWithReferral(contract: Contract, username?: string) {
const postFix =
username && contract.creatorUsername !== username
? '?referrer=' + username
: ''
copyToClipboard(
`https://${ENV_CONFIG.domain}${contractPath(contract)}${postFix}`
)
}
// Note: if a user arrives at a /group endpoint with a ?referral= query, they'll be added to the group automatically
function copyGroupWithReferral(group: Group, username?: string) {
const postFix = username ? '?referrer=' + username : ''
copyToClipboard(
`https://${ENV_CONFIG.domain}${groupPath(group.slug)}${postFix}`
)
}
export function ShareIconButton(props: {
contract?: Contract
group?: Group
buttonClassName?: string
onCopyButtonClassName?: string
toastClassName?: string
username?: string
children?: React.ReactNode
iconClassName?: string
copyPayload: string
}) {
const {
contract,
buttonClassName,
onCopyButtonClassName,
toastClassName,
username,
group,
children,
iconClassName,
copyPayload,
} = props
const [showToast, setShowToast] = useState(false)
return (
<div className="relative z-10 flex-shrink-0">
<button
className={clsx(contractDetailsButtonClassName, buttonClassName)}
className={clsx(
contractDetailsButtonClassName,
buttonClassName,
showToast ? onCopyButtonClassName : ''
)}
onClick={() => {
if (contract) copyContractWithReferral(contract, username)
if (group) copyGroupWithReferral(group, username)
copyToClipboard(copyPayload)
track('copy share link')
setShowToast(true)
setTimeout(() => setShowToast(false), 2000)
}}
>
<ShareIcon className="h-[24px] w-5" aria-hidden="true" />
<ShareIcon
className={clsx(iconClassName ? iconClassName : 'h-[24px] w-5')}
aria-hidden="true"
/>
{children}
</button>

View File

@ -1,4 +1,5 @@
import clsx from 'clsx'
import { ENV_CONFIG } from 'common/envs/constants'
import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts'
import { CopyLinkButton } from './copy-link-button'
import { Col } from './layout/col'
@ -10,19 +11,15 @@ import { track } from 'web/lib/service/analytics'
export function ShareMarket(props: { contract: Contract; className?: string }) {
const { contract, className } = props
const url = `https://${ENV_CONFIG.domain}${contractPath(contract)}`
return (
<Col className={clsx(className, 'gap-3')}>
<div>Share your market</div>
<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
link={`https://${ENV_CONFIG.domain}${contractPath(contract)}`}
onCopy={() => track('copy share link')}
url={url}
displayUrl={contractUrl(contract)}
buttonClassName="btn-md rounded-l-none"
toastClassName={'-left-28 mt-1'}
/>

View File

@ -25,7 +25,7 @@ export const useAlgoFeed = (
getDefaultFeed().then((feed) => setAllFeed(feed))
} else setAllFeed(feed)
trackLatency('feed', getTime())
trackLatency(user.id, 'feed', getTime())
console.log('"all" feed load time', getTime())
})

View File

@ -0,0 +1,27 @@
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { User, writeReferralInfo } from 'web/lib/firebase/users'
export const useSaveReferral = (
user?: User | null,
options?: {
defaultReferrer?: string
contractId?: string
groupId?: string
}
) => {
const router = useRouter()
useEffect(() => {
const { referrer } = router.query as {
referrer?: string
}
const actualReferrer = referrer || options?.defaultReferrer
if (!user && router.isReady && actualReferrer) {
writeReferralInfo(actualReferrer, options?.contractId, options?.groupId)
}
}, [user, router, options])
}

View File

@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { Contract } from 'common/contract'
import { trackView } from 'web/lib/firebase/tracking'
import { useIsVisible } from './use-is-visible'
import { useUser } from './use-user'
export const useSeenContracts = () => {
const [seenContracts, setSeenContracts] = useState<{
@ -21,18 +22,19 @@ export const useSaveSeenContract = (
contract: Contract
) => {
const isVisible = useIsVisible(elem)
const user = useUser()
useEffect(() => {
if (isVisible) {
if (isVisible && user) {
const newSeenContracts = {
...getSeenContracts(),
[contract.id]: Date.now(),
}
localStorage.setItem(key, JSON.stringify(newSeenContracts))
trackView(contract.id)
trackView(user.id, contract.id)
}
}, [isVisible, contract])
}, [isVisible, user, contract])
}
const key = 'feed-seen-contracts'

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useContext, useEffect, useState } from 'react'
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
import { QueryClient } from 'react-query'
@ -6,32 +6,14 @@ import { doc, DocumentData, where } from 'firebase/firestore'
import { PrivateUser } from 'common/user'
import {
getUser,
listenForLogin,
listenForPrivateUser,
listenForUser,
User,
users,
} from 'web/lib/firebase/users'
import { useStateCheckEquality } from './use-state-check-equality'
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
import { AuthContext } from 'web/components/auth-context'
export const useUser = () => {
const [user, setUser] = useStateCheckEquality<User | null | undefined>(
undefined
)
useEffect(() => listenForLogin(setUser), [setUser])
useEffect(() => {
if (user) {
identifyUser(user.id)
setUserProperty('username', user.username)
return listenForUser(user.id, setUser)
}
}, [user, setUser])
return user
return useContext(AuthContext)
}
export const usePrivateUser = (userId?: string) => {

View File

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

View File

@ -22,7 +22,12 @@ export const groups = coll<Group>('groups')
export function groupPath(
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}` : ''}`
}

View File

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

View File

@ -2,16 +2,9 @@ import { doc, collection, setDoc } from 'firebase/firestore'
import { db } from './init'
import { ClickEvent, LatencyEvent, View } from 'common/tracking'
import { listenForLogin, User } from './users'
let user: User | null = null
if (typeof window !== 'undefined') {
listenForLogin((u) => (user = u))
}
export async function trackView(contractId: string) {
if (!user) return
const ref = doc(collection(db, 'private-users', user.id, 'views'))
export async function trackView(userId: string, contractId: string) {
const ref = doc(collection(db, 'private-users', userId, 'views'))
const view: View = {
contractId,
@ -21,9 +14,8 @@ export async function trackView(contractId: string) {
return await setDoc(ref, view)
}
export async function trackClick(contractId: string) {
if (!user) return
const ref = doc(collection(db, 'private-users', user.id, 'events'))
export async function trackClick(userId: string, contractId: string) {
const ref = doc(collection(db, 'private-users', userId, 'events'))
const clickEvent: ClickEvent = {
type: 'click',
@ -35,11 +27,11 @@ export async function trackClick(contractId: string) {
}
export async function trackLatency(
userId: string,
type: 'feed' | 'portfolio',
latency: number
) {
if (!user) return
const ref = doc(collection(db, 'private-users', user.id, 'latency'))
const ref = doc(collection(db, 'private-users', userId, 'latency'))
const latencyEvent: LatencyEvent = {
type,

View File

@ -15,15 +15,10 @@ import {
} from 'firebase/firestore'
import { getAuth } from 'firebase/auth'
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage'
import {
onIdTokenChanged,
GoogleAuthProvider,
signInWithPopup,
} from 'firebase/auth'
import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
import { zip } from 'lodash'
import { app, db } from './init'
import { PortfolioMetrics, PrivateUser, User } from 'common/user'
import { createUser } from './api'
import {
coll,
getValue,
@ -37,13 +32,11 @@ import { safeLocalStorage } from '../util/local'
import { filterDefined } from 'common/util/array'
import { addUserToGroupViaId } from 'web/lib/firebase/groups'
import { removeUndefinedProps } from 'common/util/object'
import { randomString } from 'common/util/random'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
dayjs.extend(utc)
import { track } from '@amplitude/analytics-browser'
import { deleteAuthCookies, setAuthCookies } from './auth'
export const users = coll<User>('users')
export const privateUsers = coll<PrivateUser>('private-users')
@ -97,7 +90,6 @@ export function listenForPrivateUser(
return listenForValue<PrivateUser>(userRef, setPrivateUser)
}
const CACHED_USER_KEY = 'CACHED_USER_KEY'
const CACHED_REFERRAL_USERNAME_KEY = 'CACHED_REFERRAL_KEY'
const CACHED_REFERRAL_CONTRACT_ID_KEY = 'CACHED_REFERRAL_CONTRACT_KEY'
const CACHED_REFERRAL_GROUP_ID_KEY = 'CACHED_REFERRAL_GROUP_KEY'
@ -130,7 +122,7 @@ export function writeReferralInfo(
local?.setItem(CACHED_REFERRAL_CONTRACT_ID_KEY, contractId)
}
async function setCachedReferralInfoForUser(user: User | null) {
export async function setCachedReferralInfoForUser(user: User | null) {
if (!user || user.referredByUserId) return
// if the user wasn't created in the last minute, don't bother
const now = dayjs().utc()
@ -181,46 +173,6 @@ async function setCachedReferralInfoForUser(user: User | null) {
local?.removeItem(CACHED_REFERRAL_CONTRACT_ID_KEY)
}
// used to avoid weird race condition
let createUserPromise: Promise<User> | undefined = undefined
export function listenForLogin(onUser: (user: User | null) => void) {
const local = safeLocalStorage()
const cachedUser = local?.getItem(CACHED_USER_KEY)
onUser(cachedUser && JSON.parse(cachedUser))
return onIdTokenChanged(auth, async (fbUser) => {
if (fbUser) {
let user: User | null = await getUser(fbUser.uid)
if (!user) {
if (createUserPromise == null) {
const local = safeLocalStorage()
let deviceToken = local?.getItem('device-token')
if (!deviceToken) {
deviceToken = randomString()
local?.setItem('device-token', deviceToken)
}
createUserPromise = createUser({ deviceToken }).then((r) => r as User)
}
user = await createUserPromise
}
onUser(user)
// Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb
local?.setItem(CACHED_USER_KEY, JSON.stringify(user))
setCachedReferralInfoForUser(user)
setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken)
} else {
// User logged out; reset to null
onUser(null)
createUserPromise = undefined
local?.removeItem(CACHED_USER_KEY)
deleteAuthCookies()
}
})
}
export async function firebaseLogin() {
const provider = new GoogleAuthProvider()
return signInWithPopup(auth, provider)
@ -258,16 +210,6 @@ export async function listAllUsers() {
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) {
const topTraders = query(
users,

View File

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

View File

@ -10,7 +10,7 @@ import { useUser } from 'web/hooks/use-user'
import { ResolutionPanel } from 'web/components/resolution-panel'
import { Title } from 'web/components/title'
import { Spacer } from 'web/components/layout/spacer'
import { listUsers, User, writeReferralInfo } from 'web/lib/firebase/users'
import { listUsers, User } from 'web/lib/firebase/users'
import {
Contract,
getContractFromSlug,
@ -43,9 +43,9 @@ import { CPMMBinaryContract } from 'common/contract'
import { AlertBox } from 'web/components/alert-box'
import { useTracking } from 'web/hooks/use-tracking'
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
import { useRouter } from 'next/router'
import { useLiquidity } from 'web/hooks/use-liquidity'
import { richTextToString } from 'common/util/parse'
import { useSaveReferral } from 'web/hooks/use-save-referral'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {
@ -157,15 +157,10 @@ export function ContractPageContent(
const ogCardProps = getOpenGraphProps(contract)
const router = useRouter()
useEffect(() => {
const { referrer } = router.query as {
referrer?: string
}
if (!user && router.isReady)
writeReferralInfo(contract.creatorUsername, contract.id, referrer)
}, [user, contract, router])
useSaveReferral(user, {
defaultReferrer: contract.creatorUsername,
contractId: contract.id,
})
const rightSidebar = hasSidePanel ? (
<Col className="gap-4">

View File

@ -5,6 +5,7 @@ import Head from 'next/head'
import Script from 'next/script'
import { usePreserveScroll } from 'web/hooks/use-preserve-scroll'
import { QueryClient, QueryClientProvider } from 'react-query'
import { AuthProvider } from 'web/components/auth-context'
function firstLine(msg: string) {
return msg.replace(/\r?\n.*/s, '')
@ -78,9 +79,11 @@ function MyApp({ Component, pageProps }: AppProps) {
/>
</Head>
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
<AuthProvider>
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
</AuthProvider>
</>
)
}

View File

@ -14,12 +14,7 @@ import {
} from 'web/lib/firebase/groups'
import { Row } from 'web/components/layout/row'
import { UserLink } from 'web/components/user-page'
import {
firebaseLogin,
getUser,
User,
writeReferralInfo,
} from 'web/lib/firebase/users'
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { listMembers, useGroup, useMembers } from 'web/hooks/use-group'
@ -34,7 +29,7 @@ import { Linkify } from 'web/components/linkify'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { Tabs } from 'web/components/layout/tabs'
import { CreateQuestionButton } from 'web/components/create-question-button'
import React, { useEffect, useState } from 'react'
import React, { useState } from 'react'
import { GroupChat } from 'web/components/groups/group-chat'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Modal } from 'web/components/layout/modal'
@ -42,7 +37,6 @@ import { getSavedSort } from 'web/hooks/use-sort-and-query-params'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { toast } from 'react-hot-toast'
import { useCommentsOnGroup } from 'web/hooks/use-comments'
import { ShareIconButton } from 'web/components/share-icon-button'
import { REFERRAL_AMOUNT } from 'common/user'
import { ContractSearch } from 'web/components/contract-search'
import clsx from 'clsx'
@ -52,6 +46,9 @@ import { useTipTxns } from 'web/hooks/use-tip-txns'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
import { searchInAny } from 'common/util/parse'
import { useWindowSize } from 'web/hooks/use-window-size'
import { CopyLinkButton } from 'web/components/copy-link-button'
import { ENV_CONFIG } from 'common/envs/constants'
import { useSaveReferral } from 'web/hooks/use-save-referral'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -113,7 +110,7 @@ const groupSubpages = [
undefined,
GROUP_CHAT_SLUG,
'questions',
'rankings',
'leaderboards',
'about',
] as const
@ -154,13 +151,11 @@ export default function GroupPage(props: {
const messages = useCommentsOnGroup(group?.id)
const user = useUser()
useEffect(() => {
const { referrer } = router.query as {
referrer?: string
}
if (!user && router.isReady)
writeReferralInfo(creator.username, undefined, referrer, group?.id)
}, [user, creator, group, router])
useSaveReferral(user, {
defaultReferrer: creator.username,
groupId: group?.id,
})
const { width } = useWindowSize()
const chatDisabled = !group || group.chatDisabled
@ -236,9 +231,9 @@ export default function GroupPage(props: {
href: groupPath(group.slug, 'questions'),
},
{
title: 'Rankings',
title: 'Leaderboards',
content: leaderboard,
href: groupPath(group.slug, 'rankings'),
href: groupPath(group.slug, 'leaderboards'),
},
{
title: 'About',
@ -252,7 +247,7 @@ export default function GroupPage(props: {
<Page
rightSidebar={showChatSidebar ? chatTab : undefined}
rightSidebarClassName={showChatSidebar ? '!top-0' : ''}
className={showChatSidebar ? '!max-w-none !pb-0' : ''}
className={showChatSidebar ? '!max-w-7xl !pb-0' : ''}
>
<SEO
title={group.name}
@ -328,6 +323,11 @@ function GroupOverview(props: {
})
}
const postFix = user ? '?referrer=' + user.username : ''
const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
group.slug
)}${postFix}`
return (
<>
<Col className="gap-2 rounded-b bg-white p-2">
@ -372,21 +372,26 @@ function GroupOverview(props: {
</span>
)}
</Row>
{anyoneCanJoin && user && (
<Row className={'flex-wrap items-center gap-1'}>
<span className={'text-gray-500'}>Share</span>
<ShareIconButton
group={group}
username={user.username}
buttonClassName={'hover:bg-gray-300 mt-1 !text-gray-700'}
>
<span className={'mx-2'}>
Invite a friend and get M${REFERRAL_AMOUNT} if they sign up!
</span>
</ShareIconButton>
</Row>
<Col className="my-4 px-2">
<div className="text-lg">Invite</div>
<div className={'mb-2 text-gray-500'}>
Invite a friend to this group and get M${REFERRAL_AMOUNT} if they
sign up!
</div>
<CopyLinkButton
url={shareUrl}
tracking="copy group share link"
buttonClassName="btn-md rounded-l-none"
toastClassName={'-left-28 mt-1'}
/>
</Col>
)}
<Col className={'mt-2'}>
<div className="mb-2 text-lg">Members</div>
<GroupMemberSearch members={members} group={group} />
</Col>
</Col>
@ -487,14 +492,14 @@ function GroupLeaderboards(props: {
<SortedLeaderboard
users={members}
scoreFunction={(user) => traderScores[user.id] ?? 0}
title="🏅 Bettor rankings"
title="🏅 Top bettors"
header="Profit"
maxToShow={maxToShow}
/>
<SortedLeaderboard
users={members}
scoreFunction={(user) => creatorScores[user.id] ?? 0}
title="🏅 Creator rankings"
title="🏅 Top creators"
header="Market volume"
maxToShow={maxToShow}
/>

View File

@ -12,6 +12,7 @@ import { getContractFromSlug } from 'web/lib/firebase/contracts'
import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral'
export const getServerSideProps = redirectIfLoggedOut('/')
@ -21,6 +22,8 @@ const Home = () => {
const router = useRouter()
useTracking('view home')
useSaveReferral()
return (
<>
<Page suspend={!!contract}>

View File

@ -1,11 +1,13 @@
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 { Page } from 'web/components/page'
import { LandingPagePanel } from 'web/components/landing-page-panel'
import { Col } from 'web/components/layout/col'
import { ManifoldLogo } from 'web/components/nav/manifold-logo'
import { redirectIfLoggedIn } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral'
export const getServerSideProps = redirectIfLoggedIn('/home', async (_) => {
// These hardcoded markets will be shown in the frontpage for signed-out users:
@ -26,6 +28,20 @@ export const getServerSideProps = redirectIfLoggedIn('/home', async (_) => {
export default function Home(props: { hotContracts: Contract[] }) {
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()
useSaveReferral()
useEffect(() => {
if (user != null) {
router.replace('/home')
}
}, [router, user])
return (
<Page>
<div className="px-4 pt-2 md:mt-0 lg:hidden">

View File

@ -7,6 +7,8 @@ import { useManalink } from 'web/lib/firebase/manalinks'
import { ManalinkCard } from 'web/components/manalink-card'
import { useUser } from 'web/hooks/use-user'
import { firebaseLogin } from 'web/lib/firebase/users'
import { Row } from 'web/components/layout/row'
import { Button } from 'web/components/button'
export default function ClaimPage() {
const user = useUser()
@ -28,34 +30,42 @@ export default function ClaimPage() {
description="Send mana to anyone via link!"
url="/send"
/>
<div className="mx-auto max-w-xl">
<Title text={`Claim M$${manalink.amount} mana`} />
<ManalinkCard
user={user}
info={info}
isClaiming={claiming}
onClaim={async () => {
setClaiming(true)
try {
if (user == null) {
await firebaseLogin()
<div className="mx-auto max-w-xl px-2">
<Row className="items-center justify-between">
<Title text={`Claim M$${manalink.amount} mana`} />
<div className="my-auto">
<Button
onClick={async () => {
setClaiming(true)
try {
if (user == null) {
await firebaseLogin()
setClaiming(false)
return
}
if (user?.id == manalink.fromId) {
throw new Error("You can't claim your own manalink.")
}
await claimManalink({ slug: manalink.slug })
user && router.push(`/${user.username}?claimed-mana=yes`)
} catch (e) {
console.log(e)
const message =
e && e instanceof Object
? e.toString()
: 'An error occurred.'
setError(message)
}
setClaiming(false)
return
}
if (user?.id == manalink.fromId) {
throw new Error("You can't claim your own manalink.")
}
await claimManalink({ slug: manalink.slug })
user && router.push(`/${user.username}?claimed-mana=yes`)
} catch (e) {
console.log(e)
const message =
e && e instanceof Object ? e.toString() : 'An error occurred.'
setError(message)
}
setClaiming(false)
}}
/>
}}
disabled={claiming}
size="lg"
>
{user ? 'Claim' : 'Login'}
</Button>
</div>
</Row>
<ManalinkCard info={info} />
{error && (
<section className="my-5 text-red-500">
<p>Failed to claim manalink.</p>

View File

@ -1,7 +1,4 @@
import clsx from 'clsx'
import { useState } from 'react'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Claim, Manalink } from 'common/manalink'
import { formatMoney } from 'common/util/format'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
@ -11,7 +8,6 @@ import { Title } from 'web/components/title'
import { Subtitle } from 'web/components/subtitle'
import { useUser } from 'web/hooks/use-user'
import { useUserManalinks } from 'web/lib/firebase/manalinks'
import { fromNow } from 'web/lib/util/time'
import { useUserById } from 'web/hooks/use-user'
import { ManalinkTxn } from 'common/txn'
import { Avatar } from 'web/components/avatar'
@ -22,8 +18,11 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import { ManalinkCardFromView } from 'web/components/manalink-card'
import { Pagination } from 'web/components/pagination'
dayjs.extend(customParseFormat)
const LINKS_PER_PAGE = 24
export const getServerSideProps = redirectIfLoggedOut('/')
export function getManalinkUrl(slug: string) {
@ -40,6 +39,10 @@ export default function LinkPage() {
(l.maxUses == null || l.claimedUserIds.length < l.maxUses) &&
(l.expiresTime == null || l.expiresTime > Date.now())
)
const [page, setPage] = useState(0)
const start = page * LINKS_PER_PAGE
const end = start + LINKS_PER_PAGE
const displayedLinks = unclaimedLinks.slice(start, end)
if (user == null) {
return null
@ -68,12 +71,30 @@ export default function LinkPage() {
don&apos;t yet have a Manifold account.
</p>
<Subtitle text="Your Manalinks" />
<LinksTable links={unclaimedLinks} highlightedSlug={highlightedSlug} />
<Col className="grid w-full gap-4 md:grid-cols-2">
{displayedLinks.map((link) => {
return (
<ManalinkCardFromView
link={link}
highlightedSlug={highlightedSlug}
/>
)
})}
</Col>
<Pagination
page={page}
itemsPerPage={LINKS_PER_PAGE}
totalItems={unclaimedLinks.length}
setPage={setPage}
className="mt-4 bg-transparent"
scrollToTop
/>
</Col>
</Page>
)
}
// TODO: either utilize this or get rid of it
export function ClaimsList(props: { txns: ManalinkTxn[] }) {
const { txns } = props
return (
@ -121,127 +142,3 @@ export function ClaimDescription(props: { txn: ManalinkTxn }) {
</div>
)
}
function ClaimTableRow(props: { claim: Claim }) {
const { claim } = props
const who = useUserById(claim.toId)
return (
<tr>
<td className="px-5 py-2">{who?.name || 'Loading...'}</td>
<td className="px-5 py-2">{`${new Date(
claim.claimedTime
).toLocaleString()}, ${fromNow(claim.claimedTime)}`}</td>
</tr>
)
}
function LinkDetailsTable(props: { link: Manalink }) {
const { link } = props
return (
<table className="w-full divide-y divide-gray-300 border border-gray-400">
<thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900">
<tr>
<th className="px-5 py-2">Claimed by</th>
<th className="px-5 py-2">Time</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white text-sm text-gray-500">
{link.claims.length ? (
link.claims.map((claim) => <ClaimTableRow claim={claim} />)
) : (
<tr>
<td className="px-5 py-2" colSpan={2}>
No claims yet.
</td>
</tr>
)}
</tbody>
</table>
)
}
function LinkTableRow(props: { link: Manalink; highlight: boolean }) {
const { link, highlight } = props
const [expanded, setExpanded] = useState(false)
return (
<>
<LinkSummaryRow
link={link}
highlight={highlight}
expanded={expanded}
onToggle={() => setExpanded((exp) => !exp)}
/>
{expanded && (
<tr>
<td className="bg-gray-100 p-3" colSpan={5}>
<LinkDetailsTable link={link} />
</td>
</tr>
)}
</>
)
}
function LinkSummaryRow(props: {
link: Manalink
highlight: boolean
expanded: boolean
onToggle: () => void
}) {
const { link, highlight, expanded, onToggle } = props
const className = clsx(
'whitespace-nowrap text-sm hover:cursor-pointer text-gray-500 hover:bg-sky-50 bg-white',
highlight ? 'bg-indigo-100 rounded-lg animate-pulse' : ''
)
return (
<tr id={link.slug} key={link.slug} className={className}>
<td className="py-4 pl-5" onClick={onToggle}>
{expanded ? (
<ChevronUpIcon className="h-5 w-5" />
) : (
<ChevronDownIcon className="h-5 w-5" />
)}
</td>
<td className="px-5 py-4 font-medium text-gray-900">
{formatMoney(link.amount)}
</td>
<td className="px-5 py-4">{getManalinkUrl(link.slug)}</td>
<td className="px-5 py-4">{link.claimedUserIds.length}</td>
<td className="px-5 py-4">{link.maxUses == null ? '∞' : link.maxUses}</td>
<td className="px-5 py-4">
{link.expiresTime == null ? 'Never' : fromNow(link.expiresTime)}
</td>
</tr>
)
}
function LinksTable(props: { links: Manalink[]; highlightedSlug?: string }) {
const { links, highlightedSlug } = props
return links.length == 0 ? (
<p>You don&apos;t currently have any outstanding manalinks.</p>
) : (
<div className="overflow-scroll">
<table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200">
<thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900">
<tr>
<th></th>
<th className="px-5 py-3.5">Amount</th>
<th className="px-5 py-3.5">Link</th>
<th className="px-5 py-3.5">Uses</th>
<th className="px-5 py-3.5">Max Uses</th>
<th className="px-5 py-3.5">Expires</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{links.map((link) => (
<LinkTableRow
link={link}
highlight={link.slug === highlightedSlug}
/>
))}
</tbody>
</table>
</div>
)
}

View File

@ -16,7 +16,6 @@ import {
User,
} from 'common/user'
import { getUser } from 'web/lib/firebase/users'
import { LoadingIndicator } from 'web/components/loading-indicator'
import clsx from 'clsx'
import { RelativeTimestamp } from 'web/components/relative-timestamp'
import { Linkify } from 'web/components/linkify'

57
web/pages/referrals.tsx Normal file
View File

@ -0,0 +1,57 @@
import { Col } from 'web/components/layout/col'
import { SEO } from 'web/components/SEO'
import { Title } from 'web/components/title'
import { useUser } from 'web/hooks/use-user'
import { Page } from 'web/components/page'
import { useTracking } from 'web/hooks/use-tracking'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { REFERRAL_AMOUNT } from 'common/user'
import { CopyLinkButton } from 'web/components/copy-link-button'
import { ENV_CONFIG } from 'common/envs/constants'
import { InfoBox } from 'web/components/info-box'
export const getServerSideProps = redirectIfLoggedOut('/')
export default function ReferralsPage() {
const user = useUser()
useTracking('view referrals')
const url = `https://${ENV_CONFIG.domain}?referrer=${user?.username}`
return (
<Page>
<SEO title="Referrals" description="" url="/add-funds" />
<Col className="items-center">
<Col className="h-full rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md">
<Title className="!mt-0" text="Referrals" />
<img
className="mb-6 block -scale-x-100 self-center"
src="/logo-flapping-with-money.gif"
width={200}
height={200}
/>
<div className={'mb-4'}>
Invite new users to Manifold and get M${REFERRAL_AMOUNT} if they
sign up!
</div>
<CopyLinkButton
url={url}
tracking="copy referral link"
buttonClassName="btn-md rounded-l-none"
toastClassName={'-left-28 mt-1'}
/>
<InfoBox
title="FYI"
className="mt-4 max-w-md"
text="You can also earn the referral bonus from sharing the link to any market or group you've created!"
/>
</Col>
</Col>
</Page>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

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="https://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 plugin = require('tailwindcss/plugin')
module.exports = {
content: [
@ -32,6 +33,22 @@ module.exports = {
require('@tailwindcss/typography'),
require('@tailwindcss/line-clamp'),
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: {
themes: [

178
yarn.lock
View File

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