diff --git a/firestore.rules b/firestore.rules index 7d24cf66..894367a6 100644 --- a/firestore.rules +++ b/firestore.rules @@ -22,7 +22,7 @@ service cloud.firestore { match /contracts/{contractId} { allow read; allow update: if resource.data.creatorId == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['description']); + .hasOnly(['description', 'tags', 'lowercaseTags']); allow delete: if resource.data.creatorId == request.auth.uid; } diff --git a/functions/src/backup-db.ts b/functions/src/backup-db.ts index db2df27d..cc5c531d 100644 --- a/functions/src/backup-db.ts +++ b/functions/src/backup-db.ts @@ -1,5 +1,20 @@ -// Export script from https://firebase.google.com/docs/firestore/solutions/schedule-export -// To import the data into dev Firestore: https://firebase.google.com/docs/firestore/manage-data/move-data +// This code is copied from https://firebase.google.com/docs/firestore/solutions/schedule-export +// +// To deploy after any changes: +// `yarn deploy` +// +// To manually run a backup: Click "Run Now" on the backupDb script +// https://console.cloud.google.com/cloudscheduler?project=mantic-markets +// +// Backups are here: +// https://console.cloud.google.com/storage/browser/manifold-firestore-backup +// +// To import the data into dev Firestore (from https://firebase.google.com/docs/firestore/manage-data/move-data): +// 0. Open up a cloud shell from manticmarkets@gmail.com: https://console.cloud.google.com/home/dashboard?cloudshell=true +// 1. `gcloud config set project dev-mantic-markets` +// 2. Get the backup timestamp e.g. `2022-01-25T21:19:20_6605` +// 3. `gcloud firestore import gs://manifold-firestore-backup/2022-01-25T21:19:20_6605 --async` +// 4. (Optional) `gcloud firestore operations list` to check progress import * as functions from 'firebase-functions' import * as firestore from '@google-cloud/firestore' @@ -20,12 +35,15 @@ export const backupDb = functions.pubsub // Leave collectionIds empty to export all collections // or set to a list of collection IDs to export, // collectionIds: ['users', 'posts'] + // NOTE: Subcollections are not backed up by default collectionIds: [ 'contracts', 'folds', 'private-users', 'stripe-transactions', 'users', + 'bets', + 'comments', ], }) .then((responses) => { diff --git a/functions/src/create-fold.ts b/functions/src/create-fold.ts index a653fa93..368a6b03 100644 --- a/functions/src/create-fold.ts +++ b/functions/src/create-fold.ts @@ -23,11 +23,16 @@ export const createFold = functions.runWith({ minInstances: 1 }).https.onCall( const creator = await getUser(userId) if (!creator) return { status: 'error', message: 'User not found' } - const { name, about, tags } = data + let { name, about, tags } = data if (!name || typeof name !== 'string') return { status: 'error', message: 'Name must be a non-empty string' } + if (!about || typeof about !== 'string') + return { status: 'error', message: 'About must be a non-empty string' } + + about = about.slice(0, 140) + if (!_.isArray(tags)) return { status: 'error', message: 'Tags must be an array of strings' } diff --git a/web/components/contract-feed.tsx b/web/components/contract-feed.tsx index 10580695..ae518cce 100644 --- a/web/components/contract-feed.tsx +++ b/web/components/contract-feed.tsx @@ -41,6 +41,7 @@ import { outcome } from '../../common/contract' import { fromNow } from '../lib/util/time' import BetRow from './bet-row' import clsx from 'clsx' +import { parseTags } from '../../common/util/parse' export function AvatarWithIcon(props: { username: string; avatarUrl: string }) { const { username, avatarUrl } = props @@ -173,7 +174,13 @@ export function ContractDescription(props: { setEditing(false) const newDescription = `${contract.description}\n\n${description}`.trim() - await updateContract(contract.id, { description: newDescription }) + const tags = parseTags(`${contract.tags.join(' ')} ${newDescription}`) + const lowercaseTags = tags.map((tag) => tag.toLowerCase()) + await updateContract(contract.id, { + description: newDescription, + tags, + lowercaseTags, + }) setDescription(editStatement()) } diff --git a/web/components/create-fold-button.tsx b/web/components/create-fold-button.tsx new file mode 100644 index 00000000..ac9913c5 --- /dev/null +++ b/web/components/create-fold-button.tsx @@ -0,0 +1,137 @@ +import clsx from 'clsx' +import { useRouter } from 'next/router' +import { useState } from 'react' +import { parseWordsAsTags } from '../../common/util/parse' +import { createFold } from '../lib/firebase/api-call' +import { foldPath } from '../lib/firebase/folds' +import { toCamelCase } from '../lib/util/format' +import { ConfirmationButton } from './confirmation-button' +import { Col } from './layout/col' +import { Spacer } from './layout/spacer' +import { TagsList } from './tags-list' +import { Title } from './title' + +export function CreateFoldButton() { + const [name, setName] = useState('') + const [about, setAbout] = useState('') + const [otherTags, setOtherTags] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + const router = useRouter() + + const tags = parseWordsAsTags(toCamelCase(name) + ' ' + otherTags) + + const updateName = (newName: string) => { + setName(newName) + } + + const onSubmit = async () => { + setIsSubmitting(true) + + const result = await createFold({ + name, + tags, + about, + }).then((r) => r.data || {}) + + if (result.fold) { + await router.push(foldPath(result.fold)).catch((e) => { + console.log(e) + setIsSubmitting(false) + }) + } else { + console.log(result.status, result.message) + setIsSubmitting(false) + } + } + + return ( + + + + <Col className="text-gray-500 gap-1"> + <div>A fold is a Manifold community with selected markets.</div> + <div>Markets are included if they match one or more tags.</div> + </Col> + + <Spacer h={4} /> + + <div> + <div className="form-control w-full"> + <label className="label"> + <span className="mb-1">Fold name</span> + </label> + + <input + placeholder="Your fold name" + className="input input-bordered resize-none" + disabled={isSubmitting} + value={name} + onChange={(e) => updateName(e.target.value || '')} + /> + </div> + + <Spacer h={4} /> + + <div className="form-control w-full"> + <label className="label"> + <span className="mb-1">About</span> + </label> + + <input + placeholder="Short description (140 characters max)" + className="input input-bordered resize-none" + disabled={isSubmitting} + value={about} + maxLength={140} + onChange={(e) => setAbout(e.target.value || '')} + /> + </div> + + <Spacer h={4} /> + + <label className="label"> + <span className="mb-1">Primary tag</span> + </label> + <TagsList noLink tags={[`#${toCamelCase(name)}`]} /> + + <Spacer h={4} /> + + <div className="form-control w-full"> + <label className="label"> + <span className="mb-1">Additional tags</span> + </label> + + <input + placeholder="Politics, Economics, Rationality (Optional)" + className="input input-bordered resize-none" + disabled={isSubmitting} + value={otherTags} + onChange={(e) => setOtherTags(e.target.value || '')} + /> + </div> + + <Spacer h={4} /> + + <TagsList + tags={parseWordsAsTags(otherTags).map((tag) => `#${tag}`)} + noLink + /> + </div> + </ConfirmationButton> + ) +} diff --git a/web/components/edit-fold-button.tsx b/web/components/edit-fold-button.tsx index 3ac2504d..2176e165 100644 --- a/web/components/edit-fold-button.tsx +++ b/web/components/edit-fold-button.tsx @@ -10,8 +10,8 @@ import { toCamelCase } from '../lib/util/format' import { Spacer } from './layout/spacer' import { TagsList } from './tags-list' -export function EditFoldButton(props: { fold: Fold }) { - const { fold } = props +export function EditFoldButton(props: { fold: Fold; className?: string }) { + const { fold, className } = props const [name, setName] = useState(fold.name) const [about, setAbout] = useState(fold.about ?? '') @@ -41,7 +41,7 @@ export function EditFoldButton(props: { fold: Fold }) { } return ( - <div> + <div className={clsx('p-1', className)}> <label htmlFor="edit" className={clsx( diff --git a/web/components/site-link.tsx b/web/components/site-link.tsx index a3a5a01b..95b2758c 100644 --- a/web/components/site-link.tsx +++ b/web/components/site-link.tsx @@ -12,7 +12,7 @@ export const SiteLink = (props: { <a href={href} className={clsx( - 'hover:underline hover:decoration-indigo-400 hover:decoration-2', + 'hover:underline hover:decoration-indigo-400 hover:decoration-2 z-10', className )} onClick={(e) => e.stopPropagation()} @@ -23,7 +23,7 @@ export const SiteLink = (props: { <Link href={href}> <a className={clsx( - 'hover:underline hover:decoration-indigo-400 hover:decoration-2', + 'hover:underline hover:decoration-indigo-400 hover:decoration-2 z-10', className )} onClick={(e) => e.stopPropagation()} diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index a0fc7de8..d92122e0 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -2,6 +2,7 @@ import { getFunctions, httpsCallable } from 'firebase/functions' import { Fold } from '../../../common/fold' import { User } from '../../../common/user' import { randomString } from '../../../common/util/random' +import './init' const functions = getFunctions() diff --git a/web/lib/firebase/folds.ts b/web/lib/firebase/folds.ts index 8d2b850a..3220f884 100644 --- a/web/lib/firebase/folds.ts +++ b/web/lib/firebase/folds.ts @@ -45,7 +45,14 @@ export async function getFoldContracts(fold: Fold) { // TODO: if tags.length > 10, execute multiple parallel queries tags.length > 0 ? getValues<Contract>( - query(contractCollection, where('tags', 'array-contains-any', tags)) + query( + contractCollection, + where( + 'lowercaseTags', + 'array-contains-any', + tags.map((tag) => tag.toLowerCase()) + ) + ) ) : [], diff --git a/web/pages/fold/[...slugs]/index.tsx b/web/pages/fold/[...slugs]/index.tsx index c73be9b8..4cdb41ca 100644 --- a/web/pages/fold/[...slugs]/index.tsx +++ b/web/pages/fold/[...slugs]/index.tsx @@ -30,6 +30,7 @@ import { scoreCreators, scoreTraders } from '../../../lib/firebase/scoring' import { Leaderboard } from '../../../components/leaderboard' import { formatMoney } from '../../../lib/util/format' import { EditFoldButton } from '../../../components/edit-fold-button' +import Custom404 from '../../404' export async function getStaticProps(props: { params: { slugs: string[] } }) { const { slugs } = props.params @@ -106,9 +107,10 @@ async function toUserScores(userScores: { [userId: string]: number }) { export async function getStaticPaths() { return { paths: [], fallback: 'blocking' } } +const foldSubpages = [undefined, 'activity', 'markets', 'leaderboards'] as const export default function FoldPage(props: { - fold: Fold + fold: Fold | null curator: User contracts: Contract[] activeContracts: Contract[] @@ -118,7 +120,6 @@ export default function FoldPage(props: { topTraderScores: number[] topCreators: User[] topCreatorScores: number[] - params: { tab: string } }) { const { curator, @@ -134,86 +135,86 @@ export default function FoldPage(props: { const router = useRouter() const { slugs } = router.query as { slugs: string[] } - const page = - (slugs[1] as 'markets' | 'leaderboards' | undefined) ?? 'activity' - const fold = useFold(props.fold.id) ?? props.fold - const { curatorId } = fold + const page = (slugs[1] ?? 'activity') as typeof foldSubpages[number] + + const fold = useFold(props.fold?.id ?? '') ?? props.fold const { query, setQuery, sort, setSort } = useQueryAndSortParams({ defaultSort: 'most-traded', }) const user = useUser() - const isCurator = user?.id === curatorId + const isCurator = user && fold && user.id === fold.curatorId + + if (fold === null || !foldSubpages.includes(page) || slugs[2]) { + return <Custom404 /> + } return ( <Page wide> - <Col className="items-center"> - <Col className="max-w-5xl w-full"> - <Col className="sm:flex-row sm:justify-between sm:items-end gap-4 mb-6"> - <Title className="!m-0" text={fold.name} /> - {isCurator && <EditFoldButton fold={fold} />} - </Col> + <Row className="justify-between mb-6 px-1"> + <Title className="!m-0" text={fold.name} /> + {isCurator && <EditFoldButton className="ml-1" fold={fold} />} + </Row> - <div className="tabs mb-4"> - <Link href={foldPath(fold)} shallow> - <a - className={clsx( - 'tab tab-bordered', - page === 'activity' && 'tab-active' - )} - > - Activity - </a> - </Link> + <Col className="md:hidden text-gray-500 gap-2 mb-6 px-1"> + <Row> + <div className="mr-1">Curated by</div> + <UserLink + className="text-neutral" + name={curator.name} + username={curator.username} + /> + </Row> + <div>{fold.about}</div> + </Col> - <Link href={foldPath(fold, 'markets')} shallow> - <a - className={clsx( - 'tab tab-bordered', - page === 'markets' && 'tab-active' - )} - > - Markets - </a> - </Link> - <Link href={foldPath(fold, 'leaderboards')} shallow> - <a - className={clsx( - 'tab tab-bordered', - page === 'leaderboards' && 'tab-active', - page !== 'leaderboards' && 'md:hidden' - )} - > - Leaderboards - </a> - </Link> - </div> + <div className="tabs mb-2"> + <Link href={foldPath(fold)} shallow> + <a + className={clsx( + 'tab tab-bordered', + page === 'activity' && 'tab-active' + )} + > + Activity + </a> + </Link> - {page === 'activity' && ( - <Row className="gap-8"> - <Col> - <ActivityFeed - contracts={activeContracts} - contractBets={activeContractBets} - contractComments={activeContractComments} - /> - </Col> - <Col className="hidden md:flex max-w-xs gap-10"> - <FoldOverview fold={fold} curator={curator} /> - <FoldLeaderboards - topTraders={topTraders} - topTraderScores={topTraderScores} - topCreators={topCreators} - topCreatorScores={topCreatorScores} - /> - </Col> - </Row> - )} + <Link href={foldPath(fold, 'markets')} shallow> + <a + className={clsx( + 'tab tab-bordered', + page === 'markets' && 'tab-active' + )} + > + Markets + </a> + </Link> + <Link href={foldPath(fold, 'leaderboards')} shallow> + <a + className={clsx( + 'tab tab-bordered', + page === 'leaderboards' && 'tab-active', + page !== 'leaderboards' && 'md:hidden' + )} + > + Leaderboards + </a> + </Link> + </div> - {page === 'markets' && ( - <div className="w-full"> + {(page === 'activity' || page === 'markets') && ( + <Row className={clsx(page === 'activity' ? 'gap-16' : 'gap-8')}> + <Col className="flex-1"> + {page === 'activity' ? ( + <ActivityFeed + contracts={activeContracts} + contractBets={activeContractBets} + contractComments={activeContractComments} + /> + ) : ( <SearchableGrid contracts={contracts} query={query} @@ -221,21 +222,30 @@ export default function FoldPage(props: { sort={sort} setSort={setSort} /> - </div> - )} + )} + </Col> + <Col className="hidden md:flex max-w-xs gap-10"> + <FoldOverview fold={fold} curator={curator} /> + <FoldLeaderboards + topTraders={topTraders} + topTraderScores={topTraderScores} + topCreators={topCreators} + topCreatorScores={topCreatorScores} + /> + </Col> + </Row> + )} - {page === 'leaderboards' && ( - <Col className="gap-8"> - <FoldLeaderboards - topTraders={topTraders} - topTraderScores={topTraderScores} - topCreators={topCreators} - topCreatorScores={topCreatorScores} - /> - </Col> - )} + {page === 'leaderboards' && ( + <Col className="gap-8 lg:flex-row"> + <FoldLeaderboards + topTraders={topTraders} + topTraderScores={topTraderScores} + topCreators={topCreators} + topCreatorScores={topCreatorScores} + /> </Col> - </Col> + )} </Page> ) } @@ -246,10 +256,10 @@ function FoldOverview(props: { fold: Fold; curator: User }) { return ( <Col className="max-w-sm"> - <div className="px-4 py-3 bg-indigo-700 text-white text-sm rounded-t"> + <div className="px-4 py-3 bg-indigo-500 text-white text-sm rounded-t"> About community </div> - <Col className="p-4 bg-white self-start gap-2 rounded-b"> + <Col className="p-4 bg-white gap-2 rounded-b"> <Row> <div className="text-gray-500 mr-1">Curated by</div> <UserLink diff --git a/web/pages/folds.tsx b/web/pages/folds.tsx index d6be19e9..f0706b38 100644 --- a/web/pages/folds.tsx +++ b/web/pages/folds.tsx @@ -1,24 +1,18 @@ -import clsx from 'clsx' import _ from 'lodash' -import { useRouter } from 'next/router' +import Link from 'next/link' import { useEffect, useState } from 'react' import { Fold } from '../../common/fold' -import { parseWordsAsTags } from '../../common/util/parse' -import { ConfirmationButton } from '../components/confirmation-button' +import { CreateFoldButton } from '../components/create-fold-button' import { Col } from '../components/layout/col' import { Row } from '../components/layout/row' -import { Spacer } from '../components/layout/spacer' import { Page } from '../components/page' import { SiteLink } from '../components/site-link' -import { TagsList } from '../components/tags-list' import { Title } from '../components/title' import { UserLink } from '../components/user-page' import { useFolds } from '../hooks/use-fold' import { useUser } from '../hooks/use-user' -import { createFold } from '../lib/firebase/api-call' import { foldPath, listAllFolds } from '../lib/firebase/folds' import { getUser, User } from '../lib/firebase/users' -import { toCamelCase } from '../lib/util/format' export async function getStaticProps() { const folds = await listAllFolds().catch((_) => []) @@ -67,32 +61,45 @@ export default function Folds(props: { return ( <Page> <Col className="items-center"> - <Col className="max-w-2xl w-full px-2 sm:px-0"> + <Col className="max-w-lg w-full px-2 sm:px-0"> <Row className="justify-between items-center"> - <Title text="Manifold communities: Folds" /> + <Title text="Folds" /> {user && <CreateFoldButton />} </Row> <div className="text-gray-500 mb-6"> - Browse folds on topics that interest you. + Browse Manifold communities, called folds. </div> - <Col className="gap-4"> + <Col className="gap-2"> {folds.map((fold) => ( - <Row key={fold.id} className="items-center gap-2"> - <SiteLink href={foldPath(fold)}>{fold.name}</SiteLink> - <div /> - <div className="text-sm text-gray-500">12 followers</div> - <div className="text-gray-500">•</div> - <Row> - <div className="text-sm text-gray-500 mr-1">Curated by</div> - <UserLink - className="text-sm text-neutral" - name={curatorsDict[fold.curatorId]?.name ?? ''} - username={curatorsDict[fold.curatorId]?.username ?? ''} - /> + <Col + key={fold.id} + className="bg-white p-4 rounded-xl gap-1 relative" + > + <Link href={foldPath(fold)}> + <a className="absolute left-0 right-0 top-0 bottom-0" /> + </Link> + <Row className="justify-between items-center gap-2"> + <SiteLink href={foldPath(fold)}>{fold.name}</SiteLink> + <button className="btn btn-secondary btn-sm z-10 mb-1"> + Follow + </button> </Row> - </Row> + <Row className="items-center gap-2 text-gray-500 text-sm"> + <div>12 followers</div> + <div>•</div> + <Row> + <div className="mr-1">Curated by</div> + <UserLink + className="text-neutral" + name={curatorsDict[fold.curatorId]?.name ?? ''} + username={curatorsDict[fold.curatorId]?.username ?? ''} + /> + </Row> + </Row> + <div className="text-gray-500 text-sm">{fold.about}</div> + </Col> ))} </Col> </Col> @@ -100,128 +107,3 @@ export default function Folds(props: { </Page> ) } - -function CreateFoldButton() { - const [name, setName] = useState('') - const [about, setAbout] = useState('') - const [otherTags, setOtherTags] = useState('') - const [isSubmitting, setIsSubmitting] = useState(false) - - const router = useRouter() - - const tags = parseWordsAsTags(toCamelCase(name) + ' ' + otherTags) - - const updateName = (newName: string) => { - setName(newName) - } - - const onSubmit = async () => { - setIsSubmitting(true) - - const result = await createFold({ - name, - tags, - about, - }).then((r) => r.data || {}) - - if (result.fold) { - await router.push(foldPath(result.fold)).catch((e) => { - console.log(e) - setIsSubmitting(false) - }) - } else { - console.log(result.status, result.message) - setIsSubmitting(false) - } - } - - return ( - <ConfirmationButton - id="create-fold" - openModelBtn={{ - label: 'Create a fold', - className: clsx( - isSubmitting ? 'loading btn-disabled' : 'btn-primary', - 'btn-sm' - ), - }} - submitBtn={{ - label: 'Create', - className: clsx(name && about ? 'btn-primary' : 'btn-disabled'), - }} - onSubmit={onSubmit} - > - <Title className="!mt-0" text="Create a fold" /> - - <Col className="text-gray-500 gap-1"> - <div>A fold is a sub-community of markets organized on a topic.</div> - <div>Markets are included if they match one or more tags.</div> - </Col> - - <Spacer h={4} /> - - <div> - <div className="form-control w-full"> - <label className="label"> - <span className="mb-1">Fold name</span> - </label> - - <input - placeholder="Your fold name" - className="input input-bordered resize-none" - disabled={isSubmitting} - value={name} - onChange={(e) => updateName(e.target.value || '')} - /> - </div> - - <Spacer h={4} /> - - <div className="form-control w-full"> - <label className="label"> - <span className="mb-1">About</span> - </label> - - <input - placeholder="Short description (140 characters max)" - className="input input-bordered resize-none" - disabled={isSubmitting} - value={about} - maxLength={140} - onChange={(e) => setAbout(e.target.value || '')} - /> - </div> - - <Spacer h={4} /> - - <label className="label"> - <span className="mb-1">Primary tag</span> - </label> - <TagsList noLink tags={[`#${toCamelCase(name)}`]} /> - - <Spacer h={4} /> - - <div className="form-control w-full"> - <label className="label"> - <span className="mb-1">Additional tags</span> - </label> - - <input - placeholder="Politics, Economics, Rationality (Optional)" - className="input input-bordered resize-none" - disabled={isSubmitting} - value={otherTags} - onChange={(e) => setOtherTags(e.target.value || '')} - /> - </div> - - <Spacer h={4} /> - - <TagsList - tags={parseWordsAsTags(otherTags).map((tag) => `#${tag}`)} - noLink - /> - </div> - </ConfirmationButton> - ) -}