Overview page on groups (#961)

* Frontpage on groups

wip

* Fix James's nits
This commit is contained in:
FRC 2022-10-03 00:02:31 +01:00 committed by GitHub
parent 40c51c3d59
commit 1f8c72b4c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 668 additions and 178 deletions

View File

@ -23,6 +23,7 @@ export type Group = {
score: number score: number
}[] }[]
} }
pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
} }
export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_GROUP_NAME_LENGTH = 75

View File

@ -176,7 +176,7 @@ service cloud.firestore {
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data) && request.resource.data.diff(resource.data)
.affectedKeys() .affectedKeys()
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]); .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId', 'pinnedItems' ]);
allow delete: if request.auth.uid == resource.data.creatorId; allow delete: if request.auth.uid == resource.data.creatorId;
match /groupContracts/{contractId} { match /groupContracts/{contractId} {

View File

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

View File

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

View File

@ -3,10 +3,7 @@ import { SearchOptions } from '@algolia/client-search'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { PAST_BETS, User } from 'common/user' import { PAST_BETS, User } from 'common/user'
import { import { CardHighlightOptions, ContractsGrid } from './contract/contracts-grid'
ContractHighlightOptions,
ContractsGrid,
} from './contract/contracts-grid'
import { ShowTime } from './contract/contract-details' import { ShowTime } from './contract/contract-details'
import { Row } from './layout/row' import { Row } from './layout/row'
import { import {
@ -82,7 +79,7 @@ export function ContractSearch(props: {
defaultFilter?: filter defaultFilter?: filter
defaultPill?: string defaultPill?: string
additionalFilter?: AdditionalFilter additionalFilter?: AdditionalFilter
highlightOptions?: ContractHighlightOptions highlightOptions?: CardHighlightOptions
onContractClick?: (contract: Contract) => void onContractClick?: (contract: Contract) => void
hideOrderSelector?: boolean hideOrderSelector?: boolean
cardUIOptions?: { cardUIOptions?: {

View File

@ -91,7 +91,7 @@ export function SelectMarketsModal(props: {
noLinkAvatar: true, noLinkAvatar: true,
}} }}
highlightOptions={{ highlightOptions={{
contractIds: contracts.map((c) => c.id), itemIds: contracts.map((c) => c.id),
highlightClassName: highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300', '!bg-indigo-100 outline outline-2 outline-indigo-300',
}} }}

View File

@ -12,8 +12,8 @@ import { VisibilityObserver } from '../visibility-observer'
import Masonry from 'react-masonry-css' import Masonry from 'react-masonry-css'
import { CPMMBinaryContract } from 'common/contract' import { CPMMBinaryContract } from 'common/contract'
export type ContractHighlightOptions = { export type CardHighlightOptions = {
contractIds?: string[] itemIds?: string[]
highlightClassName?: string highlightClassName?: string
} }
@ -28,7 +28,7 @@ export function ContractsGrid(props: {
noLinkAvatar?: boolean noLinkAvatar?: boolean
showProbChange?: boolean showProbChange?: boolean
} }
highlightOptions?: ContractHighlightOptions highlightOptions?: CardHighlightOptions
trackingPostfix?: string trackingPostfix?: string
breakpointColumns?: { [key: string]: number } breakpointColumns?: { [key: string]: number }
}) { }) {
@ -43,7 +43,7 @@ export function ContractsGrid(props: {
} = props } = props
const { hideQuickBet, hideGroupLink, noLinkAvatar, showProbChange } = const { hideQuickBet, hideGroupLink, noLinkAvatar, showProbChange } =
cardUIOptions || {} cardUIOptions || {}
const { contractIds, highlightClassName } = highlightOptions || {} const { itemIds: contractIds, highlightClassName } = highlightOptions || {}
const onVisibilityUpdated = useCallback( const onVisibilityUpdated = useCallback(
(visible) => { (visible) => {
if (visible && loadMore) { if (visible && loadMore) {

View File

@ -13,7 +13,7 @@ import { deletePost, updatePost } from 'web/lib/firebase/posts'
import { useState } from 'react' import { useState } from 'react'
import { usePost } from 'web/hooks/use-post' import { usePost } from 'web/hooks/use-post'
export function GroupAboutPost(props: { export function GroupOverviewPost(props: {
group: Group group: Group
isEditable: boolean isEditable: boolean
post: Post | null post: Post | null

View File

@ -0,0 +1,378 @@
import { track } from '@amplitude/analytics-browser'
import {
ArrowSmRightIcon,
PlusCircleIcon,
XCircleIcon,
} from '@heroicons/react/outline'
import PencilIcon from '@heroicons/react/solid/PencilIcon'
import { Contract } from 'common/contract'
import { Group } from 'common/group'
import { Post } from 'common/post'
import { useEffect, useState } from 'react'
import { ReactNode } from 'react'
import { getPost } from 'web/lib/firebase/posts'
import { ContractSearch } from '../contract-search'
import { ContractCard } from '../contract/contract-card'
import Masonry from 'react-masonry-css'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { SiteLink } from '../site-link'
import { GroupOverviewPost } from './group-overview-post'
import { getContractFromId } from 'web/lib/firebase/contracts'
import { groupPath, updateGroup } from 'web/lib/firebase/groups'
import { PinnedSelectModal } from '../pinned-select-modal'
import { Button } from '../button'
import { User } from 'common/user'
import { UserLink } from '../user-link'
import { EditGroupButton } from './edit-group-button'
import { JoinOrLeaveGroupButton } from './groups-button'
import { Linkify } from '../linkify'
import { ChoicesToggleGroup } from '../choices-toggle-group'
import { CopyLinkButton } from '../copy-link-button'
import { REFERRAL_AMOUNT } from 'common/economy'
import toast from 'react-hot-toast'
import { ENV_CONFIG } from 'common/envs/constants'
import { PostCard } from '../post-card'
const MAX_TRENDING_POSTS = 6
export function GroupOverview(props: {
group: Group
isEditable: boolean
posts: Post[]
aboutPost: Post | null
creator: User
user: User | null | undefined
memberIds: string[]
}) {
const { group, isEditable, posts, aboutPost, creator, user, memberIds } =
props
return (
<Col className="pm:mx-10 gap-4 px-4 pb-12 pt-4 sm:pt-0">
<GroupOverviewPinned
group={group}
posts={posts}
isEditable={isEditable}
/>
{(group.aboutPostId != null || isEditable) && (
<>
<SectionHeader label={'About'} href={'/post/' + group.slug} />
<GroupOverviewPost
group={group}
isEditable={isEditable}
post={aboutPost}
/>
</>
)}
<SectionHeader label={'Trending'} />
<ContractSearch
user={user}
defaultSort={'score'}
noControls
maxResults={MAX_TRENDING_POSTS}
defaultFilter={'all'}
additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-trending-${group.slug}`}
/>
<GroupAbout
group={group}
creator={creator}
isEditable={isEditable}
user={user}
memberIds={memberIds}
/>
</Col>
)
}
function GroupOverviewPinned(props: {
group: Group
posts: Post[]
isEditable: boolean
}) {
const { group, posts, isEditable } = props
const [pinned, setPinned] = useState<JSX.Element[]>([])
const [open, setOpen] = useState(false)
const [editMode, setEditMode] = useState(false)
useEffect(() => {
async function getPinned() {
if (group.pinnedItems == null) {
updateGroup(group, { pinnedItems: [] })
} else {
const itemComponents = await Promise.all(
group.pinnedItems.map(async (element) => {
if (element.type === 'post') {
const post = await getPost(element.itemId)
if (post) {
return <PostCard post={post as Post} />
}
} else if (element.type === 'contract') {
const contract = await getContractFromId(element.itemId)
if (contract) {
return <ContractCard contract={contract as Contract} />
}
}
})
)
setPinned(
itemComponents.filter(
(element) => element != undefined
) as JSX.Element[]
)
}
}
getPinned()
}, [group, group.pinnedItems])
async function onSubmit(selectedItems: { itemId: string; type: string }[]) {
await updateGroup(group, {
pinnedItems: [
...group.pinnedItems,
...(selectedItems as { itemId: string; type: 'contract' | 'post' }[]),
],
})
setOpen(false)
}
return isEditable || pinned.length > 0 ? (
<>
<Row className="mb-3 items-center justify-between">
<SectionHeader label={'Pinned'} />
{isEditable && (
<Button
color="gray"
size="xs"
onClick={() => {
setEditMode(!editMode)
}}
>
{editMode ? (
'Done'
) : (
<>
<PencilIcon className="inline h-4 w-4" />
Edit
</>
)}
</Button>
)}
</Row>
<div>
<Masonry
breakpointCols={{ default: 2, 768: 1 }}
className="-ml-4 flex w-auto"
columnClassName="pl-4 bg-clip-padding"
>
{pinned.length == 0 && !editMode && (
<div className="flex flex-col items-center justify-center">
<p className="text-center text-gray-400">
No pinned items yet. Click the edit button to add some!
</p>
</div>
)}
{pinned.map((element, index) => (
<div className="relative my-2">
{element}
{editMode && (
<CrossIcon
onClick={() => {
const newPinned = group.pinnedItems.filter((item) => {
return item.itemId !== group.pinnedItems[index].itemId
})
updateGroup(group, { pinnedItems: newPinned })
}}
/>
)}
</div>
))}
{editMode && group.pinnedItems && pinned.length < 6 && (
<div className=" py-2">
<Row
className={
'relative gap-3 rounded-lg border-4 border-dotted p-2 hover:cursor-pointer hover:bg-gray-100'
}
>
<button
className="flex w-full justify-center"
onClick={() => setOpen(true)}
>
<PlusCircleIcon
className="h-12 w-12 text-gray-600"
aria-hidden="true"
/>
</button>
</Row>
</div>
)}
</Masonry>
</div>
<PinnedSelectModal
open={open}
group={group}
posts={posts}
setOpen={setOpen}
title="Pin a post or market"
description={
<div className={'text-md my-4 text-gray-600'}>
Pin posts or markets to the overview of this group.
</div>
}
onSubmit={onSubmit}
/>
</>
) : (
<></>
)
}
function SectionHeader(props: {
label: string
href?: string
children?: ReactNode
}) {
const { label, href, children } = props
const content = (
<>
{label}{' '}
<ArrowSmRightIcon
className="mb-0.5 inline h-6 w-6 text-gray-500"
aria-hidden="true"
/>
</>
)
return (
<Row className="mb-3 items-center justify-between">
{href ? (
<SiteLink
className="text-xl"
href={href}
onClick={() => track('group click section header', { section: href })}
>
{content}
</SiteLink>
) : (
<span className="text-xl">{content}</span>
)}
{children}
</Row>
)
}
export function GroupAbout(props: {
group: Group
creator: User
user: User | null | undefined
isEditable: boolean
memberIds: string[]
}) {
const { group, creator, isEditable, user, memberIds } = props
const anyoneCanJoinChoices: { [key: string]: string } = {
Closed: 'false',
Open: 'true',
}
const [anyoneCanJoin, setAnyoneCanJoin] = useState(group.anyoneCanJoin)
function updateAnyoneCanJoin(newVal: boolean) {
if (group.anyoneCanJoin == newVal || !isEditable) return
setAnyoneCanJoin(newVal)
toast.promise(updateGroup(group, { ...group, anyoneCanJoin: newVal }), {
loading: 'Updating group...',
success: 'Updated group!',
error: "Couldn't update group",
})
}
const postFix = user ? '?referrer=' + user.username : ''
const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
group.slug
)}${postFix}`
const isMember = user ? memberIds.includes(user.id) : false
return (
<>
<Col className="gap-2 rounded-b bg-white p-2">
<Row className={'flex-wrap justify-between'}>
<div className={'inline-flex items-center'}>
<div className="mr-1 text-gray-500">Created by</div>
<UserLink
className="text-neutral"
name={creator.name}
username={creator.username}
/>
</div>
{isEditable ? (
<EditGroupButton className={'ml-1'} group={group} />
) : (
user && (
<Row>
<JoinOrLeaveGroupButton
group={group}
user={user}
isMember={isMember}
/>
</Row>
)
)}
</Row>
<div className={'block sm:hidden'}>
<Linkify text={group.about} />
</div>
<Row className={'items-center gap-1'}>
<span className={'text-gray-500'}>Membership</span>
{user && user.id === creator.id ? (
<ChoicesToggleGroup
currentChoice={anyoneCanJoin.toString()}
choicesMap={anyoneCanJoinChoices}
setChoice={(choice) =>
updateAnyoneCanJoin(choice.toString() === 'true')
}
toggleClassName={'h-10'}
className={'ml-2'}
/>
) : (
<span className={'text-gray-700'}>
{anyoneCanJoin ? 'Open to all' : 'Closed (by invite only)'}
</span>
)}
</Row>
{anyoneCanJoin && user && (
<Col className="my-4 px-2">
<div className="text-lg">Invite</div>
<div className={'mb-2 text-gray-500'}>
Invite a friend to this group and get M${REFERRAL_AMOUNT} if they
sign up!
</div>
<CopyLinkButton
url={shareUrl}
tracking="copy group share link"
buttonClassName="btn-md rounded-l-none"
toastClassName={'-left-28 mt-1'}
/>
</Col>
)}
</Col>
</>
)
}
function CrossIcon(props: { onClick: () => void }) {
const { onClick } = props
return (
<div>
<button className=" text-gray-500 hover:text-gray-700" onClick={onClick}>
<div className="absolute top-0 left-0 right-0 bottom-0 bg-gray-200 bg-opacity-50">
<XCircleIcon className="h-12 w-12 text-gray-600" />
</div>
</button>
</div>
)
}

View File

@ -0,0 +1,164 @@
import { Contract } from 'common/contract'
import { Group } from 'common/group'
import { Post } from 'common/post'
import { useState } from 'react'
import { PostCardList } from 'web/pages/group/[...slugs]'
import { Button } from './button'
import { PillButton } from './buttons/pill-button'
import { ContractSearch } from './contract-search'
import { Col } from './layout/col'
import { Modal } from './layout/modal'
import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator'
export function PinnedSelectModal(props: {
title: string
description?: React.ReactNode
open: boolean
setOpen: (open: boolean) => void
onSubmit: (
selectedItems: { itemId: string; type: string }[]
) => void | Promise<void>
contractSearchOptions?: Partial<Parameters<typeof ContractSearch>[0]>
group: Group
posts: Post[]
}) {
const {
title,
description,
open,
setOpen,
onSubmit,
contractSearchOptions,
posts,
group,
} = props
const [selectedItem, setSelectedItem] = useState<{
itemId: string
type: string
} | null>(null)
const [loading, setLoading] = useState(false)
const [selectedTab, setSelectedTab] = useState<'contracts' | 'posts'>('posts')
async function selectContract(contract: Contract) {
selectItem(contract.id, 'contract')
}
async function selectPost(post: Post) {
selectItem(post.id, 'post')
}
async function selectItem(itemId: string, type: string) {
setSelectedItem({ itemId: itemId, type: type })
}
async function onFinish() {
setLoading(true)
if (selectedItem) {
await onSubmit([
{
itemId: selectedItem.itemId,
type: selectedItem.type,
},
])
setLoading(false)
setOpen(false)
setSelectedItem(null)
}
}
return (
<Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}>
<Col className="h-[85vh] w-full gap-4 rounded-md bg-white">
<div className="p-8 pb-0">
<Row>
<div className={'text-xl text-indigo-700'}>{title}</div>
{!loading && (
<Row className="grow justify-end gap-4">
{selectedItem && (
<Button onClick={onFinish} color="indigo">
Add to Pinned
</Button>
)}
<Button
onClick={() => {
setSelectedItem(null)
setOpen(false)
}}
color="gray"
>
Cancel
</Button>
</Row>
)}
</Row>
{description}
</div>
{loading && (
<div className="w-full justify-center">
<LoadingIndicator />
</div>
)}
<div>
<Row className="justify-center gap-4">
<PillButton
onSelect={() => setSelectedTab('contracts')}
selected={selectedTab === 'contracts'}
>
Contracts
</PillButton>
<PillButton
onSelect={() => setSelectedTab('posts')}
selected={selectedTab === 'posts'}
>
Posts
</PillButton>
</Row>
</div>
{selectedTab === 'contracts' ? (
<div className="overflow-y-auto px-2 sm:px-8">
<ContractSearch
hideOrderSelector
onContractClick={selectContract}
cardUIOptions={{
hideGroupLink: true,
hideQuickBet: true,
noLinkAvatar: true,
}}
highlightOptions={{
itemIds: [selectedItem?.itemId ?? ''],
highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300',
}}
additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`}
headerClassName="bg-white sticky"
{...contractSearchOptions}
/>
</div>
) : (
<>
<div className="mt-2 px-2">
<PostCardList
posts={posts}
onPostClick={selectPost}
highlightOptions={{
itemIds: [selectedItem?.itemId ?? ''],
highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300',
}}
/>
{posts.length === 0 && (
<div className="text-center text-gray-500">No posts yet</div>
)}
</div>
</>
)}
</Col>
</Modal>
)
}

View File

@ -0,0 +1,82 @@
import { track } from '@amplitude/analytics-browser'
import clsx from 'clsx'
import { Post } from 'common/post'
import Link from 'next/link'
import { useUserById } from 'web/hooks/use-user'
import { postPath } from 'web/lib/firebase/posts'
import { fromNow } from 'web/lib/util/time'
import { Avatar } from './avatar'
import { CardHighlightOptions } from './contract/contracts-grid'
import { Row } from './layout/row'
import { UserLink } from './user-link'
export function PostCard(props: {
post: Post
onPostClick?: (post: Post) => void
highlightOptions?: CardHighlightOptions
}) {
const { post, onPostClick, highlightOptions } = props
const creatorId = post.creatorId
const user = useUserById(creatorId)
const { itemIds: itemIds, highlightClassName } = highlightOptions || {}
if (!user) return <> </>
return (
<div className="relative py-1">
<Row
className={clsx(
' relative gap-3 rounded-lg bg-white py-2 shadow-md hover:cursor-pointer hover:bg-gray-100',
itemIds?.includes(post.id) && highlightClassName
)}
>
<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>
{onPostClick ? (
<a
className="absolute top-0 left-0 right-0 bottom-0"
onClick={(e) => {
// Let the browser handle the link click (opens in new tab).
if (e.ctrlKey || e.metaKey) return
e.preventDefault()
track('select post card'),
{
slug: post.slug,
postId: post.id,
}
onPostClick(post)
}}
/>
) : (
<Link href={postPath(post.slug)}>
<a
onClick={() => {
track('select post card'),
{
slug: post.slug,
postId: post.id,
}
}}
className="absolute top-0 left-0 right-0 bottom-0"
/>
</Link>
)}
</div>
)
}

View File

@ -11,12 +11,11 @@ import {
groupPath, groupPath,
joinGroup, joinGroup,
listMemberIds, listMemberIds,
updateGroup,
} from 'web/lib/firebase/groups' } from 'web/lib/firebase/groups'
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, useUserById } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { import {
useGroup, useGroup,
useGroupContractIds, useGroupContractIds,
@ -24,27 +23,17 @@ import {
} from 'web/hooks/use-group' } from 'web/hooks/use-group'
import { Leaderboard } from 'web/components/leaderboard' import { Leaderboard } from 'web/components/leaderboard'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { EditGroupButton } from 'web/components/groups/edit-group-button'
import Custom404 from '../../404' import Custom404 from '../../404'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { Linkify } from 'web/components/linkify'
import { fromPropz, usePropz } from 'web/hooks/use-propz' import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { ContractSearch } from 'web/components/contract-search' import { ContractSearch } from 'web/components/contract-search'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
import { CopyLinkButton } from 'web/components/copy-link-button'
import { ENV_CONFIG } from 'common/envs/constants'
import { useSaveReferral } from 'web/hooks/use-save-referral' import { useSaveReferral } from 'web/hooks/use-save-referral'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments' import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
import { GroupComment } from 'common/comment' import { GroupComment } from 'common/comment'
import { REFERRAL_AMOUNT } from 'common/economy' import { getPost, listPosts } from 'web/lib/firebase/posts'
import { UserLink } from 'web/components/user-link'
import { GroupAboutPost } from 'web/components/groups/group-about-post'
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 { usePost, usePosts } 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'
@ -53,10 +42,11 @@ 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 { Title } from 'web/components/title'
import { fromNow } from 'web/lib/util/time'
import { CreatePost } from 'web/components/create-post' import { CreatePost } from 'web/components/create-post'
import { GroupOverview } from 'web/components/groups/group-overview'
import { CardHighlightOptions } from 'web/components/contract/contracts-grid'
import { PostCard } from 'web/components/post-card'
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[] } }) {
@ -203,24 +193,18 @@ export default function GroupPage(props: {
</> </>
) )
const aboutTab = ( const overviewPage = (
<Col> <>
{(group.aboutPostId != null || isCreator || isAdmin) && (
<GroupAboutPost
group={group}
isEditable={!!isCreator || isAdmin}
post={aboutPost}
/>
)}
<Spacer h={3} />
<GroupOverview <GroupOverview
group={group} group={group}
posts={groupPosts}
isEditable={!!isCreator || isAdmin}
aboutPost={aboutPost}
creator={creator} creator={creator}
isCreator={!!isCreator}
user={user} user={user}
memberIds={memberIds} memberIds={memberIds}
/> />
</Col> </>
) )
const questionsTab = ( const questionsTab = (
@ -261,14 +245,14 @@ export default function GroupPage(props: {
title: 'Leaderboards', title: 'Leaderboards',
content: leaderboardTab, content: leaderboardTab,
}, },
{
title: 'About',
content: aboutTab,
},
{ {
title: 'Posts', title: 'Posts',
content: postsPage, content: postsPage,
}, },
{
title: 'Overview',
content: overviewPage,
},
] ]
return ( return (
@ -326,103 +310,6 @@ function JoinOrAddQuestionsButtons(props: {
) : null ) : null
} }
function GroupOverview(props: {
group: Group
creator: User
user: User | null | undefined
isCreator: boolean
memberIds: string[]
}) {
const { group, creator, isCreator, user, memberIds } = props
const anyoneCanJoinChoices: { [key: string]: string } = {
Closed: 'false',
Open: 'true',
}
const [anyoneCanJoin, setAnyoneCanJoin] = useState(group.anyoneCanJoin)
function updateAnyoneCanJoin(newVal: boolean) {
if (group.anyoneCanJoin == newVal || !isCreator) return
setAnyoneCanJoin(newVal)
toast.promise(updateGroup(group, { ...group, anyoneCanJoin: newVal }), {
loading: 'Updating group...',
success: 'Updated group!',
error: "Couldn't update group",
})
}
const postFix = user ? '?referrer=' + user.username : ''
const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
group.slug
)}${postFix}`
const isMember = user ? memberIds.includes(user.id) : false
return (
<>
<Col className="gap-2 rounded-b bg-white p-2">
<Row className={'flex-wrap justify-between'}>
<div className={'inline-flex items-center'}>
<div className="mr-1 text-gray-500">Created by</div>
<UserLink
className="text-neutral"
name={creator.name}
username={creator.username}
/>
</div>
{isCreator ? (
<EditGroupButton className={'ml-1'} group={group} />
) : (
user && (
<Row>
<JoinOrLeaveGroupButton
group={group}
user={user}
isMember={isMember}
/>
</Row>
)
)}
</Row>
<div className={'block sm:hidden'}>
<Linkify text={group.about} />
</div>
<Row className={'items-center gap-1'}>
<span className={'text-gray-500'}>Membership</span>
{user && user.id === creator.id ? (
<ChoicesToggleGroup
currentChoice={anyoneCanJoin.toString()}
choicesMap={anyoneCanJoinChoices}
setChoice={(choice) =>
updateAnyoneCanJoin(choice.toString() === 'true')
}
toggleClassName={'h-10'}
className={'ml-2'}
/>
) : (
<span className={'text-gray-700'}>
{anyoneCanJoin ? 'Open to all' : 'Closed (by invite only)'}
</span>
)}
</Row>
{anyoneCanJoin && user && (
<Col className="my-4 px-2">
<div className="text-lg">Invite</div>
<div className={'mb-2 text-gray-500'}>
Invite a friend to this group and get M${REFERRAL_AMOUNT} if they
sign up!
</div>
<CopyLinkButton
url={shareUrl}
tracking="copy group share link"
buttonClassName="btn-md rounded-l-none"
toastClassName={'-left-28 mt-1'}
/>
</Col>
)}
</Col>
</>
)
}
function GroupLeaderboard(props: { function GroupLeaderboard(props: {
topUsers: { user: User; score: number }[] topUsers: { user: User; score: number }[]
title: string title: string
@ -449,7 +336,7 @@ function GroupLeaderboard(props: {
) )
} }
function GroupPosts(props: { posts: Post[]; group: Group }) { export function GroupPosts(props: { posts: Post[]; group: Group }) {
const { posts, group } = props const { posts, group } = props
const [showCreatePost, setShowCreatePost] = useState(false) const [showCreatePost, setShowCreatePost] = useState(false)
const user = useUser() const user = useUser()
@ -475,9 +362,7 @@ function GroupPosts(props: { posts: Post[]; group: Group }) {
</Row> </Row>
<div className="mt-2"> <div className="mt-2">
{posts.map((post) => ( <PostCardList posts={posts} />
<PostCard key={post.id} post={post} />
))}
{posts.length === 0 && ( {posts.length === 0 && (
<div className="text-center text-gray-500">No posts yet</div> <div className="text-center text-gray-500">No posts yet</div>
)} )}
@ -488,41 +373,22 @@ function GroupPosts(props: { posts: Post[]; group: Group }) {
return showCreatePost ? createPost : postList return showCreatePost ? createPost : postList
} }
function PostCard(props: { post: Post }) { export function PostCardList(props: {
const { post } = props posts: Post[]
const creatorId = post.creatorId highlightOptions?: CardHighlightOptions
onPostClick?: (post: Post) => void
const user = useUserById(creatorId) }) {
const { posts, onPostClick, highlightOptions } = props
if (!user) return <> </>
return ( return (
<div className="py-1"> <div className="w-full">
<Link href={postPath(post.slug)}> {posts.map((post) => (
<Row <PostCard
className={ key={post.id}
'relative gap-3 rounded-lg bg-white p-2 shadow-md hover:cursor-pointer hover:bg-gray-100' post={post}
} onPostClick={onPostClick}
> highlightOptions={highlightOptions}
<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> </div>
) )
} }