From b21daa1248986cc2f4b099904ddf776242da9fde Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 27 Sep 2022 17:30:07 -0500 Subject: [PATCH] Date docs on Manifold (#941) * Date docs * Create date doc * Create and show a date market as well * Move url to date-docs * Date doc individual page * Add share button * Edit date docs * Layout * Add comments for create-post * Add comments and back nav * Fix urls * Tweaks --- common/post.ts | 8 ++ functions/src/api.ts | 2 +- functions/src/create-market.ts | 27 +++-- functions/src/create-post.ts | 39 +++++- web/components/site-link.tsx | 4 +- web/lib/firebase/posts.ts | 24 +++- web/pages/date-docs/[username].tsx | 159 ++++++++++++++++++++++++ web/pages/date-docs/create.tsx | 179 ++++++++++++++++++++++++++++ web/pages/date-docs/index.tsx | 72 +++++++++++ web/pages/post/[...slugs]/index.tsx | 19 +-- web/posts/post-comments.tsx | 2 - 11 files changed, 500 insertions(+), 35 deletions(-) create mode 100644 web/pages/date-docs/[username].tsx create mode 100644 web/pages/date-docs/create.tsx create mode 100644 web/pages/date-docs/index.tsx diff --git a/common/post.ts b/common/post.ts index 05eab685..13a90821 100644 --- a/common/post.ts +++ b/common/post.ts @@ -9,4 +9,12 @@ export type Post = { slug: string } +export type DateDoc = Post & { + bounty: number + birthday: number + photoUrl: string + type: 'date-doc' + contractSlug: string +} + export const MAX_POST_TITLE_LENGTH = 480 diff --git a/functions/src/api.ts b/functions/src/api.ts index 7440f16a..7134c8d8 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -14,7 +14,7 @@ import { export { APIError } from '../../common/api' type Output = Record -type AuthedUser = { +export type AuthedUser = { uid: string creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser }) } diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index 300d91f2..d1483ca4 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -16,7 +16,7 @@ import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' import { chargeUser, getContract, isProd } from './utils' -import { APIError, newEndpoint, validate, zTimestamp } from './api' +import { APIError, AuthedUser, newEndpoint, validate, zTimestamp } from './api' import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy' import { @@ -92,7 +92,11 @@ const multipleChoiceSchema = z.object({ answers: z.string().trim().min(1).array().min(2), }) -export const createmarket = newEndpoint({}, async (req, auth) => { +export const createmarket = newEndpoint({}, (req, auth) => { + return createMarketHelper(req.body, auth) +}) + +export async function createMarketHelper(body: any, auth: AuthedUser) { const { question, description, @@ -101,16 +105,13 @@ export const createmarket = newEndpoint({}, async (req, auth) => { outcomeType, groupId, visibility = 'public', - } = validate(bodySchema, req.body) + } = validate(bodySchema, body) let min, max, initialProb, isLogScale, answers if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { let initialValue - ;({ min, max, initialValue, isLogScale } = validate( - numericSchema, - req.body - )) + ;({ min, max, initialValue, isLogScale } = validate(numericSchema, body)) if (max - min <= 0.01 || initialValue <= min || initialValue >= max) throw new APIError(400, 'Invalid range.') @@ -126,11 +127,11 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } if (outcomeType === 'BINARY') { - ;({ initialProb } = validate(binarySchema, req.body)) + ;({ initialProb } = validate(binarySchema, body)) } if (outcomeType === 'MULTIPLE_CHOICE') { - ;({ answers } = validate(multipleChoiceSchema, req.body)) + ;({ answers } = validate(multipleChoiceSchema, body)) } const userDoc = await firestore.collection('users').doc(auth.uid).get() @@ -186,17 +187,17 @@ export const createmarket = newEndpoint({}, async (req, auth) => { // convert string descriptions into JSONContent const newDescription = - typeof description === 'string' + !description || typeof description === 'string' ? { type: 'doc', content: [ { type: 'paragraph', - content: [{ type: 'text', text: description }], + content: [{ type: 'text', text: description || ' ' }], }, ], } - : description ?? {} + : description const contract = getNewContract( contractRef.id, @@ -323,7 +324,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } return contract -}) +} const getSlug = async (question: string) => { const proposedSlug = slugify(question) diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts index 113a34bd..a342dc05 100644 --- a/functions/src/create-post.ts +++ b/functions/src/create-post.ts @@ -7,6 +7,9 @@ import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post' import { APIError, newEndpoint, validate } from './api' import { JSONContent } from '@tiptap/core' import { z } from 'zod' +import { removeUndefinedProps } from '../../common/util/object' +import { createMarketHelper } from './create-market' +import { DAY_MS } from '../../common/util/time' const contentSchema: z.ZodType = z.lazy(() => z.intersection( @@ -35,11 +38,21 @@ const postSchema = z.object({ title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), content: contentSchema, groupId: z.string().optional(), + + // Date doc fields: + bounty: z.number().optional(), + birthday: z.number().optional(), + photoUrl: z.string().optional(), + type: z.string().optional(), + question: z.string().optional(), }) export const createpost = newEndpoint({}, async (req, auth) => { const firestore = admin.firestore() - const { title, content, groupId } = validate(postSchema, req.body) + const { title, content, groupId, question, ...otherProps } = validate( + postSchema, + req.body + ) const creator = await getUser(auth.uid) if (!creator) @@ -51,14 +64,34 @@ export const createpost = newEndpoint({}, async (req, auth) => { const postRef = firestore.collection('posts').doc() - const post: Post = { + // If this is a date doc, create a market for it. + let contractSlug + if (question) { + const closeTime = Date.now() + DAY_MS * 30 * 3 + + const result = await createMarketHelper( + { + question, + closeTime, + outcomeType: 'BINARY', + visibility: 'unlisted', + initialProb: 50, + }, + auth + ) + contractSlug = result.slug + } + + const post: Post = removeUndefinedProps({ + ...otherProps, id: postRef.id, creatorId: creator.id, slug, title, createdTime: Date.now(), content: content, - } + contractSlug, + }) await postRef.create(post) if (groupId) { diff --git a/web/components/site-link.tsx b/web/components/site-link.tsx index f395e6a9..2b97f07d 100644 --- a/web/components/site-link.tsx +++ b/web/components/site-link.tsx @@ -6,13 +6,15 @@ export const linkClass = 'z-10 break-anywhere hover:underline hover:decoration-indigo-400 hover:decoration-2' export const SiteLink = (props: { - href: string + href: string | undefined children?: ReactNode onClick?: () => void className?: string }) => { const { href, children, onClick, className } = props + if (!href) return <>{children} + return ( ('posts') @@ -44,3 +45,22 @@ export async function listPosts(postIds?: string[]) { if (postIds === undefined) return [] return Promise.all(postIds.map(getPost)) } + +export async function getDateDocs() { + const q = query(posts, where('type', '==', 'date-doc')) + return getValues(q) +} + +export async function getDateDoc(username: string) { + const user = await getUserByUsername(username) + if (!user) return null + + const q = query( + posts, + where('type', '==', 'date-doc'), + where('creatorId', '==', user.id) + ) + const docs = await getValues(q) + const post = docs.length === 0 ? null : docs[0] + return { post, user } +} diff --git a/web/pages/date-docs/[username].tsx b/web/pages/date-docs/[username].tsx new file mode 100644 index 00000000..d6ac37cd --- /dev/null +++ b/web/pages/date-docs/[username].tsx @@ -0,0 +1,159 @@ +import { getDateDoc } from 'web/lib/firebase/posts' +import { ArrowLeftIcon, LinkIcon } from '@heroicons/react/outline' +import { Page } from 'web/components/page' +import dayjs from 'dayjs' + +import { DateDoc } from 'common/post' +import { Content } from 'web/components/editor' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { SiteLink } from 'web/components/site-link' +import { User } from 'web/lib/firebase/users' +import { DOMAIN } from 'common/envs/constants' +import Custom404 from '../404' +import { ShareIcon } from '@heroicons/react/solid' +import clsx from 'clsx' +import { Button } from 'web/components/button' +import { track } from '@amplitude/analytics-browser' +import toast from 'react-hot-toast' +import { copyToClipboard } from 'web/lib/util/copy' +import { useUser } from 'web/hooks/use-user' +import { PostCommentsActivity, RichEditPost } from '../post/[...slugs]' +import { usePost } from 'web/hooks/use-post' +import { useTipTxns } from 'web/hooks/use-tip-txns' +import { useCommentsOnPost } from 'web/hooks/use-comments' + +export async function getStaticProps(props: { params: { username: string } }) { + const { username } = props.params + const { user: creator, post } = (await getDateDoc(username)) ?? { + creator: null, + post: null, + } + + return { + props: { + creator, + post, + }, + revalidate: 5, // regenerate after five seconds + } +} + +export async function getStaticPaths() { + return { paths: [], fallback: 'blocking' } +} + +export default function DateDocPageHelper(props: { + creator: User | null + post: DateDoc | null +}) { + const { creator, post } = props + + if (!creator || !post) return + + return +} + +function DateDocPage(props: { creator: User; post: DateDoc }) { + const { creator, post } = props + + const tips = useTipTxns({ postId: post.id }) + const comments = useCommentsOnPost(post.id) ?? [] + + return ( + + + + + + + + +
Add your endorsement of {creator.name}!
+ + + +
+ ) +} + +export function DateDocPost(props: { + dateDoc: DateDoc + creator: User + link?: boolean +}) { + const { dateDoc, creator, link } = props + const { content, birthday, photoUrl, contractSlug } = dateDoc + const { name, username } = creator + + const user = useUser() + const post = usePost(dateDoc.id) ?? dateDoc + + const age = dayjs().diff(birthday, 'year') + const shareUrl = `https://${DOMAIN}/date-docs/${username}` + const marketUrl = `https://${DOMAIN}/${username}/${contractSlug}` + + return ( + + + + +
+ {name}, {age} +
+ + + + +
+ {name} + +
+ {user && user.id === creator.id ? ( + + ) : ( + + )} +
+ +
+ + ) +} diff --git a/web/pages/date-docs/create.tsx b/web/pages/date-docs/create.tsx new file mode 100644 index 00000000..5d72da42 --- /dev/null +++ b/web/pages/date-docs/create.tsx @@ -0,0 +1,179 @@ +import Router from 'next/router' +import { useEffect, useState } from 'react' +import Textarea from 'react-expanding-textarea' + +import { DateDoc } from 'common/post' +import { useTextEditor, TextEditor } from 'web/components/editor' +import { Page } from 'web/components/page' +import { Title } from 'web/components/title' +import { useUser } from 'web/hooks/use-user' +import { createPost } from 'web/lib/firebase/api' +import { Row } from 'web/components/layout/row' +import { Button } from 'web/components/button' +import dayjs from 'dayjs' +import { MINUTE_MS } from 'common/util/time' +import { Col } from 'web/components/layout/col' +import { uploadImage } from 'web/lib/firebase/storage' +import { LoadingIndicator } from 'web/components/loading-indicator' +import { MAX_QUESTION_LENGTH } from 'common/contract' + +export default function CreateDateDocPage() { + const user = useUser() + + useEffect(() => { + if (user === null) Router.push('/date') + }) + + const title = `${user?.name}'s Date Doc` + const [birthday, setBirthday] = useState(undefined) + const [photoUrl, setPhotoUrl] = useState('') + const [avatarLoading, setAvatarLoading] = useState(false) + const [question, setQuestion] = useState( + 'Will I find a partner in the next 3 months?' + ) + + const [isSubmitting, setIsSubmitting] = useState(false) + + const { editor, upload } = useTextEditor({ + disabled: isSubmitting, + }) + + const birthdayTime = birthday ? dayjs(birthday).valueOf() : undefined + const isValid = + user && + birthday && + photoUrl && + editor && + editor.isEmpty === false && + question + + const fileHandler = async (event: any) => { + if (!user) return + + const file = event.target.files[0] + + setAvatarLoading(true) + + await uploadImage(user.username, file) + .then(async (url) => { + setPhotoUrl(url) + setAvatarLoading(false) + }) + .catch(() => { + setAvatarLoading(false) + setPhotoUrl('') + }) + } + + async function saveDateDoc() { + if (!user || !editor || !birthdayTime) return + + const newPost: Omit< + DateDoc, + 'id' | 'creatorId' | 'createdTime' | 'slug' | 'contractSlug' + > & { question: string } = { + title, + content: editor.getJSON(), + bounty: 0, + birthday: birthdayTime, + photoUrl, + type: 'date-doc', + question, + } + + const result = await createPost(newPost) + + if (result.post) { + await Router.push(`/date-docs/${user.username}`) + } + } + + return ( + +
+
+ + + <Button + type="submit" + disabled={isSubmitting || !isValid || upload.isLoading} + onClick={async () => { + setIsSubmitting(true) + await saveDateDoc() + setIsSubmitting(false) + }} + color="blue" + > + {isSubmitting ? 'Publishing...' : 'Publish'} + </Button> + </Row> + + <Col className="gap-8"> + <Col className="max-w-[160px] justify-start gap-4"> + <div className="">Birthday</div> + <input + type={'date'} + className="input input-bordered" + onClick={(e) => e.stopPropagation()} + onChange={(e) => setBirthday(e.target.value)} + max={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS} + disabled={isSubmitting} + value={birthday} + /> + </Col> + + <Col className="gap-4"> + <div className="">Photo</div> + <Row className="items-center gap-4"> + {avatarLoading ? ( + <LoadingIndicator /> + ) : ( + <> + {photoUrl && ( + <img + src={photoUrl} + width={80} + height={80} + className="flex h-[80px] w-[80px] items-center justify-center rounded-lg bg-gray-400 object-cover" + /> + )} + <input + className="text-sm text-gray-500" + type="file" + name="file" + accept="image/*" + onChange={fileHandler} + /> + </> + )} + </Row> + </Col> + + <Col className="gap-4"> + <div className=""> + Tell us about you! What are you looking for? + </div> + <TextEditor editor={editor} upload={upload} /> + </Col> + + <Col className="gap-4"> + <div className=""> + Finally, we'll create an (unlisted) prediction market! + </div> + + <Col className="gap-2"> + <Textarea + className="input input-bordered resize-none" + maxLength={MAX_QUESTION_LENGTH} + value={question} + onChange={(e) => setQuestion(e.target.value || '')} + /> + <div className="ml-2 text-gray-500">Cost: M$100</div> + </Col> + </Col> + </Col> + </div> + </div> + </Page> + ) +} diff --git a/web/pages/date-docs/index.tsx b/web/pages/date-docs/index.tsx new file mode 100644 index 00000000..d2dd874c --- /dev/null +++ b/web/pages/date-docs/index.tsx @@ -0,0 +1,72 @@ +import { Page } from 'web/components/page' +import { PlusCircleIcon } from '@heroicons/react/outline' + +import { getDateDocs } from 'web/lib/firebase/posts' +import type { DateDoc } from 'common/post' +import { Title } from 'web/components/title' +import { Spacer } from 'web/components/layout/spacer' +import { Col } from 'web/components/layout/col' +import { useUser } from 'web/hooks/use-user' +import { Row } from 'web/components/layout/row' +import { Button } from 'web/components/button' +import { SiteLink } from 'web/components/site-link' +import { getUser, User } from 'web/lib/firebase/users' +import { DateDocPost } from './[username]' + +export async function getStaticProps() { + const dateDocs = await getDateDocs() + const docCreators = await Promise.all( + dateDocs.map((d) => getUser(d.creatorId)) + ) + + return { + props: { + dateDocs, + docCreators, + }, + + revalidate: 60, // regenerate after a minute + } +} + +export default function DatePage(props: { + dateDocs: DateDoc[] + docCreators: User[] +}) { + const { dateDocs, docCreators } = props + const user = useUser() + + const hasDoc = dateDocs.some((d) => d.creatorId === user?.id) + + return ( + <Page> + <div className="mx-auto w-full max-w-xl"> + <Row className="items-center justify-between"> + <Title className="!my-0 px-2 text-blue-500" text="Date docs" /> + {!hasDoc && ( + <SiteLink href="/date-docs/create" className="!no-underline"> + <Button className="flex flex-row gap-1" color="blue"> + <PlusCircleIcon + className={'h-5 w-5 flex-shrink-0 text-white'} + aria-hidden="true" + /> + New + </Button> + </SiteLink> + )} + </Row> + <Spacer h={6} /> + <Col className="gap-4"> + {dateDocs.map((dateDoc, i) => ( + <DateDocPost + key={dateDoc.id} + dateDoc={dateDoc} + creator={docCreators[i]} + link + /> + ))} + </Col> + </div> + </Page> + ) +} diff --git a/web/pages/post/[...slugs]/index.tsx b/web/pages/post/[...slugs]/index.tsx index 6cd4408f..b71b7cca 100644 --- a/web/pages/post/[...slugs]/index.tsx +++ b/web/pages/post/[...slugs]/index.tsx @@ -34,9 +34,9 @@ export async function getStaticProps(props: { params: { slugs: string[] } }) { return { props: { - post: post, - creator: creator, - comments: comments, + post, + creator, + comments, }, revalidate: 60, // regenerate after a minute @@ -117,12 +117,7 @@ export default function PostPage(props: { <Spacer h={4} /> <div className="rounded-lg bg-white px-6 py-4 sm:py-0"> - <PostCommentsActivity - post={post} - comments={comments} - tips={tips} - user={creator} - /> + <PostCommentsActivity post={post} comments={comments} tips={tips} /> </div> </div> </Page> @@ -133,9 +128,8 @@ export function PostCommentsActivity(props: { post: Post comments: PostComment[] tips: CommentTipMap - user: User | null | undefined }) { - const { post, comments, user, tips } = props + const { post, comments, tips } = props const commentsByUserId = groupBy(comments, (c) => c.userId) const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_') const topLevelComments = sortBy( @@ -149,7 +143,6 @@ export function PostCommentsActivity(props: { {topLevelComments.map((parent) => ( <PostCommentThread key={parent.id} - user={user} post={post} parentComment={parent} threadComments={sortBy( @@ -164,7 +157,7 @@ export function PostCommentsActivity(props: { ) } -function RichEditPost(props: { post: Post }) { +export function RichEditPost(props: { post: Post }) { const { post } = props const [editing, setEditing] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) diff --git a/web/posts/post-comments.tsx b/web/posts/post-comments.tsx index b98887bb..f1d50a29 100644 --- a/web/posts/post-comments.tsx +++ b/web/posts/post-comments.tsx @@ -3,7 +3,6 @@ import { Editor } from '@tiptap/core' import clsx from 'clsx' import { PostComment } from 'common/comment' import { Post } from 'common/post' -import { User } from 'common/user' import { Dictionary } from 'lodash' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' @@ -21,7 +20,6 @@ import { createCommentOnPost } from 'web/lib/firebase/comments' import { firebaseLogin } from 'web/lib/firebase/users' export function PostCommentThread(props: { - user: User | null | undefined post: Post threadComments: PostComment[] tips: CommentTipMap