Add a "Posts" tab to groups (#926)

* Add a "Posts" sidebar item to groups

* Fix James's nits

* Show "Add Post" button only to users
This commit is contained in:
FRC 2022-09-23 15:11:50 -04:00 committed by GitHub
parent ebcecd4fe9
commit 5a10132e2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 272 additions and 111 deletions

View File

@ -10,6 +10,7 @@ export type Group = {
totalContracts: number totalContracts: number
totalMembers: number totalMembers: number
aboutPostId?: string aboutPostId?: string
postIds: string[]
chatDisabled?: boolean chatDisabled?: boolean
mostRecentContractAddedTime?: number mostRecentContractAddedTime?: number
cachedLeaderboard?: { cachedLeaderboard?: {

View File

@ -61,6 +61,7 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
anyoneCanJoin, anyoneCanJoin,
totalContracts: 0, totalContracts: 0,
totalMembers: memberIds.length, totalMembers: memberIds.length,
postIds: [],
} }
await groupRef.create(group) await groupRef.create(group)

View File

@ -34,11 +34,12 @@ const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
const postSchema = z.object({ 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(),
}) })
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 } = validate(postSchema, req.body) const { title, content, groupId } = validate(postSchema, req.body)
const creator = await getUser(auth.uid) const creator = await getUser(auth.uid)
if (!creator) if (!creator)
@ -60,6 +61,18 @@ export const createpost = newEndpoint({}, async (req, auth) => {
} }
await postRef.create(post) await postRef.create(post)
if (groupId) {
const groupRef = firestore.collection('groups').doc(groupId)
const group = await groupRef.get()
if (group.exists) {
const groupData = group.data()
if (groupData) {
const postIds = groupData.postIds ?? []
postIds.push(postRef.id)
await groupRef.update({ postIds })
}
}
}
return { status: 'success', post } return { status: 'success', post }
}) })

View File

@ -41,6 +41,7 @@ const createGroup = async (
anyoneCanJoin: true, anyoneCanJoin: true,
totalContracts: contracts.length, totalContracts: contracts.length,
totalMembers: 1, totalMembers: 1,
postIds: [],
} }
await groupRef.create(group) await groupRef.create(group)
// create a GroupMemberDoc for the creator // create a GroupMemberDoc for the creator

View File

@ -0,0 +1,94 @@
import { useState } from 'react'
import { Spacer } from 'web/components/layout/spacer'
import { Title } from 'web/components/title'
import Textarea from 'react-expanding-textarea'
import { TextEditor, useTextEditor } from 'web/components/editor'
import { createPost } from 'web/lib/firebase/api'
import clsx from 'clsx'
import Router from 'next/router'
import { MAX_POST_TITLE_LENGTH } from 'common/post'
import { postPath } from 'web/lib/firebase/posts'
import { Group } from 'common/group'
export function CreatePost(props: { group?: Group }) {
const [title, setTitle] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const { group } = props
const { editor, upload } = useTextEditor({
disabled: isSubmitting,
})
const isValid = editor && title.length > 0 && editor.isEmpty === false
async function savePost(title: string) {
if (!editor) return
const newPost = {
title: title,
content: editor.getJSON(),
groupId: group?.id,
}
const result = await createPost(newPost).catch((e) => {
console.log(e)
setError('There was an error creating the post, please try again')
return e
})
if (result.post) {
await Router.push(postPath(result.post.slug))
}
}
return (
<div className="mx-auto w-full max-w-3xl">
<div className="rounded-lg px-6 py-4 sm:py-0">
<Title className="!mt-0" text="Create a post" />
<form>
<div className="form-control w-full">
<label className="label">
<span className="mb-1">
Title<span className={'text-red-700'}> *</span>
</span>
</label>
<Textarea
placeholder="e.g. Elon Mania Post"
className="input input-bordered resize-none"
autoFocus
maxLength={MAX_POST_TITLE_LENGTH}
value={title}
onChange={(e) => setTitle(e.target.value || '')}
/>
<Spacer h={6} />
<label className="label">
<span className="mb-1">
Content<span className={'text-red-700'}> *</span>
</span>
</label>
<TextEditor editor={editor} upload={upload} />
<Spacer h={6} />
<button
type="submit"
className={clsx(
'btn btn-primary normal-case',
isSubmitting && 'loading disabled'
)}
disabled={isSubmitting || !isValid || upload.isLoading}
onClick={async () => {
setIsSubmitting(true)
await savePost(title)
setIsSubmitting(false)
}}
>
{isSubmitting ? 'Creating...' : 'Create a post'}
</button>
{error !== '' && <div className="text-red-700">{error}</div>}
</div>
</form>
</div>
</div>
)
}

View File

@ -16,29 +16,26 @@ import { usePost } from 'web/hooks/use-post'
export function GroupAboutPost(props: { export function GroupAboutPost(props: {
group: Group group: Group
isEditable: boolean isEditable: boolean
post: Post post: Post | null
}) { }) {
const { group, isEditable } = props const { group, isEditable } = props
const post = usePost(group.aboutPostId) ?? props.post const post = usePost(group.aboutPostId) ?? props.post
return ( return (
<div className="rounded-md bg-white p-4 "> <div className="rounded-md bg-white p-4 ">
{isEditable ? ( {isEditable && <RichEditGroupAboutPost group={group} post={post} />}
<RichEditGroupAboutPost group={group} post={post} /> {!isEditable && post && <Content content={post.content} />}
) : (
<Content content={post.content} />
)}
</div> </div>
) )
} }
function RichEditGroupAboutPost(props: { group: Group; post: Post }) { function RichEditGroupAboutPost(props: { group: Group; post: Post | null }) {
const { group, post } = props const { group, post } = props
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const { editor, upload } = useTextEditor({ const { editor, upload } = useTextEditor({
defaultValue: post.content, defaultValue: post?.content,
disabled: isSubmitting, disabled: isSubmitting,
}) })
@ -49,7 +46,7 @@ function RichEditGroupAboutPost(props: { group: Group; post: Post }) {
content: editor.getJSON(), content: editor.getJSON(),
} }
if (group.aboutPostId == null) { if (post == null) {
const result = await createPost(newPost).catch((e) => { const result = await createPost(newPost).catch((e) => {
console.error(e) console.error(e)
return e return e
@ -65,6 +62,7 @@ function RichEditGroupAboutPost(props: { group: Group; post: Post }) {
} }
async function deleteGroupAboutPost() { async function deleteGroupAboutPost() {
if (post == null) return
await deletePost(post) await deletePost(post)
await deleteFieldFromGroup(group, 'aboutPostId') await deleteFieldFromGroup(group, 'aboutPostId')
} }
@ -91,7 +89,7 @@ function RichEditGroupAboutPost(props: { group: Group; post: Post }) {
</> </>
) : ( ) : (
<> <>
{group.aboutPostId == null ? ( {post == null ? (
<div className="text-center text-gray-500"> <div className="text-center text-gray-500">
<p className="text-sm"> <p className="text-sm">
No post has been added yet. No post has been added yet.

View File

@ -11,3 +11,29 @@ export const usePost = (postId: string | undefined) => {
return post return post
} }
export const usePosts = (postIds: string[]) => {
const [posts, setPosts] = useState<Post[]>([])
useEffect(() => {
if (postIds.length === 0) return
setPosts([])
const unsubscribes = postIds.map((postId) =>
listenForPost(postId, (post) => {
if (post) {
setPosts((posts) => [...posts, post])
}
})
)
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe())
}
}, [postIds])
return posts
.filter(
(post, index, self) => index === self.findIndex((t) => t.id === post.id)
)
.sort((a, b) => b.createdTime - a.createdTime)
}

View File

@ -90,6 +90,10 @@ export function getCurrentUser(params: any) {
return call(getFunctionUrl('getcurrentuser'), 'GET', params) return call(getFunctionUrl('getcurrentuser'), 'GET', params)
} }
export function createPost(params: { title: string; content: JSONContent }) { export function createPost(params: {
title: string
content: JSONContent
groupId?: string
}) {
return call(getFunctionUrl('createpost'), 'POST', params) return call(getFunctionUrl('createpost'), 'POST', params)
} }

View File

@ -43,6 +43,7 @@ export function groupPath(
| 'about' | 'about'
| typeof GROUP_CHAT_SLUG | typeof GROUP_CHAT_SLUG
| 'leaderboards' | 'leaderboards'
| 'posts'
) { ) {
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}` return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
} }

View File

@ -39,3 +39,8 @@ export function listenForPost(
) { ) {
return listenForValue(doc(posts, postId), setPost) return listenForValue(doc(posts, postId), setPost)
} }
export async function listPosts(postIds?: string[]) {
if (postIds === undefined) return []
return Promise.all(postIds.map(getPost))
}

View File

@ -1,93 +0,0 @@
import { useState } from 'react'
import { Spacer } from 'web/components/layout/spacer'
import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
import Textarea from 'react-expanding-textarea'
import { TextEditor, useTextEditor } from 'web/components/editor'
import { createPost } from 'web/lib/firebase/api'
import clsx from 'clsx'
import Router from 'next/router'
import { MAX_POST_TITLE_LENGTH } from 'common/post'
import { postPath } from 'web/lib/firebase/posts'
export default function CreatePost() {
const [title, setTitle] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const { editor, upload } = useTextEditor({
disabled: isSubmitting,
})
const isValid = editor && title.length > 0 && editor.isEmpty === false
async function savePost(title: string) {
if (!editor) return
const newPost = {
title: title,
content: editor.getJSON(),
}
const result = await createPost(newPost).catch((e) => {
console.log(e)
setError('There was an error creating the post, please try again')
return e
})
if (result.post) {
await Router.push(postPath(result.post.slug))
}
}
return (
<Page>
<div className="mx-auto w-full max-w-3xl">
<div className="rounded-lg px-6 py-4 sm:py-0">
<Title className="!mt-0" text="Create a post" />
<form>
<div className="form-control w-full">
<label className="label">
<span className="mb-1">
Title<span className={'text-red-700'}> *</span>
</span>
</label>
<Textarea
placeholder="e.g. Elon Mania Post"
className="input input-bordered resize-none"
autoFocus
maxLength={MAX_POST_TITLE_LENGTH}
value={title}
onChange={(e) => setTitle(e.target.value || '')}
/>
<Spacer h={6} />
<label className="label">
<span className="mb-1">
Content<span className={'text-red-700'}> *</span>
</span>
</label>
<TextEditor editor={editor} upload={upload} />
<Spacer h={6} />
<button
type="submit"
className={clsx(
'btn btn-primary normal-case',
isSubmitting && 'loading disabled'
)}
disabled={isSubmitting || !isValid || upload.isLoading}
onClick={async () => {
setIsSubmitting(true)
await savePost(title)
setIsSubmitting(false)
}}
>
{isSubmitting ? 'Creating...' : 'Create a post'}
</button>
{error !== '' && <div className="text-red-700">{error}</div>}
</div>
</form>
</div>
</div>
</Page>
)
}

View File

@ -16,7 +16,7 @@ import {
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user' import { useUser, useUserById } from 'web/hooks/use-user'
import { import {
useGroup, useGroup,
useGroupContractIds, useGroupContractIds,
@ -42,10 +42,10 @@ import { GroupComment } from 'common/comment'
import { REFERRAL_AMOUNT } from 'common/economy' import { REFERRAL_AMOUNT } from 'common/economy'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { GroupAboutPost } from 'web/components/groups/group-about-post' import { GroupAboutPost } from 'web/components/groups/group-about-post'
import { getPost } from 'web/lib/firebase/posts' import { getPost, listPosts, postPath } from 'web/lib/firebase/posts'
import { Post } from 'common/post' import { Post } from 'common/post'
import { Spacer } from 'web/components/layout/spacer' import { Spacer } from 'web/components/layout/spacer'
import { usePost } from 'web/hooks/use-post' import { usePost, usePosts } from 'web/hooks/use-post'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { ArrowLeftIcon } from '@heroicons/react/solid' import { ArrowLeftIcon } from '@heroicons/react/solid'
@ -53,6 +53,10 @@ import { SelectMarketsModal } from 'web/components/contract-select-modal'
import { BETTORS } from 'common/user' import { BETTORS } from 'common/user'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { Tabs } from 'web/components/layout/tabs' import { Tabs } from 'web/components/layout/tabs'
import { Avatar } from 'web/components/avatar'
import { Title } from 'web/components/title'
import { fromNow } from 'web/lib/util/time'
import { CreatePost } from 'web/components/create-post'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) { export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -70,7 +74,8 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
? 'all' ? 'all'
: 'open' : 'open'
const aboutPost = const aboutPost =
group && group.aboutPostId != null && (await getPost(group.aboutPostId)) group && group.aboutPostId != null ? await getPost(group.aboutPostId) : null
const messages = group && (await listAllCommentsOnGroup(group.id)) const messages = group && (await listAllCommentsOnGroup(group.id))
const cachedTopTraderIds = const cachedTopTraderIds =
@ -83,6 +88,9 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
const creator = await creatorPromise const creator = await creatorPromise
const posts = ((group && (await listPosts(group.postIds))) ?? []).filter(
(p) => p != null
) as Post[]
return { return {
props: { props: {
group, group,
@ -93,6 +101,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
messages, messages,
aboutPost, aboutPost,
suggestedFilter, suggestedFilter,
posts,
}, },
revalidate: 60, // regenerate after a minute revalidate: 60, // regenerate after a minute
@ -107,6 +116,7 @@ const groupSubpages = [
'markets', 'markets',
'leaderboards', 'leaderboards',
'about', 'about',
'posts',
] as const ] as const
export default function GroupPage(props: { export default function GroupPage(props: {
@ -116,8 +126,9 @@ export default function GroupPage(props: {
topTraders: { user: User; score: number }[] topTraders: { user: User; score: number }[]
topCreators: { user: User; score: number }[] topCreators: { user: User; score: number }[]
messages: GroupComment[] messages: GroupComment[]
aboutPost: Post aboutPost: Post | null
suggestedFilter: 'open' | 'all' suggestedFilter: 'open' | 'all'
posts: Post[]
}) { }) {
props = usePropz(props, getStaticPropz) ?? { props = usePropz(props, getStaticPropz) ?? {
group: null, group: null,
@ -127,8 +138,9 @@ export default function GroupPage(props: {
topCreators: [], topCreators: [],
messages: [], messages: [],
suggestedFilter: 'open', suggestedFilter: 'open',
posts: [],
} }
const { creator, topTraders, topCreators, suggestedFilter } = props const { creator, topTraders, topCreators, suggestedFilter, posts } = props
const router = useRouter() const router = useRouter()
const { slugs } = router.query as { slugs: string[] } const { slugs } = router.query as { slugs: string[] }
@ -137,6 +149,12 @@ export default function GroupPage(props: {
const group = useGroup(props.group?.id) ?? props.group const group = useGroup(props.group?.id) ?? props.group
const aboutPost = usePost(props.aboutPost?.id) ?? props.aboutPost const aboutPost = usePost(props.aboutPost?.id) ?? props.aboutPost
let groupPosts = usePosts(group?.postIds ?? []) ?? posts
if (aboutPost != null) {
groupPosts = [aboutPost, ...groupPosts]
}
const user = useUser() const user = useUser()
const isAdmin = useAdmin() const isAdmin = useAdmin()
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
@ -172,6 +190,16 @@ export default function GroupPage(props: {
</Col> </Col>
) )
const postsPage = (
<>
<Col>
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
{posts && <GroupPosts posts={groupPosts} group={group} />}
</div>
</Col>
</>
)
const aboutTab = ( const aboutTab = (
<Col> <Col>
{(group.aboutPostId != null || isCreator || isAdmin) && ( {(group.aboutPostId != null || isCreator || isAdmin) && (
@ -234,6 +262,10 @@ export default function GroupPage(props: {
title: 'About', title: 'About',
content: aboutTab, content: aboutTab,
}, },
{
title: 'Posts',
content: postsPage,
},
] ]
return ( return (
@ -413,6 +445,84 @@ function GroupLeaderboard(props: {
) )
} }
function GroupPosts(props: { posts: Post[]; group: Group }) {
const { posts, group } = props
const [showCreatePost, setShowCreatePost] = useState(false)
const user = useUser()
const createPost = <CreatePost group={group} />
const postList = (
<div className=" align-start w-full items-start">
<Row className="flex justify-between">
<Col>
<Title text={'Posts'} className="!mt-0" />
</Col>
<Col>
{user && (
<Button
className="btn-md"
onClick={() => setShowCreatePost(!showCreatePost)}
>
Add a Post
</Button>
)}
</Col>
</Row>
<div className="mt-2">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
{posts.length === 0 && (
<div className="text-center text-gray-500">No posts yet</div>
)}
</div>
</div>
)
return showCreatePost ? createPost : postList
}
function PostCard(props: { post: Post }) {
const { post } = props
const creatorId = post.creatorId
const user = useUserById(creatorId)
if (!user) return <> </>
return (
<div className="py-1">
<Link href={postPath(post.slug)}>
<Row
className={
'relative gap-3 rounded-lg bg-white p-2 shadow-md hover:cursor-pointer hover:bg-gray-100'
}
>
<div className="flex-shrink-0">
<Avatar className="h-12 w-12" username={user?.username} />
</div>
<div className="">
<div className="text-sm text-gray-500">
<UserLink
className="text-neutral"
name={user?.name}
username={user?.username}
/>
<span className="mx-1"></span>
<span className="text-gray-500">{fromNow(post.createdTime)}</span>
</div>
<div className="text-lg font-medium text-gray-900">
{post.title}
</div>
</div>
</Row>
</Link>
</div>
)
}
function AddContractButton(props: { group: Group; user: User }) { function AddContractButton(props: { group: Group; user: User }) {
const { group, user } = props const { group, user } = props
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)