Implement bookmarking of posts and comments (#235)
This commit is contained in:
parent
30cde2ea38
commit
7b838cdeb2
@ -484,6 +484,28 @@ export default {
|
|||||||
comments
|
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,
|
item: getItem,
|
||||||
pageTitleAndUnshorted: async (parent, { url }, { models }) => {
|
pageTitleAndUnshorted: async (parent, { url }, { models }) => {
|
||||||
const res = {}
|
const res = {}
|
||||||
@ -576,6 +598,14 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Mutation: {
|
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 }) => {
|
deleteItem: async (parent, { id }, { me, models }) => {
|
||||||
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
||||||
if (Number(old.userId) !== Number(me?.id)) {
|
if (Number(old.userId) !== Number(me?.id)) {
|
||||||
@ -924,6 +954,20 @@ export default {
|
|||||||
|
|
||||||
return !!dontLike
|
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 }) => {
|
outlawed: async (item, args, { me, models }) => {
|
||||||
if (me && Number(item.userId) === Number(me.id)) {
|
if (me && Number(item.userId) === Number(me.id)) {
|
||||||
return false
|
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 }) => {
|
stacked: async (user, { when }, { models }) => {
|
||||||
if (user.stacked) {
|
if (user.stacked) {
|
||||||
return user.stacked
|
return user.stacked
|
||||||
|
@ -4,6 +4,7 @@ export default gql`
|
|||||||
extend type Query {
|
extend type Query {
|
||||||
items(sub: String, sort: String, type: String, cursor: String, name: String, within: String): Items
|
items(sub: String, sort: String, type: String, cursor: String, name: String, within: String): Items
|
||||||
moreFlatComments(sort: String!, cursor: String, name: String, within: String): Comments
|
moreFlatComments(sort: String!, cursor: String, name: String, within: String): Comments
|
||||||
|
moreBookmarks(cursor: String, name: String!): Items
|
||||||
item(id: ID!): Item
|
item(id: ID!): Item
|
||||||
comments(id: ID!, sort: String): [Item!]!
|
comments(id: ID!, sort: String): [Item!]!
|
||||||
pageTitleAndUnshorted(url: String!): TitleUnshorted
|
pageTitleAndUnshorted(url: String!): TitleUnshorted
|
||||||
@ -32,6 +33,7 @@ export default gql`
|
|||||||
}
|
}
|
||||||
|
|
||||||
extend type Mutation {
|
extend type Mutation {
|
||||||
|
bookmarkItem(id: ID): Item
|
||||||
deleteItem(id: ID): Item
|
deleteItem(id: ID): Item
|
||||||
upsertLink(id: ID, title: String!, url: String!, boost: Int, forward: String): 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!
|
upsertDiscussion(id: ID, title: String!, text: String, boost: Int, forward: String): Item!
|
||||||
@ -99,6 +101,7 @@ export default gql`
|
|||||||
wvotes: Float!
|
wvotes: Float!
|
||||||
meSats: Int!
|
meSats: Int!
|
||||||
meDontLike: Boolean!
|
meDontLike: Boolean!
|
||||||
|
meBookmark: Boolean!
|
||||||
outlawed: Boolean!
|
outlawed: Boolean!
|
||||||
freebie: Boolean!
|
freebie: Boolean!
|
||||||
paidImgLink: Boolean
|
paidImgLink: Boolean
|
||||||
|
@ -45,6 +45,7 @@ export default gql`
|
|||||||
name: String
|
name: String
|
||||||
nitems(when: String): Int!
|
nitems(when: String): Int!
|
||||||
ncomments(when: String): Int!
|
ncomments(when: String): Int!
|
||||||
|
nbookmarks(when: String): Int!
|
||||||
stacked(when: String): Int!
|
stacked(when: String): Int!
|
||||||
spent(when: String): Int!
|
spent(when: String): Int!
|
||||||
referrals(when: String): Int!
|
referrals(when: String): Int!
|
||||||
|
30
components/bookmark.js
Normal file
30
components/bookmark.js
Normal file
@ -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 Link from 'next/link'
|
||||||
import Reply, { ReplyOnAnotherPage } from './reply'
|
import Reply, { ReplyOnAnotherPage } from './reply'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { timeSince } from '../lib/time'
|
|
||||||
import UpVote from './upvote'
|
import UpVote from './upvote'
|
||||||
import Eye from '../svgs/eye-fill.svg'
|
import Eye from '../svgs/eye-fill.svg'
|
||||||
import EyeClose from '../svgs/eye-close-line.svg'
|
import EyeClose from '../svgs/eye-close-line.svg'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import CommentEdit from './comment-edit'
|
import CommentEdit from './comment-edit'
|
||||||
import Countdown from './countdown'
|
|
||||||
import { COMMENT_DEPTH_LIMIT, NOFOLLOW_LIMIT } from '../lib/constants'
|
import { COMMENT_DEPTH_LIMIT, NOFOLLOW_LIMIT } from '../lib/constants'
|
||||||
import { ignoreClick } from '../lib/clicks'
|
import { ignoreClick } from '../lib/clicks'
|
||||||
import PayBounty from './pay-bounty'
|
import PayBounty from './pay-bounty'
|
||||||
import BountyIcon from '../svgs/bounty-bag.svg'
|
import BountyIcon from '../svgs/bounty-bag.svg'
|
||||||
import ActionTooltip from './action-tooltip'
|
import ActionTooltip from './action-tooltip'
|
||||||
import { useMe } from './me'
|
|
||||||
import DontLikeThis from './dont-link-this'
|
|
||||||
import Flag from '../svgs/flag-fill.svg'
|
import Flag from '../svgs/flag-fill.svg'
|
||||||
import { Badge } from 'react-bootstrap'
|
|
||||||
import { abbrNum } from '../lib/format'
|
import { abbrNum } from '../lib/format'
|
||||||
import Share from './share'
|
import Share from './share'
|
||||||
import { DeleteDropdown } from './delete'
|
import ItemInfo from './item-info'
|
||||||
import CowboyHat from './cowboy-hat'
|
|
||||||
|
|
||||||
function Parent ({ item, rootText }) {
|
function Parent ({ item, rootText }) {
|
||||||
const ParentFrag = () => (
|
const ParentFrag = () => (
|
||||||
@ -89,12 +83,7 @@ export default function Comment ({
|
|||||||
const [edit, setEdit] = useState()
|
const [edit, setEdit] = useState()
|
||||||
const [collapse, setCollapse] = useState(false)
|
const [collapse, setCollapse] = useState(false)
|
||||||
const ref = useRef(null)
|
const ref = useRef(null)
|
||||||
const me = useMe()
|
|
||||||
const router = useRouter()
|
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(() => {
|
useEffect(() => {
|
||||||
if (Number(router.query.commentId) === Number(item.id)) {
|
if (Number(router.query.commentId) === Number(item.id)) {
|
||||||
@ -123,56 +112,23 @@ export default function Comment ({
|
|||||||
: <UpVote item={item} className={styles.upvote} />}
|
: <UpVote item={item} className={styles.upvote} />}
|
||||||
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
|
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
|
||||||
<div className='d-flex align-items-center'>
|
<div className='d-flex align-items-center'>
|
||||||
<div className={`${itemStyles.other} ${styles.other}`}>
|
<ItemInfo
|
||||||
<span title={`from ${item.upvotes} users ${item.mine ? `\\ ${item.meSats} sats to post` : `(${item.meSats} sats from me)`}`}>{abbrNum(item.sats)} sats</span>
|
item={item}
|
||||||
<span> \ </span>
|
commentsText='replies'
|
||||||
{item.boost > 0 &&
|
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>
|
{includeParent && <Parent item={item} rootText={rootText} />}
|
||||||
<span> \ </span>
|
{bountyPaid &&
|
||||||
</>}
|
<ActionTooltip notForm overlayText={`${abbrNum(item.root.bounty)} sats paid`}>
|
||||||
<Link href={`/items/${item.id}`} passHref>
|
<BountyIcon className={`${styles.bountyIcon} ${'fill-success vertical-align-middle'}`} height={16} width={16} />
|
||||||
<a title={`${item.commentSats} sats`} className='text-reset'>{item.ncomments} replies</a>
|
</ActionTooltip>}
|
||||||
</Link>
|
</>
|
||||||
<span> \ </span>
|
}
|
||||||
<Link href={`/${item.user.name}`} passHref>
|
onEdit={e => { setEdit(!edit) }}
|
||||||
<a className='d-inline-flex align-items-center'>
|
editText={edit ? 'cancel' : 'edit'}
|
||||||
@{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 && (collapse
|
{!includeParent && (collapse
|
||||||
? <Eye
|
? <Eye
|
||||||
className={styles.collapser} height={10} width={10} onClick={() => {
|
className={styles.collapser} height={10} width={10} onClick={() => {
|
||||||
@ -186,7 +142,11 @@ export default function Comment ({
|
|||||||
localStorage.setItem(`commentCollapse:${item.id}`, 'yep')
|
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>
|
</div>
|
||||||
{edit
|
{edit
|
||||||
? (
|
? (
|
||||||
@ -194,7 +154,6 @@ export default function Comment ({
|
|||||||
comment={item}
|
comment={item}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setEdit(!edit)
|
setEdit(!edit)
|
||||||
setCanEdit(mine && (Date.now() < editThreshold))
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
@ -3,7 +3,6 @@ import { gql } from 'apollo-server-micro'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Alert, Button, Dropdown } from 'react-bootstrap'
|
import { Alert, Button, Dropdown } from 'react-bootstrap'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import MoreIcon from '../svgs/more-fill.svg'
|
|
||||||
|
|
||||||
export default function Delete ({ itemId, children, onDelete }) {
|
export default function Delete ({ itemId, children, onDelete }) {
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
@ -81,22 +80,12 @@ function DeleteConfirm ({ onConfirm }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeleteDropdown (props) {
|
export function DeleteDropdownItem (props) {
|
||||||
return (
|
return (
|
||||||
<Dropdown className='pointer' as='span'>
|
<Delete {...props}>
|
||||||
<Dropdown.Toggle variant='success' id='dropdown-basic' as='a'>
|
<Dropdown.Item>
|
||||||
<MoreIcon className='fill-grey ml-1' height={16} width={16} />
|
delete
|
||||||
</Dropdown.Toggle>
|
</Dropdown.Item>
|
||||||
|
</Delete>
|
||||||
<Dropdown.Menu>
|
|
||||||
<Delete {...props}>
|
|
||||||
<Dropdown.Item
|
|
||||||
className='text-center'
|
|
||||||
>
|
|
||||||
delete
|
|
||||||
</Dropdown.Item>
|
|
||||||
</Delete>
|
|
||||||
</Dropdown.Menu>
|
|
||||||
</Dropdown>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { gql, useMutation } from '@apollo/client'
|
import { gql, useMutation } from '@apollo/client'
|
||||||
import { Dropdown } from 'react-bootstrap'
|
import { Dropdown } from 'react-bootstrap'
|
||||||
import MoreIcon from '../svgs/more-fill.svg'
|
|
||||||
import FundError from './fund-error'
|
import FundError from './fund-error'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
|
|
||||||
export default function DontLikeThis ({ id }) {
|
export default function DontLikeThisDropdownItem ({ id }) {
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
|
|
||||||
const [dontLikeThis] = useMutation(
|
const [dontLikeThis] = useMutation(
|
||||||
@ -26,32 +25,23 @@ export default function DontLikeThis ({ id }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown className='pointer' as='span'>
|
<Dropdown.Item
|
||||||
<Dropdown.Toggle variant='success' id='dropdown-basic' as='a'>
|
onClick={async () => {
|
||||||
<MoreIcon className='fill-grey ml-1' height={16} width={16} />
|
try {
|
||||||
</Dropdown.Toggle>
|
await dontLikeThis({
|
||||||
|
variables: { id },
|
||||||
<Dropdown.Menu>
|
optimisticResponse: { dontLikeThis: true }
|
||||||
<Dropdown.Item
|
})
|
||||||
className='text-center'
|
} catch (error) {
|
||||||
onClick={async () => {
|
if (error.toString().includes('insufficient funds')) {
|
||||||
try {
|
showModal(onClose => {
|
||||||
await dontLikeThis({
|
return <FundError onClose={onClose} />
|
||||||
variables: { id },
|
})
|
||||||
optimisticResponse: { dontLikeThis: true }
|
}
|
||||||
})
|
}
|
||||||
} catch (error) {
|
}}
|
||||||
if (error.toString().includes('insufficient funds')) {
|
>
|
||||||
showModal(onClose => {
|
flag
|
||||||
return <FundError onClose={onClose} />
|
</Dropdown.Item>
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
flag
|
|
||||||
</Dropdown.Item>
|
|
||||||
</Dropdown.Menu>
|
|
||||||
</Dropdown>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -31,6 +31,7 @@ export default function Header ({ sub }) {
|
|||||||
const prefix = sub ? `/~${sub}` : ''
|
const prefix = sub ? `/~${sub}` : ''
|
||||||
// there's always at least 2 on the split, e.g. '/' yields ['','']
|
// there's always at least 2 on the split, e.g. '/' yields ['','']
|
||||||
const topNavKey = path.split('/')[sub ? 2 : 1]
|
const topNavKey = path.split('/')[sub ? 2 : 1]
|
||||||
|
const dropNavKey = path.split('/').slice(sub ? 2 : 1).join('/')
|
||||||
const { data: subLatestPost } = useQuery(gql`
|
const { data: subLatestPost } = useQuery(gql`
|
||||||
query subLatestPost($name: ID!) {
|
query subLatestPost($name: ID!) {
|
||||||
subLatestPost(name: $name)
|
subLatestPost(name: $name)
|
||||||
@ -80,7 +81,7 @@ export default function Header ({ sub }) {
|
|||||||
} alignRight
|
} alignRight
|
||||||
>
|
>
|
||||||
<Link href={'/' + me?.name} passHref>
|
<Link href={'/' + me?.name} passHref>
|
||||||
<NavDropdown.Item eventKey={me?.name}>
|
<NavDropdown.Item active={me?.name === dropNavKey}>
|
||||||
profile
|
profile
|
||||||
{me && !me.bioId &&
|
{me && !me.bioId &&
|
||||||
<div className='p-1 d-inline-block bg-secondary ml-1'>
|
<div className='p-1 d-inline-block bg-secondary ml-1'>
|
||||||
@ -88,6 +89,9 @@ export default function Header ({ sub }) {
|
|||||||
</div>}
|
</div>}
|
||||||
</NavDropdown.Item>
|
</NavDropdown.Item>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href={'/' + me?.name + '/bookmarks'} passHref>
|
||||||
|
<NavDropdown.Item active={me?.name + '/bookmarks' === dropNavKey}>bookmarks</NavDropdown.Item>
|
||||||
|
</Link>
|
||||||
<Link href='/wallet' passHref>
|
<Link href='/wallet' passHref>
|
||||||
<NavDropdown.Item eventKey='wallet'>wallet</NavDropdown.Item>
|
<NavDropdown.Item eventKey='wallet'>wallet</NavDropdown.Item>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -17,6 +17,9 @@ import { commentsViewed } from '../lib/new-comments'
|
|||||||
import Related from './related'
|
import Related from './related'
|
||||||
import PastBounties from './past-bounties'
|
import PastBounties from './past-bounties'
|
||||||
import Check from '../svgs/check-double-line.svg'
|
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 }) {
|
function BioItem ({ item, handleClick }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
@ -91,11 +94,32 @@ function ItemEmbed ({ item }) {
|
|||||||
return null
|
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 }) {
|
function TopLevelItem ({ item, noReply, ...props }) {
|
||||||
const ItemComponent = item.isJob ? ItemJob : Item
|
const ItemComponent = item.isJob ? ItemJob : Item
|
||||||
|
|
||||||
return (
|
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}>
|
<div className={styles.fullItemContainer}>
|
||||||
{item.text && <ItemText item={item} />}
|
{item.text && <ItemText item={item} />}
|
||||||
{item.url && <ItemEmbed item={item} />}
|
{item.url && <ItemEmbed item={item} />}
|
||||||
|
116
components/item-info.js
Normal file
116
components/item-info.js
Normal file
@ -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 Link from 'next/link'
|
||||||
import styles from './item.module.css'
|
import styles from './item.module.css'
|
||||||
import { timeSince } from '../lib/time'
|
|
||||||
import UpVote from './upvote'
|
import UpVote from './upvote'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import Countdown from './countdown'
|
|
||||||
import { NOFOLLOW_LIMIT } from '../lib/constants'
|
import { NOFOLLOW_LIMIT } from '../lib/constants'
|
||||||
import Pin from '../svgs/pushpin-fill.svg'
|
import Pin from '../svgs/pushpin-fill.svg'
|
||||||
import reactStringReplace from 'react-string-replace'
|
import reactStringReplace from 'react-string-replace'
|
||||||
import Toc from './table-of-contents'
|
|
||||||
import PollIcon from '../svgs/bar-chart-horizontal-fill.svg'
|
import PollIcon from '../svgs/bar-chart-horizontal-fill.svg'
|
||||||
import BountyIcon from '../svgs/bounty-bag.svg'
|
import BountyIcon from '../svgs/bounty-bag.svg'
|
||||||
import ActionTooltip from './action-tooltip'
|
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 Flag from '../svgs/flag-fill.svg'
|
||||||
import Share from './share'
|
|
||||||
import { abbrNum } from '../lib/format'
|
import { abbrNum } from '../lib/format'
|
||||||
import { DeleteDropdown } from './delete'
|
import ItemInfo from './item-info'
|
||||||
import CowboyHat from './cowboy-hat'
|
|
||||||
|
|
||||||
export function SearchTitle ({ title }) {
|
export function SearchTitle ({ title }) {
|
||||||
return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => {
|
return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => {
|
||||||
@ -27,26 +18,9 @@ export function SearchTitle ({ title }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function FwdUser ({ user }) {
|
export default function Item ({ item, rank, belowTitle, right, children }) {
|
||||||
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))
|
|
||||||
const [wrap, setWrap] = useState(false)
|
const [wrap, setWrap] = useState(false)
|
||||||
const titleRef = useRef()
|
const titleRef = useRef()
|
||||||
const me = useMe()
|
|
||||||
const [hasNewComments, setHasNewComments] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setWrap(
|
setWrap(
|
||||||
@ -54,11 +28,6 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
|
|||||||
titleRef.current.clientHeight)
|
titleRef.current.clientHeight)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// if we are showing toc, then this is a full item
|
|
||||||
setHasNewComments(!toc && newComments(item))
|
|
||||||
}, [item])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{rank
|
{rank
|
||||||
@ -96,69 +65,10 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
|
|||||||
</a>
|
</a>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
<div className={`${styles.other}`}>
|
<ItemInfo item={item} />
|
||||||
{!item.position &&
|
{belowTitle}
|
||||||
<>
|
|
||||||
<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} />}
|
|
||||||
</div>
|
</div>
|
||||||
{toc &&
|
{right}
|
||||||
<>
|
|
||||||
<Share item={item} />
|
|
||||||
<Toc text={item.text} />
|
|
||||||
</>}
|
|
||||||
</div>
|
</div>
|
||||||
{children && (
|
{children && (
|
||||||
<div className={styles.children}>
|
<div className={styles.children}>
|
||||||
|
@ -34,7 +34,6 @@ export default function Share ({ item }) {
|
|||||||
|
|
||||||
<Dropdown.Menu>
|
<Dropdown.Menu>
|
||||||
<Dropdown.Item
|
<Dropdown.Item
|
||||||
className='text-center'
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
copy(url)
|
copy(url)
|
||||||
}}
|
}}
|
||||||
@ -44,3 +43,26 @@ export default function Share ({ item }) {
|
|||||||
</Dropdown.Menu>
|
</Dropdown.Menu>
|
||||||
</Dropdown>)
|
</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>
|
<Nav.Link>{user.ncomments} comments</Nav.Link>
|
||||||
</Link>
|
</Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
{isMe &&
|
<Nav.Item>
|
||||||
<Nav.Item>
|
<Link href={'/' + user.name + '/bookmarks'} passHref>
|
||||||
<Link href='/satistics?inc=invoice,withdrawal' passHref>
|
<Nav.Link>{user.nbookmarks} bookmarks</Nav.Link>
|
||||||
<Nav.Link eventKey='/satistics'>satistics</Nav.Link>
|
</Link>
|
||||||
</Link>
|
</Nav.Item>
|
||||||
</Nav.Item>}
|
|
||||||
</Nav>
|
</Nav>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -17,6 +17,7 @@ export const COMMENT_FIELDS = gql`
|
|||||||
boost
|
boost
|
||||||
meSats
|
meSats
|
||||||
meDontLike
|
meDontLike
|
||||||
|
meBookmark
|
||||||
outlawed
|
outlawed
|
||||||
freebie
|
freebie
|
||||||
path
|
path
|
||||||
|
@ -24,6 +24,7 @@ export const ITEM_FIELDS = gql`
|
|||||||
path
|
path
|
||||||
meSats
|
meSats
|
||||||
meDontLike
|
meDontLike
|
||||||
|
meBookmark
|
||||||
outlawed
|
outlawed
|
||||||
freebie
|
freebie
|
||||||
ncomments
|
ncomments
|
||||||
@ -218,8 +219,8 @@ export const ITEM_WITH_COMMENTS = gql`
|
|||||||
|
|
||||||
export const BOUNTY_ITEMS_BY_USER_NAME = gql`
|
export const BOUNTY_ITEMS_BY_USER_NAME = gql`
|
||||||
${ITEM_FIELDS}
|
${ITEM_FIELDS}
|
||||||
query getBountiesByUserName($name: String!) {
|
query getBountiesByUserName($name: String!, $cursor: String, $limit: Int) {
|
||||||
getBountiesByUserName(name: $name) {
|
getBountiesByUserName(name: $name, cursor: $cursor, limit: $limit) {
|
||||||
cursor
|
cursor
|
||||||
items {
|
items {
|
||||||
...ItemFields
|
...ItemFields
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { gql } from '@apollo/client'
|
import { gql } from '@apollo/client'
|
||||||
import { COMMENT_FIELDS } from './comments'
|
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`
|
export const ME = gql`
|
||||||
{
|
{
|
||||||
@ -124,6 +124,7 @@ export const USER_FIELDS = gql`
|
|||||||
streak
|
streak
|
||||||
nitems
|
nitems
|
||||||
ncomments
|
ncomments
|
||||||
|
nbookmarks
|
||||||
stacked
|
stacked
|
||||||
sats
|
sats
|
||||||
photoId
|
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`
|
export const USER_WITH_POSTS = gql`
|
||||||
${USER_FIELDS}
|
${USER_FIELDS}
|
||||||
${ITEM_FIELDS}
|
${ITEM_FIELDS}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { gql } from '@apollo/client'
|
import { gql } from '@apollo/client'
|
||||||
import { ITEM_FULL_FIELDS } from './items'
|
import { ITEM_FULL_FIELDS } from './items'
|
||||||
import { USER_FIELDS } from './users'
|
|
||||||
|
|
||||||
export const INVOICE = gql`
|
export const INVOICE = gql`
|
||||||
query Invoice($id: ID!) {
|
query Invoice($id: ID!) {
|
||||||
@ -28,12 +27,8 @@ export const WITHDRAWL = gql`
|
|||||||
|
|
||||||
export const WALLET_HISTORY = gql`
|
export const WALLET_HISTORY = gql`
|
||||||
${ITEM_FULL_FIELDS}
|
${ITEM_FULL_FIELDS}
|
||||||
${USER_FIELDS}
|
|
||||||
|
|
||||||
query WalletHistory($cursor: String, $inc: String) {
|
query WalletHistory($cursor: String, $inc: String) {
|
||||||
me {
|
|
||||||
...UserFields
|
|
||||||
}
|
|
||||||
walletHistory(cursor: $cursor, inc: $inc) {
|
walletHistory(cursor: $cursor, inc: $inc) {
|
||||||
facts {
|
facts {
|
||||||
id
|
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: {
|
notifications: {
|
||||||
keyArgs: ['inc'],
|
keyArgs: ['inc'],
|
||||||
merge (existing, incoming) {
|
merge (existing, incoming) {
|
||||||
|
33
pages/[name]/bookmarks.js
Normal file
33
pages/[name]/bookmarks.js
Normal file
@ -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 { getGetServerSideProps } from '../api/ssrApollo'
|
||||||
import Layout from '../components/layout'
|
import Layout from '../components/layout'
|
||||||
import MoreFooter from '../components/more-footer'
|
import MoreFooter from '../components/more-footer'
|
||||||
import UserHeader from '../components/user-header'
|
|
||||||
import { WALLET_HISTORY } from '../fragments/wallet'
|
import { WALLET_HISTORY } from '../fragments/wallet'
|
||||||
import styles from '../styles/satistics.module.css'
|
import styles from '../styles/satistics.module.css'
|
||||||
import Moon from '../svgs/moon-fill.svg'
|
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>
|
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 router = useRouter()
|
||||||
const { data, fetchMore } = useQuery(WALLET_HISTORY, { variables: { inc: router.query.inc } })
|
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) {
|
if (data) {
|
||||||
({ me, walletHistory: { facts, cursor } } = data)
|
({ walletHistory: { facts, cursor } } = data)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SatisticsSkeleton = () => (
|
const SatisticsSkeleton = () => (
|
||||||
@ -185,9 +184,9 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
|
|||||||
</div>)
|
</div>)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout noSeo>
|
<Layout>
|
||||||
<UserHeader user={me} />
|
|
||||||
<div className='mt-3'>
|
<div className='mt-3'>
|
||||||
|
<h2 className='text-center'>satistics</h2>
|
||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{
|
||||||
invoice: included('invoice'),
|
invoice: included('invoice'),
|
||||||
|
14
prisma/migrations/20230212001152_bookmarks/migration.sql
Normal file
14
prisma/migrations/20230212001152_bookmarks/migration.sql
Normal file
@ -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[]
|
Donation Donation[]
|
||||||
ReferralAct ReferralAct[]
|
ReferralAct ReferralAct[]
|
||||||
Streak Streak[]
|
Streak Streak[]
|
||||||
|
Bookmarks Bookmark[]
|
||||||
|
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([inviteId])
|
@@index([inviteId])
|
||||||
@ -297,6 +298,7 @@ model Item {
|
|||||||
User User[]
|
User User[]
|
||||||
PollOption PollOption[]
|
PollOption PollOption[]
|
||||||
PollVote PollVote[]
|
PollVote PollVote[]
|
||||||
|
Bookmark Bookmark[]
|
||||||
|
|
||||||
@@index([weightedVotes])
|
@@index([weightedVotes])
|
||||||
@@index([weightedDownVotes])
|
@@index([weightedDownVotes])
|
||||||
@ -523,3 +525,13 @@ model VerificationRequest {
|
|||||||
|
|
||||||
@@map(name: "verification_requests")
|
@@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;
|
margin-top: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.other {
|
||||||
|
font-size: 80%;
|
||||||
|
color: var(--theme-grey);
|
||||||
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 600px) {
|
@media only screen and (max-width: 600px) {
|
||||||
.comments {
|
.comments {
|
||||||
margin-top: .5rem;
|
margin-top: .5rem;
|
||||||
|
1
svgs/bookmark.svg
Normal file
1
svgs/bookmark.svg
Normal file
@ -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…
x
Reference in New Issue
Block a user