Overview page on groups (#961)
* Frontpage on groups wip * Fix James's nits
This commit is contained in:
parent
40c51c3d59
commit
1f8c72b4c9
|
@ -23,6 +23,7 @@ export type Group = {
|
|||
score: number
|
||||
}[]
|
||||
}
|
||||
pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
|
||||
}
|
||||
|
||||
export const MAX_GROUP_NAME_LENGTH = 75
|
||||
|
|
|
@ -176,7 +176,7 @@ service cloud.firestore {
|
|||
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
|
||||
&& request.resource.data.diff(resource.data)
|
||||
.affectedKeys()
|
||||
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
|
||||
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId', 'pinnedItems' ]);
|
||||
allow delete: if request.auth.uid == resource.data.creatorId;
|
||||
|
||||
match /groupContracts/{contractId} {
|
||||
|
|
|
@ -62,6 +62,7 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
|
|||
totalContracts: 0,
|
||||
totalMembers: memberIds.length,
|
||||
postIds: [],
|
||||
pinnedItems: [],
|
||||
}
|
||||
|
||||
await groupRef.create(group)
|
||||
|
|
|
@ -42,6 +42,7 @@ const createGroup = async (
|
|||
totalContracts: contracts.length,
|
||||
totalMembers: 1,
|
||||
postIds: [],
|
||||
pinnedItems: [],
|
||||
}
|
||||
await groupRef.create(group)
|
||||
// create a GroupMemberDoc for the creator
|
||||
|
|
|
@ -3,10 +3,7 @@ import { SearchOptions } from '@algolia/client-search'
|
|||
import { useRouter } from 'next/router'
|
||||
import { Contract } from 'common/contract'
|
||||
import { PAST_BETS, User } from 'common/user'
|
||||
import {
|
||||
ContractHighlightOptions,
|
||||
ContractsGrid,
|
||||
} from './contract/contracts-grid'
|
||||
import { CardHighlightOptions, ContractsGrid } from './contract/contracts-grid'
|
||||
import { ShowTime } from './contract/contract-details'
|
||||
import { Row } from './layout/row'
|
||||
import {
|
||||
|
@ -82,7 +79,7 @@ export function ContractSearch(props: {
|
|||
defaultFilter?: filter
|
||||
defaultPill?: string
|
||||
additionalFilter?: AdditionalFilter
|
||||
highlightOptions?: ContractHighlightOptions
|
||||
highlightOptions?: CardHighlightOptions
|
||||
onContractClick?: (contract: Contract) => void
|
||||
hideOrderSelector?: boolean
|
||||
cardUIOptions?: {
|
||||
|
|
|
@ -91,7 +91,7 @@ export function SelectMarketsModal(props: {
|
|||
noLinkAvatar: true,
|
||||
}}
|
||||
highlightOptions={{
|
||||
contractIds: contracts.map((c) => c.id),
|
||||
itemIds: contracts.map((c) => c.id),
|
||||
highlightClassName:
|
||||
'!bg-indigo-100 outline outline-2 outline-indigo-300',
|
||||
}}
|
||||
|
|
|
@ -12,8 +12,8 @@ import { VisibilityObserver } from '../visibility-observer'
|
|||
import Masonry from 'react-masonry-css'
|
||||
import { CPMMBinaryContract } from 'common/contract'
|
||||
|
||||
export type ContractHighlightOptions = {
|
||||
contractIds?: string[]
|
||||
export type CardHighlightOptions = {
|
||||
itemIds?: string[]
|
||||
highlightClassName?: string
|
||||
}
|
||||
|
||||
|
@ -28,7 +28,7 @@ export function ContractsGrid(props: {
|
|||
noLinkAvatar?: boolean
|
||||
showProbChange?: boolean
|
||||
}
|
||||
highlightOptions?: ContractHighlightOptions
|
||||
highlightOptions?: CardHighlightOptions
|
||||
trackingPostfix?: string
|
||||
breakpointColumns?: { [key: string]: number }
|
||||
}) {
|
||||
|
@ -43,7 +43,7 @@ export function ContractsGrid(props: {
|
|||
} = props
|
||||
const { hideQuickBet, hideGroupLink, noLinkAvatar, showProbChange } =
|
||||
cardUIOptions || {}
|
||||
const { contractIds, highlightClassName } = highlightOptions || {}
|
||||
const { itemIds: contractIds, highlightClassName } = highlightOptions || {}
|
||||
const onVisibilityUpdated = useCallback(
|
||||
(visible) => {
|
||||
if (visible && loadMore) {
|
||||
|
|
|
@ -13,7 +13,7 @@ import { deletePost, updatePost } from 'web/lib/firebase/posts'
|
|||
import { useState } from 'react'
|
||||
import { usePost } from 'web/hooks/use-post'
|
||||
|
||||
export function GroupAboutPost(props: {
|
||||
export function GroupOverviewPost(props: {
|
||||
group: Group
|
||||
isEditable: boolean
|
||||
post: Post | null
|
378
web/components/groups/group-overview.tsx
Normal file
378
web/components/groups/group-overview.tsx
Normal 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>
|
||||
)
|
||||
}
|
164
web/components/pinned-select-modal.tsx
Normal file
164
web/components/pinned-select-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
82
web/components/post-card.tsx
Normal file
82
web/components/post-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -11,12 +11,11 @@ import {
|
|||
groupPath,
|
||||
joinGroup,
|
||||
listMemberIds,
|
||||
updateGroup,
|
||||
} from 'web/lib/firebase/groups'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { useUser, useUserById } from 'web/hooks/use-user'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import {
|
||||
useGroup,
|
||||
useGroupContractIds,
|
||||
|
@ -24,27 +23,17 @@ import {
|
|||
} from 'web/hooks/use-group'
|
||||
import { Leaderboard } from 'web/components/leaderboard'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { EditGroupButton } from 'web/components/groups/edit-group-button'
|
||||
import Custom404 from '../../404'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
import { Linkify } from 'web/components/linkify'
|
||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||
|
||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||
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 { Button } from 'web/components/button'
|
||||
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
|
||||
import { GroupComment } from 'common/comment'
|
||||
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||
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 { getPost, listPosts } from 'web/lib/firebase/posts'
|
||||
import { Post } from 'common/post'
|
||||
import { Spacer } from 'web/components/layout/spacer'
|
||||
import { usePost, usePosts } from 'web/hooks/use-post'
|
||||
import { useAdmin } from 'web/hooks/use-admin'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
|
@ -53,10 +42,11 @@ import { SelectMarketsModal } from 'web/components/contract-select-modal'
|
|||
import { BETTORS } from 'common/user'
|
||||
import { Page } from 'web/components/page'
|
||||
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'
|
||||
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 async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||
|
@ -203,24 +193,18 @@ export default function GroupPage(props: {
|
|||
</>
|
||||
)
|
||||
|
||||
const aboutTab = (
|
||||
<Col>
|
||||
{(group.aboutPostId != null || isCreator || isAdmin) && (
|
||||
<GroupAboutPost
|
||||
group={group}
|
||||
isEditable={!!isCreator || isAdmin}
|
||||
post={aboutPost}
|
||||
/>
|
||||
)}
|
||||
<Spacer h={3} />
|
||||
const overviewPage = (
|
||||
<>
|
||||
<GroupOverview
|
||||
group={group}
|
||||
posts={groupPosts}
|
||||
isEditable={!!isCreator || isAdmin}
|
||||
aboutPost={aboutPost}
|
||||
creator={creator}
|
||||
isCreator={!!isCreator}
|
||||
user={user}
|
||||
memberIds={memberIds}
|
||||
/>
|
||||
</Col>
|
||||
</>
|
||||
)
|
||||
|
||||
const questionsTab = (
|
||||
|
@ -261,14 +245,14 @@ export default function GroupPage(props: {
|
|||
title: 'Leaderboards',
|
||||
content: leaderboardTab,
|
||||
},
|
||||
{
|
||||
title: 'About',
|
||||
content: aboutTab,
|
||||
},
|
||||
{
|
||||
title: 'Posts',
|
||||
content: postsPage,
|
||||
},
|
||||
{
|
||||
title: 'Overview',
|
||||
content: overviewPage,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
|
@ -326,103 +310,6 @@ function JoinOrAddQuestionsButtons(props: {
|
|||
) : 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: {
|
||||
topUsers: { user: User; score: number }[]
|
||||
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 [showCreatePost, setShowCreatePost] = useState(false)
|
||||
const user = useUser()
|
||||
|
@ -475,9 +362,7 @@ function GroupPosts(props: { posts: Post[]; group: Group }) {
|
|||
</Row>
|
||||
|
||||
<div className="mt-2">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
<PostCardList posts={posts} />
|
||||
{posts.length === 0 && (
|
||||
<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
|
||||
}
|
||||
|
||||
function PostCard(props: { post: Post }) {
|
||||
const { post } = props
|
||||
const creatorId = post.creatorId
|
||||
|
||||
const user = useUserById(creatorId)
|
||||
|
||||
if (!user) return <> </>
|
||||
|
||||
export function PostCardList(props: {
|
||||
posts: Post[]
|
||||
highlightOptions?: CardHighlightOptions
|
||||
onPostClick?: (post: Post) => void
|
||||
}) {
|
||||
const { posts, onPostClick, highlightOptions } = props
|
||||
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 className="w-full">
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
onPostClick={onPostClick}
|
||||
highlightOptions={highlightOptions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user