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:
parent
419c7ab636
commit
b21daa1248
|
@ -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
|
||||||
|
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
159
web/pages/date-docs/[username].tsx
Normal file
159
web/pages/date-docs/[username].tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
179
web/pages/date-docs/create.tsx
Normal file
179
web/pages/date-docs/create.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
72
web/pages/date-docs/index.tsx
Normal file
72
web/pages/date-docs/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue
Block a user