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