Implement bookmarking of posts and comments (#235)

This commit is contained in:
ekzyis 2023-02-16 23:23:59 +01:00 committed by GitHub
parent 30cde2ea38
commit 7b838cdeb2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 422 additions and 226 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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!

30
components/bookmark.js Normal file
View 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>
)
}

View File

@ -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))
}}
/>
)

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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} />}

116
components/item-info.js Normal file
View 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>
)
}

View File

@ -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}>

View File

@ -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>
)
}

View File

@ -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>
</>
)

View File

@ -17,6 +17,7 @@ export const COMMENT_FIELDS = gql`
boost
meSats
meDontLike
meBookmark
outlawed
freebie
path

View File

@ -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

View File

@ -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}

View File

@ -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

View File

@ -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) {

33
pages/[name]/bookmarks.js Normal file
View 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>
)
}

View File

@ -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'),

View 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;

View File

@ -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])
}

View File

@ -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;

1
svgs/bookmark.svg Normal file
View 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