Implement bookmarking of posts and comments (#235)
This commit is contained in:
parent
30cde2ea38
commit
7b838cdeb2
|
@ -484,6 +484,28 @@ export default {
|
|||
comments
|
||||
}
|
||||
},
|
||||
moreBookmarks: async (parent, { cursor, name }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
|
||||
const user = await models.user.findUnique({ where: { name } })
|
||||
if (!user) {
|
||||
throw new UserInputError('no user has that name', { argumentName: 'name' })
|
||||
}
|
||||
|
||||
const items = await models.$queryRaw(`
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
JOIN "Bookmark" ON "Bookmark"."itemId" = "Item"."id" AND "Bookmark"."userId" = $1
|
||||
AND "Bookmark".created_at <= $2
|
||||
ORDER BY "Bookmark".created_at DESC
|
||||
OFFSET $3
|
||||
LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset)
|
||||
|
||||
return {
|
||||
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||
items
|
||||
}
|
||||
},
|
||||
item: getItem,
|
||||
pageTitleAndUnshorted: async (parent, { url }, { models }) => {
|
||||
const res = {}
|
||||
|
@ -576,6 +598,14 @@ export default {
|
|||
},
|
||||
|
||||
Mutation: {
|
||||
bookmarkItem: async (parent, { id }, { me, models }) => {
|
||||
const data = { itemId: Number(id), userId: me.id }
|
||||
const old = await models.bookmark.findUnique({ where: { userId_itemId: data } })
|
||||
if (old) {
|
||||
await models.bookmark.delete({ where: { userId_itemId: data } })
|
||||
} else await models.bookmark.create({ data })
|
||||
return { id }
|
||||
},
|
||||
deleteItem: async (parent, { id }, { me, models }) => {
|
||||
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
||||
if (Number(old.userId) !== Number(me?.id)) {
|
||||
|
@ -924,6 +954,20 @@ export default {
|
|||
|
||||
return !!dontLike
|
||||
},
|
||||
meBookmark: async (item, args, { me, models }) => {
|
||||
if (!me) return false
|
||||
|
||||
const bookmark = await models.bookmark.findUnique({
|
||||
where: {
|
||||
userId_itemId: {
|
||||
itemId: Number(item.id),
|
||||
userId: me.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return !!bookmark
|
||||
},
|
||||
outlawed: async (item, args, { me, models }) => {
|
||||
if (me && Number(item.userId) === Number(me.id)) {
|
||||
return false
|
||||
|
|
|
@ -505,6 +505,20 @@ export default {
|
|||
}
|
||||
})
|
||||
},
|
||||
nbookmarks: async (user, { when }, { models }) => {
|
||||
if (user.nBookmarks) {
|
||||
return user.nBookmarks
|
||||
}
|
||||
|
||||
return await models.bookmark.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
createdAt: {
|
||||
gte: withinDate(when)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
stacked: async (user, { when }, { models }) => {
|
||||
if (user.stacked) {
|
||||
return user.stacked
|
||||
|
|
|
@ -4,6 +4,7 @@ export default gql`
|
|||
extend type Query {
|
||||
items(sub: String, sort: String, type: String, cursor: String, name: String, within: String): Items
|
||||
moreFlatComments(sort: String!, cursor: String, name: String, within: String): Comments
|
||||
moreBookmarks(cursor: String, name: String!): Items
|
||||
item(id: ID!): Item
|
||||
comments(id: ID!, sort: String): [Item!]!
|
||||
pageTitleAndUnshorted(url: String!): TitleUnshorted
|
||||
|
@ -32,6 +33,7 @@ export default gql`
|
|||
}
|
||||
|
||||
extend type Mutation {
|
||||
bookmarkItem(id: ID): Item
|
||||
deleteItem(id: ID): Item
|
||||
upsertLink(id: ID, title: String!, url: String!, boost: Int, forward: String): Item!
|
||||
upsertDiscussion(id: ID, title: String!, text: String, boost: Int, forward: String): Item!
|
||||
|
@ -99,6 +101,7 @@ export default gql`
|
|||
wvotes: Float!
|
||||
meSats: Int!
|
||||
meDontLike: Boolean!
|
||||
meBookmark: Boolean!
|
||||
outlawed: Boolean!
|
||||
freebie: Boolean!
|
||||
paidImgLink: Boolean
|
||||
|
|
|
@ -45,6 +45,7 @@ export default gql`
|
|||
name: String
|
||||
nitems(when: String): Int!
|
||||
ncomments(when: String): Int!
|
||||
nbookmarks(when: String): Int!
|
||||
stacked(when: String): Int!
|
||||
spent(when: String): Int!
|
||||
referrals(when: String): Int!
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
import { useMutation } from '@apollo/client'
|
||||
import { gql } from 'apollo-server-micro'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
|
||||
export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) {
|
||||
const [bookmarkItem] = useMutation(
|
||||
gql`
|
||||
mutation bookmarkItem($id: ID!) {
|
||||
bookmarkItem(id: $id) {
|
||||
meBookmark
|
||||
}
|
||||
}`, {
|
||||
update (cache, { data: { bookmarkItem } }) {
|
||||
cache.modify({
|
||||
id: `Item:${id}`,
|
||||
fields: {
|
||||
meBookmark: () => bookmarkItem.meBookmark
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
return (
|
||||
<Dropdown.Item
|
||||
onClick={() => bookmarkItem({ variables: { id } })}
|
||||
>
|
||||
{meBookmark ? 'remove bookmark' : 'bookmark'}
|
||||
</Dropdown.Item>
|
||||
)
|
||||
}
|
|
@ -4,26 +4,20 @@ import Text from './text'
|
|||
import Link from 'next/link'
|
||||
import Reply, { ReplyOnAnotherPage } from './reply'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { timeSince } from '../lib/time'
|
||||
import UpVote from './upvote'
|
||||
import Eye from '../svgs/eye-fill.svg'
|
||||
import EyeClose from '../svgs/eye-close-line.svg'
|
||||
import { useRouter } from 'next/router'
|
||||
import CommentEdit from './comment-edit'
|
||||
import Countdown from './countdown'
|
||||
import { COMMENT_DEPTH_LIMIT, NOFOLLOW_LIMIT } from '../lib/constants'
|
||||
import { ignoreClick } from '../lib/clicks'
|
||||
import PayBounty from './pay-bounty'
|
||||
import BountyIcon from '../svgs/bounty-bag.svg'
|
||||
import ActionTooltip from './action-tooltip'
|
||||
import { useMe } from './me'
|
||||
import DontLikeThis from './dont-link-this'
|
||||
import Flag from '../svgs/flag-fill.svg'
|
||||
import { Badge } from 'react-bootstrap'
|
||||
import { abbrNum } from '../lib/format'
|
||||
import Share from './share'
|
||||
import { DeleteDropdown } from './delete'
|
||||
import CowboyHat from './cowboy-hat'
|
||||
import ItemInfo from './item-info'
|
||||
|
||||
function Parent ({ item, rootText }) {
|
||||
const ParentFrag = () => (
|
||||
|
@ -89,12 +83,7 @@ export default function Comment ({
|
|||
const [edit, setEdit] = useState()
|
||||
const [collapse, setCollapse] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const me = useMe()
|
||||
const router = useRouter()
|
||||
const mine = item.mine
|
||||
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
|
||||
const [canEdit, setCanEdit] =
|
||||
useState(mine && (Date.now() < editThreshold))
|
||||
|
||||
useEffect(() => {
|
||||
if (Number(router.query.commentId) === Number(item.id)) {
|
||||
|
@ -123,56 +112,23 @@ export default function Comment ({
|
|||
: <UpVote item={item} className={styles.upvote} />}
|
||||
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
|
||||
<div className='d-flex align-items-center'>
|
||||
<div className={`${itemStyles.other} ${styles.other}`}>
|
||||
<span title={`from ${item.upvotes} users ${item.mine ? `\\ ${item.meSats} sats to post` : `(${item.meSats} sats from me)`}`}>{abbrNum(item.sats)} sats</span>
|
||||
<span> \ </span>
|
||||
{item.boost > 0 &&
|
||||
<ItemInfo
|
||||
item={item}
|
||||
commentsText='replies'
|
||||
className={`${itemStyles.other} ${styles.other}`}
|
||||
embellishUser={op && <span className='text-boost font-weight-bold ml-1'>OP</span>}
|
||||
extraInfo={
|
||||
<>
|
||||
<span>{abbrNum(item.boost)} boost</span>
|
||||
<span> \ </span>
|
||||
</>}
|
||||
<Link href={`/items/${item.id}`} passHref>
|
||||
<a title={`${item.commentSats} sats`} className='text-reset'>{item.ncomments} replies</a>
|
||||
</Link>
|
||||
<span> \ </span>
|
||||
<Link href={`/${item.user.name}`} passHref>
|
||||
<a className='d-inline-flex align-items-center'>
|
||||
@{item.user.name}<CowboyHat className='ml-1 fill-grey' streak={item.user.streak} height={12} width={12} />
|
||||
{op && <span className='text-boost font-weight-bold ml-1'>OP</span>}
|
||||
</a>
|
||||
</Link>
|
||||
<span> </span>
|
||||
<Link href={`/items/${item.id}`} passHref>
|
||||
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
|
||||
</Link>
|
||||
{includeParent && <Parent item={item} rootText={rootText} />}
|
||||
{bountyPaid &&
|
||||
<ActionTooltip notForm overlayText={`${abbrNum(item.root.bounty)} sats paid`}>
|
||||
<BountyIcon className={`${styles.bountyIcon} ${'fill-success vertical-align-middle'}`} height={16} width={16} />
|
||||
</ActionTooltip>}
|
||||
{me && !item.meSats && !item.meDontLike && !item.mine && !item.deletedAt && <DontLikeThis id={item.id} />}
|
||||
{(item.outlawed && <Link href='/outlawed'><a>{' '}<Badge className={itemStyles.newComment} variant={null}>OUTLAWED</Badge></a></Link>) ||
|
||||
(item.freebie && !item.mine && (me?.greeterMode) && <Link href='/freebie'><a>{' '}<Badge className={itemStyles.newComment} variant={null}>FREEBIE</Badge></a></Link>)}
|
||||
{canEdit && !item.deletedAt &&
|
||||
<>
|
||||
<span> \ </span>
|
||||
<div
|
||||
className={styles.edit}
|
||||
onClick={e => {
|
||||
setEdit(!edit)
|
||||
}}
|
||||
>
|
||||
{edit ? 'cancel' : 'edit'}
|
||||
<Countdown
|
||||
date={editThreshold}
|
||||
onComplete={() => {
|
||||
setCanEdit(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>}
|
||||
{mine && !canEdit && !item.deletedAt && <DeleteDropdown itemId={item.id} />}
|
||||
</div>
|
||||
{includeParent && <Parent item={item} rootText={rootText} />}
|
||||
{bountyPaid &&
|
||||
<ActionTooltip notForm overlayText={`${abbrNum(item.root.bounty)} sats paid`}>
|
||||
<BountyIcon className={`${styles.bountyIcon} ${'fill-success vertical-align-middle'}`} height={16} width={16} />
|
||||
</ActionTooltip>}
|
||||
</>
|
||||
}
|
||||
onEdit={e => { setEdit(!edit) }}
|
||||
editText={edit ? 'cancel' : 'edit'}
|
||||
/>
|
||||
{!includeParent && (collapse
|
||||
? <Eye
|
||||
className={styles.collapser} height={10} width={10} onClick={() => {
|
||||
|
@ -186,7 +142,11 @@ export default function Comment ({
|
|||
localStorage.setItem(`commentCollapse:${item.id}`, 'yep')
|
||||
}}
|
||||
/>)}
|
||||
{topLevel && <Share item={item} />}
|
||||
{topLevel && (
|
||||
<span className='d-flex ml-auto align-items-center'>
|
||||
<Share item={item} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{edit
|
||||
? (
|
||||
|
@ -194,7 +154,6 @@ export default function Comment ({
|
|||
comment={item}
|
||||
onSuccess={() => {
|
||||
setEdit(!edit)
|
||||
setCanEdit(mine && (Date.now() < editThreshold))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -3,7 +3,6 @@ import { gql } from 'apollo-server-micro'
|
|||
import { useState } from 'react'
|
||||
import { Alert, Button, Dropdown } from 'react-bootstrap'
|
||||
import { useShowModal } from './modal'
|
||||
import MoreIcon from '../svgs/more-fill.svg'
|
||||
|
||||
export default function Delete ({ itemId, children, onDelete }) {
|
||||
const showModal = useShowModal()
|
||||
|
@ -81,22 +80,12 @@ function DeleteConfirm ({ onConfirm }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function DeleteDropdown (props) {
|
||||
export function DeleteDropdownItem (props) {
|
||||
return (
|
||||
<Dropdown className='pointer' as='span'>
|
||||
<Dropdown.Toggle variant='success' id='dropdown-basic' as='a'>
|
||||
<MoreIcon className='fill-grey ml-1' height={16} width={16} />
|
||||
</Dropdown.Toggle>
|
||||
|
||||
<Dropdown.Menu>
|
||||
<Delete {...props}>
|
||||
<Dropdown.Item
|
||||
className='text-center'
|
||||
>
|
||||
delete
|
||||
</Dropdown.Item>
|
||||
</Delete>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
<Delete {...props}>
|
||||
<Dropdown.Item>
|
||||
delete
|
||||
</Dropdown.Item>
|
||||
</Delete>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import { gql, useMutation } from '@apollo/client'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import MoreIcon from '../svgs/more-fill.svg'
|
||||
import FundError from './fund-error'
|
||||
import { useShowModal } from './modal'
|
||||
|
||||
export default function DontLikeThis ({ id }) {
|
||||
export default function DontLikeThisDropdownItem ({ id }) {
|
||||
const showModal = useShowModal()
|
||||
|
||||
const [dontLikeThis] = useMutation(
|
||||
|
@ -26,32 +25,23 @@ export default function DontLikeThis ({ id }) {
|
|||
)
|
||||
|
||||
return (
|
||||
<Dropdown className='pointer' as='span'>
|
||||
<Dropdown.Toggle variant='success' id='dropdown-basic' as='a'>
|
||||
<MoreIcon className='fill-grey ml-1' height={16} width={16} />
|
||||
</Dropdown.Toggle>
|
||||
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
className='text-center'
|
||||
onClick={async () => {
|
||||
try {
|
||||
await dontLikeThis({
|
||||
variables: { id },
|
||||
optimisticResponse: { dontLikeThis: true }
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.toString().includes('insufficient funds')) {
|
||||
showModal(onClose => {
|
||||
return <FundError onClose={onClose} />
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
flag
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
<Dropdown.Item
|
||||
onClick={async () => {
|
||||
try {
|
||||
await dontLikeThis({
|
||||
variables: { id },
|
||||
optimisticResponse: { dontLikeThis: true }
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.toString().includes('insufficient funds')) {
|
||||
showModal(onClose => {
|
||||
return <FundError onClose={onClose} />
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
flag
|
||||
</Dropdown.Item>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ export default function Header ({ sub }) {
|
|||
const prefix = sub ? `/~${sub}` : ''
|
||||
// there's always at least 2 on the split, e.g. '/' yields ['','']
|
||||
const topNavKey = path.split('/')[sub ? 2 : 1]
|
||||
const dropNavKey = path.split('/').slice(sub ? 2 : 1).join('/')
|
||||
const { data: subLatestPost } = useQuery(gql`
|
||||
query subLatestPost($name: ID!) {
|
||||
subLatestPost(name: $name)
|
||||
|
@ -80,7 +81,7 @@ export default function Header ({ sub }) {
|
|||
} alignRight
|
||||
>
|
||||
<Link href={'/' + me?.name} passHref>
|
||||
<NavDropdown.Item eventKey={me?.name}>
|
||||
<NavDropdown.Item active={me?.name === dropNavKey}>
|
||||
profile
|
||||
{me && !me.bioId &&
|
||||
<div className='p-1 d-inline-block bg-secondary ml-1'>
|
||||
|
@ -88,6 +89,9 @@ export default function Header ({ sub }) {
|
|||
</div>}
|
||||
</NavDropdown.Item>
|
||||
</Link>
|
||||
<Link href={'/' + me?.name + '/bookmarks'} passHref>
|
||||
<NavDropdown.Item active={me?.name + '/bookmarks' === dropNavKey}>bookmarks</NavDropdown.Item>
|
||||
</Link>
|
||||
<Link href='/wallet' passHref>
|
||||
<NavDropdown.Item eventKey='wallet'>wallet</NavDropdown.Item>
|
||||
</Link>
|
||||
|
|
|
@ -17,6 +17,9 @@ import { commentsViewed } from '../lib/new-comments'
|
|||
import Related from './related'
|
||||
import PastBounties from './past-bounties'
|
||||
import Check from '../svgs/check-double-line.svg'
|
||||
import Share from './share'
|
||||
import Toc from './table-of-contents'
|
||||
import Link from 'next/link'
|
||||
|
||||
function BioItem ({ item, handleClick }) {
|
||||
const me = useMe()
|
||||
|
@ -91,11 +94,32 @@ function ItemEmbed ({ item }) {
|
|||
return null
|
||||
}
|
||||
|
||||
function FwdUser ({ user }) {
|
||||
return (
|
||||
<div className={styles.other}>
|
||||
100% of tips are forwarded to{' '}
|
||||
<Link href={`/${user.name}`} passHref>
|
||||
<a>@{user.name}</a>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TopLevelItem ({ item, noReply, ...props }) {
|
||||
const ItemComponent = item.isJob ? ItemJob : Item
|
||||
|
||||
return (
|
||||
<ItemComponent item={item} toc showFwdUser {...props}>
|
||||
<ItemComponent
|
||||
item={item}
|
||||
right={
|
||||
<>
|
||||
<Share item={item} />
|
||||
<Toc text={item.text} />
|
||||
</>
|
||||
}
|
||||
belowTitle={item.fwdUser && <FwdUser user={item.fwdUser} />}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.fullItemContainer}>
|
||||
{item.text && <ItemText item={item} />}
|
||||
{item.url && <ItemEmbed item={item} />}
|
||||
|
|
|
@ -0,0 +1,116 @@
|
|||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Badge, Dropdown } from 'react-bootstrap'
|
||||
import Countdown from './countdown'
|
||||
import { abbrNum } from '../lib/format'
|
||||
import { newComments } from '../lib/new-comments'
|
||||
import { timeSince } from '../lib/time'
|
||||
import CowboyHat from './cowboy-hat'
|
||||
import { DeleteDropdownItem } from './delete'
|
||||
import styles from './item.module.css'
|
||||
import { useMe } from './me'
|
||||
import MoreIcon from '../svgs/more-fill.svg'
|
||||
import DontLikeThisDropdownItem from './dont-link-this'
|
||||
import BookmarkDropdownItem from './bookmark'
|
||||
import { CopyLinkDropdownItem } from './share'
|
||||
|
||||
export default function ItemInfo ({ item, commentsText, className, embellishUser, extraInfo, onEdit, editText }) {
|
||||
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
|
||||
const me = useMe()
|
||||
const router = useRouter()
|
||||
const [canEdit, setCanEdit] =
|
||||
useState(item.mine && (Date.now() < editThreshold))
|
||||
const [hasNewComments, setHasNewComments] = useState(false)
|
||||
useEffect(() => {
|
||||
// if we are showing toc, then this is a full item
|
||||
setHasNewComments(newComments(item))
|
||||
}, [item])
|
||||
|
||||
return (
|
||||
<div className={className || `${styles.other}`}>
|
||||
{!item.position &&
|
||||
<>
|
||||
<span title={`from ${item.upvotes} users ${item.mine ? `\\ ${item.meSats} sats to post` : `(${item.meSats} sats from me)`} `}>{abbrNum(item.sats)} sats</span>
|
||||
<span> \ </span>
|
||||
</>}
|
||||
{item.boost > 0 &&
|
||||
<>
|
||||
<span>{abbrNum(item.boost)} boost</span>
|
||||
<span> \ </span>
|
||||
</>}
|
||||
<Link href={`/items/${item.id}`} passHref>
|
||||
<a title={`${item.commentSats} sats`} className='text-reset'>
|
||||
{item.ncomments} {commentsText || 'comments'}
|
||||
{hasNewComments && <>{' '}<Badge className={styles.newComment} variant={null}>new</Badge></>}
|
||||
</a>
|
||||
</Link>
|
||||
<span> \ </span>
|
||||
<span>
|
||||
<Link href={`/${item.user.name}`} passHref>
|
||||
<a className='d-inline-flex align-items-center'>
|
||||
@{item.user.name}<CowboyHat className='ml-1 fill-grey' streak={item.user.streak} height={12} width={12} />
|
||||
{embellishUser}
|
||||
</a>
|
||||
</Link>
|
||||
<span> </span>
|
||||
<Link href={`/items/${item.id}`} passHref>
|
||||
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
|
||||
</Link>
|
||||
{item.prior &&
|
||||
<>
|
||||
<span> \ </span>
|
||||
<Link href={`/items/${item.prior}`} passHref>
|
||||
<a className='text-reset'>yesterday</a>
|
||||
</Link>
|
||||
</>}
|
||||
</span>
|
||||
{(item.outlawed && !item.mine &&
|
||||
<Link href='/outlawed'>
|
||||
<a>{' '}<Badge className={styles.newComment} variant={null}>OUTLAWED</Badge></a>
|
||||
</Link>) || (item.freebie && !item.mine &&
|
||||
<Link href='/freebie'>
|
||||
<a>{' '}<Badge className={styles.newComment} variant={null}>FREEBIE</Badge></a>
|
||||
</Link>
|
||||
)}
|
||||
{canEdit && !item.deletedAt &&
|
||||
<>
|
||||
<span> \ </span>
|
||||
<span
|
||||
className='text-reset pointer'
|
||||
onClick={() => onEdit ? onEdit() : router.push(`/items/${item.id}/edit`)}
|
||||
>
|
||||
{editText || 'edit'}
|
||||
<Countdown
|
||||
date={editThreshold}
|
||||
onComplete={() => {
|
||||
setCanEdit(false)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</>}
|
||||
{extraInfo}
|
||||
<ItemDropdown>
|
||||
<CopyLinkDropdownItem item={item} />
|
||||
<BookmarkDropdownItem item={item} />
|
||||
{me && !item.meSats && !item.position && !item.meDontLike &&
|
||||
!item.mine && !item.deletedAt && <DontLikeThisDropdownItem id={item.id} />}
|
||||
{item.mine && !item.position && !item.deletedAt &&
|
||||
<DeleteDropdownItem itemId={item.id} />}
|
||||
</ItemDropdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemDropdown ({ children }) {
|
||||
return (
|
||||
<Dropdown className='pointer' as='span'>
|
||||
<Dropdown.Toggle variant='success' id='dropdown-basic' as='a'>
|
||||
<MoreIcon className='fill-grey ml-1' height={16} width={16} />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{children}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
|
@ -1,25 +1,16 @@
|
|||
import Link from 'next/link'
|
||||
import styles from './item.module.css'
|
||||
import { timeSince } from '../lib/time'
|
||||
import UpVote from './upvote'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Countdown from './countdown'
|
||||
import { NOFOLLOW_LIMIT } from '../lib/constants'
|
||||
import Pin from '../svgs/pushpin-fill.svg'
|
||||
import reactStringReplace from 'react-string-replace'
|
||||
import Toc from './table-of-contents'
|
||||
import PollIcon from '../svgs/bar-chart-horizontal-fill.svg'
|
||||
import BountyIcon from '../svgs/bounty-bag.svg'
|
||||
import ActionTooltip from './action-tooltip'
|
||||
import { Badge } from 'react-bootstrap'
|
||||
import { newComments } from '../lib/new-comments'
|
||||
import { useMe } from './me'
|
||||
import DontLikeThis from './dont-link-this'
|
||||
import Flag from '../svgs/flag-fill.svg'
|
||||
import Share from './share'
|
||||
import { abbrNum } from '../lib/format'
|
||||
import { DeleteDropdown } from './delete'
|
||||
import CowboyHat from './cowboy-hat'
|
||||
import ItemInfo from './item-info'
|
||||
|
||||
export function SearchTitle ({ title }) {
|
||||
return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => {
|
||||
|
@ -27,26 +18,9 @@ export function SearchTitle ({ title }) {
|
|||
})
|
||||
}
|
||||
|
||||
function FwdUser ({ user }) {
|
||||
return (
|
||||
<div className={styles.other}>
|
||||
100% of tips are forwarded to{' '}
|
||||
<Link href={`/${user.name}`} passHref>
|
||||
<a>@{user.name}</a>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Item ({ item, rank, showFwdUser, toc, children }) {
|
||||
const mine = item.mine
|
||||
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
|
||||
const [canEdit, setCanEdit] =
|
||||
useState(mine && (Date.now() < editThreshold))
|
||||
export default function Item ({ item, rank, belowTitle, right, children }) {
|
||||
const [wrap, setWrap] = useState(false)
|
||||
const titleRef = useRef()
|
||||
const me = useMe()
|
||||
const [hasNewComments, setHasNewComments] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setWrap(
|
||||
|
@ -54,11 +28,6 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
|
|||
titleRef.current.clientHeight)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// if we are showing toc, then this is a full item
|
||||
setHasNewComments(!toc && newComments(item))
|
||||
}, [item])
|
||||
|
||||
return (
|
||||
<>
|
||||
{rank
|
||||
|
@ -96,69 +65,10 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
|
|||
</a>
|
||||
</>}
|
||||
</div>
|
||||
<div className={`${styles.other}`}>
|
||||
{!item.position &&
|
||||
<>
|
||||
<span title={`from ${item.upvotes} users ${item.mine ? `\\ ${item.meSats} sats to post` : `(${item.meSats} sats from me)`} `}>{abbrNum(item.sats)} sats</span>
|
||||
<span> \ </span>
|
||||
</>}
|
||||
{item.boost > 0 &&
|
||||
<>
|
||||
<span>{abbrNum(item.boost)} boost</span>
|
||||
<span> \ </span>
|
||||
</>}
|
||||
<Link href={`/items/${item.id}`} passHref>
|
||||
<a title={`${item.commentSats} sats`} className='text-reset'>
|
||||
{item.ncomments} comments
|
||||
{hasNewComments && <>{' '}<Badge className={styles.newComment} variant={null}>new</Badge></>}
|
||||
</a>
|
||||
</Link>
|
||||
<span> \ </span>
|
||||
<span>
|
||||
<Link href={`/${item.user.name}`} passHref>
|
||||
<a className='d-inline-flex align-items-center'>@{item.user.name}<CowboyHat className='ml-1 fill-grey' streak={item.user.streak} height={12} width={12} /></a>
|
||||
</Link>
|
||||
<span> </span>
|
||||
<Link href={`/items/${item.id}`} passHref>
|
||||
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
|
||||
</Link>
|
||||
{me && !item.meSats && !item.position && !item.meDontLike && !item.mine && !item.deletedAt && <DontLikeThis id={item.id} />}
|
||||
{(item.outlawed && <Link href='/outlawed'><a>{' '}<Badge className={styles.newComment} variant={null}>OUTLAWED</Badge></a></Link>) ||
|
||||
(item.freebie && !item.mine && (me?.greeterMode) && <Link href='/freebie'><a>{' '}<Badge className={styles.newComment} variant={null}>FREEBIE</Badge></a></Link>)}
|
||||
{item.prior &&
|
||||
<>
|
||||
<span> \ </span>
|
||||
<Link href={`/items/${item.prior}`} passHref>
|
||||
<a className='text-reset'>yesterday</a>
|
||||
</Link>
|
||||
</>}
|
||||
</span>
|
||||
{canEdit && !item.deletedAt &&
|
||||
<>
|
||||
<span> \ </span>
|
||||
<Link href={`/items/${item.id}/edit`} passHref>
|
||||
<a className='text-reset'>
|
||||
edit
|
||||
<Countdown
|
||||
date={editThreshold}
|
||||
className=' '
|
||||
onComplete={() => {
|
||||
setCanEdit(false)
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</>}
|
||||
{mine && !canEdit && !item.position && !item.deletedAt &&
|
||||
<DeleteDropdown itemId={item.id} />}
|
||||
</div>
|
||||
{showFwdUser && item.fwdUser && <FwdUser user={item.fwdUser} />}
|
||||
<ItemInfo item={item} />
|
||||
{belowTitle}
|
||||
</div>
|
||||
{toc &&
|
||||
<>
|
||||
<Share item={item} />
|
||||
<Toc text={item.text} />
|
||||
</>}
|
||||
{right}
|
||||
</div>
|
||||
{children && (
|
||||
<div className={styles.children}>
|
||||
|
|
|
@ -34,7 +34,6 @@ export default function Share ({ item }) {
|
|||
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
className='text-center'
|
||||
onClick={async () => {
|
||||
copy(url)
|
||||
}}
|
||||
|
@ -44,3 +43,26 @@ export default function Share ({ item }) {
|
|||
</Dropdown.Menu>
|
||||
</Dropdown>)
|
||||
}
|
||||
|
||||
export function CopyLinkDropdownItem ({ item }) {
|
||||
const me = useMe()
|
||||
const url = `https://stacker.news/items/${item.id}${me ? `/r/${me.name}` : ''}`
|
||||
return (
|
||||
<Dropdown.Item
|
||||
onClick={async () => {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: item.title || '',
|
||||
text: '',
|
||||
url
|
||||
}).then(() => console.log('Successful share'))
|
||||
.catch((error) => console.log('Error sharing', error))
|
||||
} else {
|
||||
copy(url)
|
||||
}
|
||||
}}
|
||||
>
|
||||
copy link
|
||||
</Dropdown.Item>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -161,12 +161,11 @@ export default function UserHeader ({ user }) {
|
|||
<Nav.Link>{user.ncomments} comments</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
{isMe &&
|
||||
<Nav.Item>
|
||||
<Link href='/satistics?inc=invoice,withdrawal' passHref>
|
||||
<Nav.Link eventKey='/satistics'>satistics</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>}
|
||||
<Nav.Item>
|
||||
<Link href={'/' + user.name + '/bookmarks'} passHref>
|
||||
<Nav.Link>{user.nbookmarks} bookmarks</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -17,6 +17,7 @@ export const COMMENT_FIELDS = gql`
|
|||
boost
|
||||
meSats
|
||||
meDontLike
|
||||
meBookmark
|
||||
outlawed
|
||||
freebie
|
||||
path
|
||||
|
|
|
@ -24,6 +24,7 @@ export const ITEM_FIELDS = gql`
|
|||
path
|
||||
meSats
|
||||
meDontLike
|
||||
meBookmark
|
||||
outlawed
|
||||
freebie
|
||||
ncomments
|
||||
|
@ -218,8 +219,8 @@ export const ITEM_WITH_COMMENTS = gql`
|
|||
|
||||
export const BOUNTY_ITEMS_BY_USER_NAME = gql`
|
||||
${ITEM_FIELDS}
|
||||
query getBountiesByUserName($name: String!) {
|
||||
getBountiesByUserName(name: $name) {
|
||||
query getBountiesByUserName($name: String!, $cursor: String, $limit: Int) {
|
||||
getBountiesByUserName(name: $name, cursor: $cursor, limit: $limit) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFields
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { gql } from '@apollo/client'
|
||||
import { COMMENT_FIELDS } from './comments'
|
||||
import { ITEM_FIELDS, ITEM_WITH_COMMENTS } from './items'
|
||||
import { ITEM_FIELDS, ITEM_FULL_FIELDS, ITEM_WITH_COMMENTS } from './items'
|
||||
|
||||
export const ME = gql`
|
||||
{
|
||||
|
@ -124,6 +124,7 @@ export const USER_FIELDS = gql`
|
|||
streak
|
||||
nitems
|
||||
ncomments
|
||||
nbookmarks
|
||||
stacked
|
||||
sats
|
||||
photoId
|
||||
|
@ -196,6 +197,22 @@ export const USER_WITH_COMMENTS = gql`
|
|||
}
|
||||
}`
|
||||
|
||||
export const USER_WITH_BOOKMARKS = gql`
|
||||
${USER_FIELDS}
|
||||
${ITEM_FULL_FIELDS}
|
||||
query UserWithBookmarks($name: String!, $cursor: String) {
|
||||
user(name: $name) {
|
||||
...UserFields
|
||||
}
|
||||
moreBookmarks(name: $name, cursor: $cursor) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFullFields
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const USER_WITH_POSTS = gql`
|
||||
${USER_FIELDS}
|
||||
${ITEM_FIELDS}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { gql } from '@apollo/client'
|
||||
import { ITEM_FULL_FIELDS } from './items'
|
||||
import { USER_FIELDS } from './users'
|
||||
|
||||
export const INVOICE = gql`
|
||||
query Invoice($id: ID!) {
|
||||
|
@ -28,12 +27,8 @@ export const WITHDRAWL = gql`
|
|||
|
||||
export const WALLET_HISTORY = gql`
|
||||
${ITEM_FULL_FIELDS}
|
||||
${USER_FIELDS}
|
||||
|
||||
query WalletHistory($cursor: String, $inc: String) {
|
||||
me {
|
||||
...UserFields
|
||||
}
|
||||
walletHistory(cursor: $cursor, inc: $inc) {
|
||||
facts {
|
||||
id
|
||||
|
|
|
@ -181,6 +181,19 @@ function getClient (uri) {
|
|||
}
|
||||
}
|
||||
},
|
||||
moreBookmarks: {
|
||||
keyArgs: ['name'],
|
||||
merge (existing, incoming) {
|
||||
if (isFirstPage(incoming.cursor, existing?.items)) {
|
||||
return incoming
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: incoming.cursor,
|
||||
items: [...(existing?.items || []), ...incoming.items]
|
||||
}
|
||||
}
|
||||
},
|
||||
notifications: {
|
||||
keyArgs: ['inc'],
|
||||
merge (existing, incoming) {
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import Layout from '../../components/layout'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import UserHeader from '../../components/user-header'
|
||||
import Seo from '../../components/seo'
|
||||
import Items from '../../components/items'
|
||||
import { USER_WITH_BOOKMARKS } from '../../fragments/users'
|
||||
import { getGetServerSideProps } from '../../api/ssrApollo'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps(USER_WITH_BOOKMARKS)
|
||||
|
||||
export default function UserBookmarks ({ data: { user, moreBookmarks: { items, cursor } } }) {
|
||||
const { data } = useQuery(
|
||||
USER_WITH_BOOKMARKS, { variables: { name: user.name } })
|
||||
|
||||
if (data) {
|
||||
({ user, moreBookmarks: { items, cursor } } = data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout noSeo>
|
||||
<Seo user={user} />
|
||||
<UserHeader user={user} />
|
||||
<div className='mt-2'>
|
||||
<Items
|
||||
items={items} cursor={cursor}
|
||||
query={USER_WITH_BOOKMARKS}
|
||||
destructureData={data => data.moreBookmarks}
|
||||
variables={{ name: user.name }}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
|
@ -4,7 +4,6 @@ import { Table } from 'react-bootstrap'
|
|||
import { getGetServerSideProps } from '../api/ssrApollo'
|
||||
import Layout from '../components/layout'
|
||||
import MoreFooter from '../components/more-footer'
|
||||
import UserHeader from '../components/user-header'
|
||||
import { WALLET_HISTORY } from '../fragments/wallet'
|
||||
import styles from '../styles/satistics.module.css'
|
||||
import Moon from '../svgs/moon-fill.svg'
|
||||
|
@ -138,7 +137,7 @@ function Detail ({ fact }) {
|
|||
return <div className={styles.commentWrapper}><Comment item={fact.item} includeParent noReply truncate /></div>
|
||||
}
|
||||
|
||||
export default function Satistics ({ data: { me, walletHistory: { facts, cursor } } }) {
|
||||
export default function Satistics ({ data: { walletHistory: { facts, cursor } } }) {
|
||||
const router = useRouter()
|
||||
const { data, fetchMore } = useQuery(WALLET_HISTORY, { variables: { inc: router.query.inc } })
|
||||
|
||||
|
@ -176,7 +175,7 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
|
|||
}
|
||||
|
||||
if (data) {
|
||||
({ me, walletHistory: { facts, cursor } } = data)
|
||||
({ walletHistory: { facts, cursor } } = data)
|
||||
}
|
||||
|
||||
const SatisticsSkeleton = () => (
|
||||
|
@ -185,9 +184,9 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
|
|||
</div>)
|
||||
|
||||
return (
|
||||
<Layout noSeo>
|
||||
<UserHeader user={me} />
|
||||
<Layout>
|
||||
<div className='mt-3'>
|
||||
<h2 className='text-center'>satistics</h2>
|
||||
<Form
|
||||
initial={{
|
||||
invoice: included('invoice'),
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "Bookmark" (
|
||||
"userId" INTEGER NOT NULL,
|
||||
"itemId" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY ("userId","itemId")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Bookmark" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Bookmark" ADD FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -89,6 +89,7 @@ model User {
|
|||
Donation Donation[]
|
||||
ReferralAct ReferralAct[]
|
||||
Streak Streak[]
|
||||
Bookmarks Bookmark[]
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([inviteId])
|
||||
|
@ -297,6 +298,7 @@ model Item {
|
|||
User User[]
|
||||
PollOption PollOption[]
|
||||
PollVote PollVote[]
|
||||
Bookmark Bookmark[]
|
||||
|
||||
@@index([weightedVotes])
|
||||
@@index([weightedDownVotes])
|
||||
|
@ -523,3 +525,13 @@ model VerificationRequest {
|
|||
|
||||
@@map(name: "verification_requests")
|
||||
}
|
||||
|
||||
model Bookmark {
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
item Item @relation(fields: [itemId], references: [id])
|
||||
itemId Int
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
@@id([userId, itemId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
|
|
@ -2,6 +2,11 @@
|
|||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
.other {
|
||||
font-size: 80%;
|
||||
color: var(--theme-grey);
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.comments {
|
||||
margin-top: .5rem;
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 6c0-1.4 0-2.1.272-2.635a2.5 2.5 0 0 1 1.093-1.093C6.9 2 7.6 2 9 2h6c1.4 0 2.1 0 2.635.272a2.5 2.5 0 0 1 1.092 1.093C19 3.9 19 4.6 19 6v13.208c0 1.056 0 1.583-.217 1.856a1 1 0 0 1-.778.378c-.349.002-.764-.324-1.593-.976L12 17l-4.411 3.466c-.83.652-1.245.978-1.594.976a1 1 0 0 1-.778-.378C5 20.791 5 20.264 5 19.208V6z"/></svg>
|
After Width: | Height: | Size: 436 B |
Loading…
Reference in New Issue