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
This commit is contained in:
James Grugett 2022-09-27 17:30:07 -05:00 committed by GitHub
parent 419c7ab636
commit b21daa1248
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 500 additions and 35 deletions

View File

@ -9,4 +9,12 @@ export type Post = {
slug: string slug: string
} }
export type DateDoc = Post & {
bounty: number
birthday: number
photoUrl: string
type: 'date-doc'
contractSlug: string
}
export const MAX_POST_TITLE_LENGTH = 480 export const MAX_POST_TITLE_LENGTH = 480

View File

@ -14,7 +14,7 @@ import {
export { APIError } from '../../common/api' export { APIError } from '../../common/api'
type Output = Record<string, unknown> type Output = Record<string, unknown>
type AuthedUser = { export type AuthedUser = {
uid: string uid: string
creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser }) creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser })
} }

View File

@ -16,7 +16,7 @@ import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { chargeUser, getContract, isProd } from './utils' 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 { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
import { import {
@ -92,7 +92,11 @@ const multipleChoiceSchema = z.object({
answers: z.string().trim().min(1).array().min(2), 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 { const {
question, question,
description, description,
@ -101,16 +105,13 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
outcomeType, outcomeType,
groupId, groupId,
visibility = 'public', visibility = 'public',
} = validate(bodySchema, req.body) } = validate(bodySchema, body)
let min, max, initialProb, isLogScale, answers let min, max, initialProb, isLogScale, answers
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
let initialValue let initialValue
;({ min, max, initialValue, isLogScale } = validate( ;({ min, max, initialValue, isLogScale } = validate(numericSchema, body))
numericSchema,
req.body
))
if (max - min <= 0.01 || initialValue <= min || initialValue >= max) if (max - min <= 0.01 || initialValue <= min || initialValue >= max)
throw new APIError(400, 'Invalid range.') throw new APIError(400, 'Invalid range.')
@ -126,11 +127,11 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
} }
if (outcomeType === 'BINARY') { if (outcomeType === 'BINARY') {
;({ initialProb } = validate(binarySchema, req.body)) ;({ initialProb } = validate(binarySchema, body))
} }
if (outcomeType === 'MULTIPLE_CHOICE') { if (outcomeType === 'MULTIPLE_CHOICE') {
;({ answers } = validate(multipleChoiceSchema, req.body)) ;({ answers } = validate(multipleChoiceSchema, body))
} }
const userDoc = await firestore.collection('users').doc(auth.uid).get() 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 // convert string descriptions into JSONContent
const newDescription = const newDescription =
typeof description === 'string' !description || typeof description === 'string'
? { ? {
type: 'doc', type: 'doc',
content: [ content: [
{ {
type: 'paragraph', type: 'paragraph',
content: [{ type: 'text', text: description }], content: [{ type: 'text', text: description || ' ' }],
}, },
], ],
} }
: description ?? {} : description
const contract = getNewContract( const contract = getNewContract(
contractRef.id, contractRef.id,
@ -323,7 +324,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
} }
return contract return contract
}) }
const getSlug = async (question: string) => { const getSlug = async (question: string) => {
const proposedSlug = slugify(question) const proposedSlug = slugify(question)

View File

@ -7,6 +7,9 @@ import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { z } from 'zod' 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<JSONContent> = z.lazy(() => const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection( z.intersection(
@ -35,11 +38,21 @@ const postSchema = z.object({
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
content: contentSchema, content: contentSchema,
groupId: z.string().optional(), 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) => { export const createpost = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore() 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) const creator = await getUser(auth.uid)
if (!creator) if (!creator)
@ -51,14 +64,34 @@ export const createpost = newEndpoint({}, async (req, auth) => {
const postRef = firestore.collection('posts').doc() 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, id: postRef.id,
creatorId: creator.id, creatorId: creator.id,
slug, slug,
title, title,
createdTime: Date.now(), createdTime: Date.now(),
content: content, content: content,
} contractSlug,
})
await postRef.create(post) await postRef.create(post)
if (groupId) { if (groupId) {

View File

@ -6,13 +6,15 @@ export const linkClass =
'z-10 break-anywhere hover:underline hover:decoration-indigo-400 hover:decoration-2' 'z-10 break-anywhere hover:underline hover:decoration-indigo-400 hover:decoration-2'
export const SiteLink = (props: { export const SiteLink = (props: {
href: string href: string | undefined
children?: ReactNode children?: ReactNode
onClick?: () => void onClick?: () => void
className?: string className?: string
}) => { }) => {
const { href, children, onClick, className } = props const { href, children, onClick, className } = props
if (!href) return <>{children}</>
return ( return (
<MaybeLink href={href}> <MaybeLink href={href}>
<a <a

View File

@ -6,8 +6,9 @@ import {
updateDoc, updateDoc,
where, where,
} from 'firebase/firestore' } from 'firebase/firestore'
import { Post } from 'common/post' import { DateDoc, Post } from 'common/post'
import { coll, getValue, listenForValue } from './utils' import { coll, getValue, getValues, listenForValue } from './utils'
import { getUserByUsername } from './users'
export const posts = coll<Post>('posts') export const posts = coll<Post>('posts')
@ -44,3 +45,22 @@ export async function listPosts(postIds?: string[]) {
if (postIds === undefined) return [] if (postIds === undefined) return []
return Promise.all(postIds.map(getPost)) return Promise.all(postIds.map(getPost))
} }
export async function getDateDocs() {
const q = query(posts, where('type', '==', 'date-doc'))
return getValues<DateDoc>(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<DateDoc>(q)
const post = docs.length === 0 ? null : docs[0]
return { post, user }
}

View File

@ -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 <Custom404 />
return <DateDocPage creator={creator} post={post} />
}
function DateDocPage(props: { creator: User; post: DateDoc }) {
const { creator, post } = props
const tips = useTipTxns({ postId: post.id })
const comments = useCommentsOnPost(post.id) ?? []
return (
<Page>
<Col className="mx-auto w-full max-w-xl gap-6 sm:mb-6">
<SiteLink href="/date-docs">
<Row className="items-center gap-2">
<ArrowLeftIcon className="h-5 w-5" aria-hidden="true" />
<div>Date docs</div>
</Row>
</SiteLink>
<DateDocPost dateDoc={post} creator={creator} />
<Col className="gap-4 rounded-lg bg-white px-6 py-4">
<div className="">Add your endorsement of {creator.name}!</div>
<PostCommentsActivity post={post} comments={comments} tips={tips} />
</Col>
</Col>
</Page>
)
}
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 (
<Col className="gap-6 rounded-lg bg-white px-6 py-6">
<SiteLink href={link ? `/date-docs/${creator.username}` : undefined}>
<Col className="gap-6 self-center">
<Row className="relative items-center justify-between gap-4 text-2xl">
<div>
{name}, {age}
</div>
<Col className="absolute right-0 px-2">
<Button
size="lg"
color="gray-white"
className={'flex'}
onClick={(e) => {
e.preventDefault()
copyToClipboard(shareUrl)
toast.success('Link copied!', {
icon: (
<LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
),
})
track('copy share post link')
}}
>
<ShareIcon
className={clsx('mr-2 h-[24px] w-5')}
aria-hidden="true"
/>
<div
className="!hover:no-underline !decoration-0"
style={{ textDecoration: 'none' }}
>
Share
</div>
</Button>
</Col>
</Row>
<img
className="w-full max-w-lg rounded-lg object-cover"
src={photoUrl}
alt={name}
/>
</Col>
</SiteLink>
{user && user.id === creator.id ? (
<RichEditPost post={post} />
) : (
<Content content={content} />
)}
<div className="mt-4 w-full max-w-lg self-center rounded-xl bg-gradient-to-r from-blue-200 via-purple-200 to-indigo-300 p-3">
<iframe
height="405"
src={marketUrl}
title=""
frameBorder="0"
className="w-full rounded-xl bg-white p-10"
></iframe>
</div>
</Col>
)
}

View File

@ -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 | string>(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 (
<Page>
<div className="mx-auto w-full max-w-3xl">
<div className="rounded-lg px-6 py-4 pb-4 sm:py-0">
<Row className="mb-8 items-center justify-between">
<Title className="!my-0 text-blue-500" text="Your Date Doc" />
<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>
)
}

View File

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

View File

@ -34,9 +34,9 @@ export async function getStaticProps(props: { params: { slugs: string[] } }) {
return { return {
props: { props: {
post: post, post,
creator: creator, creator,
comments: comments, comments,
}, },
revalidate: 60, // regenerate after a minute revalidate: 60, // regenerate after a minute
@ -117,12 +117,7 @@ export default function PostPage(props: {
<Spacer h={4} /> <Spacer h={4} />
<div className="rounded-lg bg-white px-6 py-4 sm:py-0"> <div className="rounded-lg bg-white px-6 py-4 sm:py-0">
<PostCommentsActivity <PostCommentsActivity post={post} comments={comments} tips={tips} />
post={post}
comments={comments}
tips={tips}
user={creator}
/>
</div> </div>
</div> </div>
</Page> </Page>
@ -133,9 +128,8 @@ export function PostCommentsActivity(props: {
post: Post post: Post
comments: PostComment[] comments: PostComment[]
tips: CommentTipMap 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 commentsByUserId = groupBy(comments, (c) => c.userId)
const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_') const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_')
const topLevelComments = sortBy( const topLevelComments = sortBy(
@ -149,7 +143,6 @@ export function PostCommentsActivity(props: {
{topLevelComments.map((parent) => ( {topLevelComments.map((parent) => (
<PostCommentThread <PostCommentThread
key={parent.id} key={parent.id}
user={user}
post={post} post={post}
parentComment={parent} parentComment={parent}
threadComments={sortBy( 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 { post } = props
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)

View File

@ -3,7 +3,6 @@ import { Editor } from '@tiptap/core'
import clsx from 'clsx' import clsx from 'clsx'
import { PostComment } from 'common/comment' import { PostComment } from 'common/comment'
import { Post } from 'common/post' import { Post } from 'common/post'
import { User } from 'common/user'
import { Dictionary } from 'lodash' import { Dictionary } from 'lodash'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@ -21,7 +20,6 @@ import { createCommentOnPost } from 'web/lib/firebase/comments'
import { firebaseLogin } from 'web/lib/firebase/users' import { firebaseLogin } from 'web/lib/firebase/users'
export function PostCommentThread(props: { export function PostCommentThread(props: {
user: User | null | undefined
post: Post post: Post
threadComments: PostComment[] threadComments: PostComment[]
tips: CommentTipMap tips: CommentTipMap