diff --git a/api/resolvers/item.js b/api/resolvers/item.js index f8a43b47..c1839575 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -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 diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 4f690248..a30dad13 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -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 diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 4a4d5c53..23ee493a 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -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 diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index a41f67d6..b5172a49 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -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! diff --git a/components/bookmark.js b/components/bookmark.js new file mode 100644 index 00000000..dc689ccc --- /dev/null +++ b/components/bookmark.js @@ -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 ( + bookmarkItem({ variables: { id } })} + > + {meBookmark ? 'remove bookmark' : 'bookmark'} + + ) +} diff --git a/components/comment.js b/components/comment.js index 222c6993..54c2a04e 100644 --- a/components/comment.js +++ b/components/comment.js @@ -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 ({ : }
-
- {abbrNum(item.sats)} sats - \ - {item.boost > 0 && + OP} + extraInfo={ <> - {abbrNum(item.boost)} boost - \ - } - - {item.ncomments} replies - - \ - - - @{item.user.name} - {op && OP} - - - - - {timeSince(new Date(item.createdAt))} - - {includeParent && } - {bountyPaid && - - - } - {me && !item.meSats && !item.meDontLike && !item.mine && !item.deletedAt && } - {(item.outlawed && {' '}OUTLAWED) || - (item.freebie && !item.mine && (me?.greeterMode) && {' '}FREEBIE)} - {canEdit && !item.deletedAt && - <> - \ -
{ - setEdit(!edit) - }} - > - {edit ? 'cancel' : 'edit'} - { - setCanEdit(false) - }} - /> -
- } - {mine && !canEdit && !item.deletedAt && } -
+ {includeParent && } + {bountyPaid && + + + } + + } + onEdit={e => { setEdit(!edit) }} + editText={edit ? 'cancel' : 'edit'} + /> {!includeParent && (collapse ? { @@ -186,7 +142,11 @@ export default function Comment ({ localStorage.setItem(`commentCollapse:${item.id}`, 'yep') }} />)} - {topLevel && } + {topLevel && ( + + + + )}
{edit ? ( @@ -194,7 +154,6 @@ export default function Comment ({ comment={item} onSuccess={() => { setEdit(!edit) - setCanEdit(mine && (Date.now() < editThreshold)) }} /> ) diff --git a/components/delete.js b/components/delete.js index 80376a5a..bdb5e123 100644 --- a/components/delete.js +++ b/components/delete.js @@ -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 ( - - - - - - - - - delete - - - - + + + delete + + ) } diff --git a/components/dont-link-this.js b/components/dont-link-this.js index 65da4cde..a40e73ea 100644 --- a/components/dont-link-this.js +++ b/components/dont-link-this.js @@ -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 ( - - - - - - - { - try { - await dontLikeThis({ - variables: { id }, - optimisticResponse: { dontLikeThis: true } - }) - } catch (error) { - if (error.toString().includes('insufficient funds')) { - showModal(onClose => { - return - }) - } - } - }} - > - flag - - - + { + try { + await dontLikeThis({ + variables: { id }, + optimisticResponse: { dontLikeThis: true } + }) + } catch (error) { + if (error.toString().includes('insufficient funds')) { + showModal(onClose => { + return + }) + } + } + }} + > + flag + ) } diff --git a/components/header.js b/components/header.js index 1d80b100..a80d772a 100644 --- a/components/header.js +++ b/components/header.js @@ -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 > - + profile {me && !me.bioId &&
@@ -88,6 +89,9 @@ export default function Header ({ sub }) {
}
+ + bookmarks + wallet diff --git a/components/item-full.js b/components/item-full.js index 4318cddf..c5e7704c 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -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 ( +
+ 100% of tips are forwarded to{' '} + + @{user.name} + +
+ ) +} + function TopLevelItem ({ item, noReply, ...props }) { const ItemComponent = item.isJob ? ItemJob : Item return ( - + + + + + } + belowTitle={item.fwdUser && } + {...props} + >
{item.text && } {item.url && } diff --git a/components/item-info.js b/components/item-info.js new file mode 100644 index 00000000..7d724986 --- /dev/null +++ b/components/item-info.js @@ -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 ( +
+ {!item.position && + <> + {abbrNum(item.sats)} sats + \ + } + {item.boost > 0 && + <> + {abbrNum(item.boost)} boost + \ + } + + + {item.ncomments} {commentsText || 'comments'} + {hasNewComments && <>{' '}new} + + + \ + + + + @{item.user.name} + {embellishUser} + + + + + {timeSince(new Date(item.createdAt))} + + {item.prior && + <> + \ + + yesterday + + } + + {(item.outlawed && !item.mine && + + {' '}OUTLAWED + ) || (item.freebie && !item.mine && + + {' '}FREEBIE + + )} + {canEdit && !item.deletedAt && + <> + \ + onEdit ? onEdit() : router.push(`/items/${item.id}/edit`)} + > + {editText || 'edit'} + { + setCanEdit(false) + }} + /> + + } + {extraInfo} + + + + {me && !item.meSats && !item.position && !item.meDontLike && + !item.mine && !item.deletedAt && } + {item.mine && !item.position && !item.deletedAt && + } + +
+ ) +} + +export function ItemDropdown ({ children }) { + return ( + + + + + + {children} + + + ) +} diff --git a/components/item.js b/components/item.js index d7a99f9b..50641dae 100644 --- a/components/item.js +++ b/components/item.js @@ -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 ( -
- 100% of tips are forwarded to{' '} - - @{user.name} - -
- ) -} - -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 }) { }
-
- {!item.position && - <> - {abbrNum(item.sats)} sats - \ - } - {item.boost > 0 && - <> - {abbrNum(item.boost)} boost - \ - } - - - {item.ncomments} comments - {hasNewComments && <>{' '}new} - - - \ - - - @{item.user.name} - - - - {timeSince(new Date(item.createdAt))} - - {me && !item.meSats && !item.position && !item.meDontLike && !item.mine && !item.deletedAt && } - {(item.outlawed && {' '}OUTLAWED) || - (item.freebie && !item.mine && (me?.greeterMode) && {' '}FREEBIE)} - {item.prior && - <> - \ - - yesterday - - } - - {canEdit && !item.deletedAt && - <> - \ - - - edit - { - setCanEdit(false) - }} - /> - - - } - {mine && !canEdit && !item.position && !item.deletedAt && - } -
- {showFwdUser && item.fwdUser && } + + {belowTitle}
- {toc && - <> - - - } + {right} {children && (
diff --git a/components/share.js b/components/share.js index 846870f6..73bf3b35 100644 --- a/components/share.js +++ b/components/share.js @@ -34,7 +34,6 @@ export default function Share ({ item }) { { copy(url) }} @@ -44,3 +43,26 @@ export default function Share ({ item }) { ) } + +export function CopyLinkDropdownItem ({ item }) { + const me = useMe() + const url = `https://stacker.news/items/${item.id}${me ? `/r/${me.name}` : ''}` + return ( + { + 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 + + ) +} diff --git a/components/user-header.js b/components/user-header.js index 4273e726..9ad1436b 100644 --- a/components/user-header.js +++ b/components/user-header.js @@ -161,12 +161,11 @@ export default function UserHeader ({ user }) { {user.ncomments} comments - {isMe && - - - satistics - - } + + + {user.nbookmarks} bookmarks + + ) diff --git a/fragments/comments.js b/fragments/comments.js index d598ed7f..43c5de06 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -17,6 +17,7 @@ export const COMMENT_FIELDS = gql` boost meSats meDontLike + meBookmark outlawed freebie path diff --git a/fragments/items.js b/fragments/items.js index 51b0f607..7d06f22e 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -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 diff --git a/fragments/users.js b/fragments/users.js index 3e89bd03..5d56598b 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -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} diff --git a/fragments/wallet.js b/fragments/wallet.js index c9d29066..62f55d1c 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -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 diff --git a/lib/apollo.js b/lib/apollo.js index 463fb94b..07556363 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -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) { diff --git a/pages/[name]/bookmarks.js b/pages/[name]/bookmarks.js new file mode 100644 index 00000000..65f05c50 --- /dev/null +++ b/pages/[name]/bookmarks.js @@ -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 ( + + + +
+ data.moreBookmarks} + variables={{ name: user.name }} + /> +
+
+ ) +} diff --git a/pages/satistics.js b/pages/satistics.js index b1125386..2ac37427 100644 --- a/pages/satistics.js +++ b/pages/satistics.js @@ -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
} -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
) return ( - - +
+

satistics

\ No newline at end of file