Skip to content

Commit af658c8

Browse files
committed
feat(Bookmarks): Custom bookmark ordering
1 parent f0e3516 commit af658c8

File tree

8 files changed

+184
-9
lines changed

8 files changed

+184
-9
lines changed

api/resolvers/item.js

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ const orderByClause = (by, me, models, type, sub) => {
150150
return 'ORDER BY "Item".boost DESC'
151151
case 'random':
152152
return 'ORDER BY RANDOM()'
153+
case 'custom':
154+
if (type === 'bookmarks') return 'ORDER BY "Bookmark"."custom_order" ASC NULLS LAST, "Bookmark"."created_at" DESC'
155+
break
153156
default:
154157
return `ORDER BY ${type === 'bookmarks' ? '"bookmarkCreatedAt"' : '"Item".created_at'} DESC`
155158
}
@@ -235,7 +238,7 @@ const relationClause = (type) => {
235238
}
236239

237240
const selectClause = (type) => type === 'bookmarks'
238-
? `${SELECT}, "Bookmark"."created_at" as "bookmarkCreatedAt"`
241+
? `${SELECT}, "Bookmark"."created_at" as "bookmarkCreatedAt", "Bookmark"."custom_order" as "bookmarkCustomOrder"`
239242
: SELECT
240243

241244
const subClauseTable = (type) => COMMENT_TYPE_QUERY.includes(type) ? 'root' : 'Item'
@@ -421,7 +424,9 @@ export default {
421424
${orderByClause(by, me, models, type)}
422425
OFFSET $4
423426
LIMIT $5`,
424-
orderBy: orderByClause(by, me, models, type)
427+
orderBy: (type === 'bookmarks' && by === 'custom')
428+
? 'ORDER BY "Item"."bookmarkCustomOrder" ASC NULLS LAST, "Item"."bookmarkCreatedAt" DESC'
429+
: orderByClause(by, me, models, type)
425430
}, ...whenRange(when, from, to || decodedCursor.time), user.id, decodedCursor.offset, limit)
426431
break
427432
case 'recent':
@@ -772,6 +777,30 @@ export default {
772777
} else await models.bookmark.create({ data })
773778
return { id }
774779
},
780+
reorderBookmarks: async (parent, { itemIds }, { me, models }) => {
781+
if (!me) {
782+
throw new GqlAuthenticationError()
783+
}
784+
785+
if (!itemIds || itemIds.length === 0) {
786+
throw new GqlInputError('itemIds required')
787+
}
788+
789+
for (let i = 0; i < itemIds.length; i++) {
790+
const itemId = Number(itemIds[i])
791+
await models.bookmark.updateMany({
792+
where: {
793+
userId: me.id,
794+
itemId
795+
},
796+
data: {
797+
customOrder: i + 1
798+
}
799+
})
800+
}
801+
802+
return true
803+
},
775804
pinItem: async (parent, { id }, { me, models }) => {
776805
if (!me) {
777806
throw new GqlAuthenticationError()

api/typeDefs/item.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export default gql`
4242
4343
extend type Mutation {
4444
bookmarkItem(id: ID): Item
45+
reorderBookmarks(itemIds: [ID!]!): Boolean
4546
pinItem(id: ID): Item
4647
subscribeItem(id: ID): Item
4748
deleteItem(id: ID): Item

components/bookmark.js

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1-
import { useMutation } from '@apollo/client'
1+
import { useMutation, useQuery } from '@apollo/client'
22
import { gql } from 'graphql-tag'
33
import Dropdown from 'react-bootstrap/Dropdown'
44
import { useToast } from './toast'
5+
import { useCallback, useMemo, useEffect, useState } from 'react'
6+
import { DndProvider, useDndHandlers } from '@/wallets/client/context/dnd'
7+
import { ListItem, ItemsSkeleton } from './items'
8+
import MoreFooter from './more-footer'
9+
import { useData } from './use-data'
10+
import { SUB_ITEMS } from '@/fragments/subs'
11+
import styles from './items.module.css'
12+
import bookmarkStyles from '@/styles/bookmark.module.css'
13+
import DragIcon from '@/svgs/draggable.svg'
514

615
export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) {
716
const toaster = useToast()
@@ -39,3 +48,85 @@ export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) {
3948
</Dropdown.Item>
4049
)
4150
}
51+
52+
const REORDER_BOOKMARKS = gql`
53+
mutation reorderBookmarks($itemIds: [ID!]!) {
54+
reorderBookmarks(itemIds: $itemIds)
55+
}
56+
`
57+
58+
function DraggableBookmarkItem ({ item, index, children, ...props }) {
59+
const handlers = useDndHandlers(index)
60+
return (
61+
<div
62+
draggable
63+
onDragStart={handlers.handleDragStart}
64+
onDragOver={handlers.handleDragOver}
65+
onDragEnter={handlers.handleDragEnter}
66+
onDragLeave={handlers.handleDragLeave}
67+
onDrop={handlers.handleDrop}
68+
onDragEnd={handlers.handleDragEnd}
69+
onTouchStart={handlers.handleTouchStart}
70+
onTouchMove={handlers.handleTouchMove}
71+
onTouchEnd={handlers.handleTouchEnd}
72+
data-index={index}
73+
className={`${bookmarkStyles.draggableBookmark} ${handlers.isBeingDragged ? bookmarkStyles.dragging : ''} ${handlers.isDragOver ? bookmarkStyles.dragOver : ''}`}
74+
{...props}
75+
>
76+
<DragIcon className={bookmarkStyles.dragHandle} width={14} height={14} />
77+
{children}
78+
</div>
79+
)
80+
}
81+
82+
export function CustomBookmarkList ({ ssrData, variables = {}, query }) {
83+
const toaster = useToast()
84+
const [reorderBookmarks] = useMutation(REORDER_BOOKMARKS)
85+
86+
const { data, fetchMore } = useQuery(query || SUB_ITEMS, { variables })
87+
const dat = useData(data, ssrData)
88+
89+
const { items, cursor } = useMemo(() => {
90+
if (!dat) return { items: [], cursor: null }
91+
return dat?.items || { items: [], cursor: null }
92+
}, [dat])
93+
94+
const [orderedItems, setOrderedItems] = useState(items || [])
95+
useEffect(() => { setOrderedItems(items || []) }, [items])
96+
97+
const Skeleton = useCallback(() =>
98+
<ItemsSkeleton startRank={items?.length} limit={variables.limit} Footer={MoreFooter} />, [items])
99+
100+
if (!dat) return <Skeleton />
101+
102+
const handleReorder = useCallback(async (newItems) => {
103+
try {
104+
const itemIds = newItems.map(item => item.id.toString())
105+
await reorderBookmarks({ variables: { itemIds } })
106+
toaster.success('bookmarks reordered')
107+
} catch (err) {
108+
console.error(err)
109+
toaster.danger('failed to reorder bookmarks')
110+
setOrderedItems(items || [])
111+
}
112+
}, [reorderBookmarks, toaster])
113+
114+
const visibleItems = useMemo(() => (orderedItems || []).filter(item => item?.meBookmark === true), [orderedItems])
115+
116+
return (
117+
<DndProvider items={visibleItems} onReorder={(newItems) => { setOrderedItems(newItems); handleReorder(newItems) }}>
118+
<div className={styles.grid}>
119+
{visibleItems.map((item, i) => (
120+
<DraggableBookmarkItem key={item.id} item={item} index={i}>
121+
<ListItem item={item} itemClassName={variables.includeComments ? 'py-2' : ''} pinnable={false} />
122+
</DraggableBookmarkItem>
123+
))}
124+
</div>
125+
<MoreFooter
126+
cursor={cursor} fetchMore={fetchMore}
127+
count={visibleItems.length}
128+
Skeleton={Skeleton}
129+
/>
130+
</DndProvider>
131+
)
132+
}

lib/constants.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export const ITEM_FILTER_THRESHOLD = 1.2
7474
export const DONT_LIKE_THIS_COST = 1
7575
export const COMMENT_TYPE_QUERY = ['comments', 'freebies', 'outlawed', 'borderland', 'all', 'bookmarks']
7676
export const USER_SORTS = ['value', 'stacking', 'spending', 'comments', 'posts', 'referrals']
77-
export const ITEM_SORTS = ['zaprank', 'comments', 'sats', 'boost']
77+
export const ITEM_SORTS = ['zaprank', 'comments', 'sats', 'boost', 'custom']
7878
export const SUB_SORTS = ['stacking', 'revenue', 'spending', 'posts', 'comments']
7979
export const WHENS = ['day', 'week', 'month', 'year', 'forever', 'custom']
8080
export const ITEM_TYPES_USER = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls', 'freebies', 'jobs', 'bookmarks']

pages/[name]/[type].js

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getGetServerSideProps } from '@/api/ssrApollo'
22
import Items from '@/components/items'
3+
import { CustomBookmarkList } from '@/components/bookmark'
34
import { useRouter } from 'next/router'
45
import { USER, USER_WITH_ITEMS } from '@/fragments/users'
56
import { useQuery } from '@apollo/client'
@@ -31,11 +32,20 @@ export default function UserItems ({ ssrData }) {
3132
<UserLayout user={user}>
3233
<div className='mt-2'>
3334
<UserItemsHeader type={variables.type} name={user.name} />
34-
<Items
35-
ssrData={ssrData}
36-
variables={variables}
37-
query={USER_WITH_ITEMS}
38-
/>
35+
{variables.type === 'bookmarks' && variables.by === 'custom'
36+
? (
37+
<CustomBookmarkList
38+
ssrData={ssrData}
39+
variables={variables}
40+
query={USER_WITH_ITEMS}
41+
/>
42+
)
43+
: (
44+
<Items
45+
ssrData={ssrData}
46+
variables={variables}
47+
query={USER_WITH_ITEMS}
48+
/>)}
3949
</div>
4050
</UserLayout>
4151
)
@@ -110,6 +120,11 @@ function UserItemsHeader ({ type, name }) {
110120
when={when}
111121
/>}
112122
</div>
123+
{type === 'bookmarks' && by === 'custom' && (
124+
<div className='text-muted small mb-2'>
125+
Drag and drop bookmarks to reorder them.
126+
</div>
127+
)}
113128
</Form>
114129
)
115130
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- AlterTable
2+
ALTER TABLE "Bookmark" ADD COLUMN "custom_order" INTEGER;
3+
4+
-- CreateIndex
5+
CREATE INDEX "Bookmark.custom_order_index" ON "Bookmark"("custom_order");

prisma/schema.prisma

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,11 +1045,13 @@ model Bookmark {
10451045
userId Int
10461046
itemId Int
10471047
createdAt DateTime @default(now()) @map("created_at")
1048+
customOrder Int? @map("custom_order")
10481049
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
10491050
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
10501051
10511052
@@id([userId, itemId])
10521053
@@index([createdAt], map: "Bookmark.created_at_index")
1054+
@@index([customOrder], map: "Bookmark.custom_order_index")
10531055
}
10541056

10551057
// TODO: make thread subscriptions for OP by default so they can

styles/bookmark.module.css

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
.draggableBookmark {
2+
transition: all 0.2s ease;
3+
cursor: grab;
4+
position: relative;
5+
grid-column: 1 / span 2;
6+
}
7+
8+
.draggableBookmark:active,
9+
.dragHandle:active {
10+
cursor: grabbing;
11+
}
12+
13+
.draggableBookmark.dragging {
14+
opacity: 0.7;
15+
transform: rotate(1deg);
16+
}
17+
18+
.draggableBookmark.dragOver {
19+
transform: translateY(-4px);
20+
}
21+
22+
.dragHandle {
23+
position: absolute;
24+
top: 8px;
25+
right: 8px;
26+
opacity: 0.5;
27+
cursor: grab;
28+
}
29+
30+
.dragHandle:hover {
31+
opacity: 0.6;
32+
}

0 commit comments

Comments
 (0)