diff --git a/api/resolvers/item.js b/api/resolvers/item.js
index 51085ba2e5..1bad02ca50 100644
--- a/api/resolvers/item.js
+++ b/api/resolvers/item.js
@@ -150,6 +150,9 @@ const orderByClause = (by, me, models, type, sub) => {
return 'ORDER BY "Item".boost DESC'
case 'random':
return 'ORDER BY RANDOM()'
+ case 'custom':
+ if (type === 'bookmarks') return 'ORDER BY "Bookmark"."custom_order" ASC NULLS LAST, "Bookmark"."created_at" DESC'
+ break
default:
return `ORDER BY ${type === 'bookmarks' ? '"bookmarkCreatedAt"' : '"Item".created_at'} DESC`
}
@@ -235,7 +238,7 @@ const relationClause = (type) => {
}
const selectClause = (type) => type === 'bookmarks'
- ? `${SELECT}, "Bookmark"."created_at" as "bookmarkCreatedAt"`
+ ? `${SELECT}, "Bookmark"."created_at" as "bookmarkCreatedAt", "Bookmark"."custom_order" as "bookmarkCustomOrder"`
: SELECT
const subClauseTable = (type) => COMMENT_TYPE_QUERY.includes(type) ? 'root' : 'Item'
@@ -421,7 +424,9 @@ export default {
${orderByClause(by, me, models, type)}
OFFSET $4
LIMIT $5`,
- orderBy: orderByClause(by, me, models, type)
+ orderBy: (type === 'bookmarks' && by === 'custom')
+ ? 'ORDER BY "Item"."bookmarkCustomOrder" ASC NULLS LAST, "Item"."bookmarkCreatedAt" DESC'
+ : orderByClause(by, me, models, type)
}, ...whenRange(when, from, to || decodedCursor.time), user.id, decodedCursor.offset, limit)
break
case 'recent':
@@ -772,6 +777,24 @@ export default {
} else await models.bookmark.create({ data })
return { id }
},
+ reorderBookmarks: async (parent, { itemIds }, { me, models }) => {
+ if (!me) {
+ throw new GqlAuthenticationError()
+ }
+
+ if (!itemIds || itemIds.length === 0) {
+ throw new GqlInputError('itemIds required')
+ }
+
+ await models.$transaction(
+ itemIds.map((id, i) => models.bookmark.update({
+ where: { userId_itemId: { userId: me.id, itemId: Number(id) } },
+ data: { customOrder: i + 1 }
+ }))
+ )
+
+ return true
+ },
pinItem: async (parent, { id }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js
index dfc638fa03..580b0c98ff 100644
--- a/api/typeDefs/item.js
+++ b/api/typeDefs/item.js
@@ -42,6 +42,7 @@ export default gql`
extend type Mutation {
bookmarkItem(id: ID): Item
+ reorderBookmarks(itemIds: [ID!]!): Boolean
pinItem(id: ID): Item
subscribeItem(id: ID): Item
deleteItem(id: ID): Item
diff --git a/components/bookmark.js b/components/bookmark.js
index b62c624c48..ee68c57b23 100644
--- a/components/bookmark.js
+++ b/components/bookmark.js
@@ -1,7 +1,16 @@
-import { useMutation } from '@apollo/client'
+import { useMutation, useQuery } from '@apollo/client'
import { gql } from 'graphql-tag'
import Dropdown from 'react-bootstrap/Dropdown'
import { useToast } from './toast'
+import { useCallback, useMemo, useEffect, useState } from 'react'
+import { DndProvider, useDndHandlers } from '@/wallets/client/context/dnd'
+import { ListItem, ItemsSkeleton } from './items'
+import MoreFooter from './more-footer'
+import { useData } from './use-data'
+import { SUB_ITEMS } from '@/fragments/subs'
+import styles from './items.module.css'
+import bookmarkStyles from '@/styles/bookmark.module.css'
+import DragIcon from '@/svgs/draggable.svg'
export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) {
const toaster = useToast()
@@ -39,3 +48,82 @@ export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) {
)
}
+
+const REORDER_BOOKMARKS = gql`
+ mutation reorderBookmarks($itemIds: [ID!]!) {
+ reorderBookmarks(itemIds: $itemIds)
+ }
+`
+
+function DraggableBookmarkItem ({ item, index, children, ...props }) {
+ const handlers = useDndHandlers(index)
+ return (
+
+
+ {children}
+
+ )
+}
+
+export function CustomBookmarkList ({ ssrData, variables = {}, query }) {
+ const toaster = useToast()
+ const [reorderBookmarks] = useMutation(REORDER_BOOKMARKS)
+
+ const { data, fetchMore } = useQuery(query || SUB_ITEMS, { variables })
+ const dat = useData(data, ssrData)
+
+ const { items, cursor } = useMemo(() => dat?.items ?? { items: [], cursor: null }, [dat])
+
+ const [orderedItems, setOrderedItems] = useState(items || [])
+ useEffect(() => { setOrderedItems(items || []) }, [items])
+
+ const Skeleton = useCallback(() =>
+ , [items])
+
+ if (!dat) return
+
+ const handleReorder = useCallback(async (newItems) => {
+ try {
+ const itemIds = newItems.map(item => item.id.toString())
+ await reorderBookmarks({ variables: { itemIds } })
+ toaster.success('bookmarks reordered')
+ } catch (err) {
+ console.error(err)
+ toaster.danger('failed to reorder bookmarks')
+ setOrderedItems(items || [])
+ }
+ }, [reorderBookmarks, toaster])
+
+ const visibleItems = useMemo(() => (orderedItems || []).filter(item => item?.meBookmark === true), [orderedItems])
+
+ return (
+ { setOrderedItems(newItems); handleReorder(newItems) }}>
+
+ {visibleItems.map((item, i) => (
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/lib/constants.js b/lib/constants.js
index 5e5d569327..874d885edb 100644
--- a/lib/constants.js
+++ b/lib/constants.js
@@ -75,7 +75,7 @@ export const ITEM_FILTER_THRESHOLD = 1.2
export const DONT_LIKE_THIS_COST = 1
export const COMMENT_TYPE_QUERY = ['comments', 'freebies', 'outlawed', 'borderland', 'all', 'bookmarks']
export const USER_SORTS = ['value', 'stacking', 'spending', 'comments', 'posts', 'referrals']
-export const ITEM_SORTS = ['zaprank', 'comments', 'sats', 'boost']
+export const ITEM_SORTS = ['zaprank', 'comments', 'sats', 'boost', 'custom']
export const SUB_SORTS = ['stacking', 'revenue', 'spending', 'posts', 'comments']
export const WHENS = ['day', 'week', 'month', 'year', 'forever', 'custom']
export const ITEM_TYPES_USER = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls', 'freebies', 'jobs', 'bookmarks']
diff --git a/pages/[name]/[type].js b/pages/[name]/[type].js
index 112587b21a..32f64dfdf3 100644
--- a/pages/[name]/[type].js
+++ b/pages/[name]/[type].js
@@ -1,5 +1,6 @@
import { getGetServerSideProps } from '@/api/ssrApollo'
import Items from '@/components/items'
+import { CustomBookmarkList } from '@/components/bookmark'
import { useRouter } from 'next/router'
import { USER, USER_WITH_ITEMS } from '@/fragments/users'
import { useQuery } from '@apollo/client'
@@ -31,11 +32,20 @@ export default function UserItems ({ ssrData }) {
-
+ {variables.type === 'bookmarks' && variables.by === 'custom'
+ ? (
+
+ )
+ : (
+ )}
)
@@ -110,6 +120,11 @@ function UserItemsHeader ({ type, name }) {
when={when}
/>}
+ {type === 'bookmarks' && by === 'custom' && (
+
+ Drag and drop bookmarks to reorder them.
+
+ )}
)
}
diff --git a/prisma/migrations/20250906100934_custom_bookmark_order/migration.sql b/prisma/migrations/20250906100934_custom_bookmark_order/migration.sql
new file mode 100644
index 0000000000..eb485c5345
--- /dev/null
+++ b/prisma/migrations/20250906100934_custom_bookmark_order/migration.sql
@@ -0,0 +1,5 @@
+-- AlterTable
+ALTER TABLE "Bookmark" ADD COLUMN "custom_order" INTEGER;
+
+-- CreateIndex
+CREATE INDEX "Bookmark.custom_order_index" ON "Bookmark"("custom_order");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 7aea842ba0..aa5cfc4c4d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1045,11 +1045,13 @@ model Bookmark {
userId Int
itemId Int
createdAt DateTime @default(now()) @map("created_at")
+ customOrder Int? @map("custom_order")
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, itemId])
@@index([createdAt], map: "Bookmark.created_at_index")
+ @@index([customOrder], map: "Bookmark.custom_order_index")
}
// TODO: make thread subscriptions for OP by default so they can
diff --git a/styles/bookmark.module.css b/styles/bookmark.module.css
new file mode 100644
index 0000000000..cc4878f369
--- /dev/null
+++ b/styles/bookmark.module.css
@@ -0,0 +1,32 @@
+.draggableBookmark {
+ transition: all 0.2s ease;
+ cursor: grab;
+ position: relative;
+ grid-column: 1 / span 2;
+}
+
+.draggableBookmark:active,
+.dragHandle:active {
+ cursor: grabbing;
+}
+
+.draggableBookmark.dragging {
+ opacity: 0.7;
+ transform: rotate(1deg);
+}
+
+.draggableBookmark.dragOver {
+ transform: translateY(-4px);
+}
+
+.dragHandle {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ opacity: 0.5;
+ cursor: grab;
+}
+
+.dragHandle:hover {
+ opacity: 0.6;
+}
\ No newline at end of file