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
|
score: number
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MAX_GROUP_NAME_LENGTH = 75
|
export const MAX_GROUP_NAME_LENGTH = 75
|
||||||
|
|
|
@ -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} {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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?: {
|
||||||
|
|
|
@ -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',
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
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,
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user