From aac893c2864ce17d4bd8b67834bf82fd126c3b4d Mon Sep 17 00:00:00 2001 From: Xavier Stouder Date: Fri, 28 Jul 2023 17:36:48 +0200 Subject: [PATCH] feat: reorder feeds --- package-lock.json | 99 +++++++++++++++++++++++++-- package.json | 6 +- src/components/layout/Article.tsx | 2 + src/components/layout/Feed.tsx | 63 ++++++++++++----- src/components/layout/FeedList.tsx | 89 +++++++++++++++++++----- src/state/data/DataActionType.ts | 1 + src/state/data/DataReducer.ts | 5 ++ src/state/data/actions/FeedActions.ts | 26 +++++++ 8 files changed, 251 insertions(+), 40 deletions(-) diff --git a/package-lock.json b/package-lock.json index 514658e..f336e7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "alduin", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.0.8", + "@dnd-kit/modifiers": "^6.0.1", + "@dnd-kit/sortable": "^7.0.2", + "@dnd-kit/utilities": "^3.2.1", "@emotion/css": "^11.11.2", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", @@ -23,7 +27,7 @@ "jszip": "^3.10.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-icons": "^4.9.0", + "react-icons": "^4.10.1", "react-router-dom": "^6.13.0", "react-use": "^17.4.0", "sanitize-html": "^2.11.0", @@ -1166,6 +1170,93 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz", + "integrity": "sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/accessibility/node_modules/tslib": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz", + "integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==" + }, + "node_modules/@dnd-kit/core": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.0.8.tgz", + "integrity": "sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==", + "dependencies": { + "@dnd-kit/accessibility": "^3.0.0", + "@dnd-kit/utilities": "^3.2.1", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core/node_modules/tslib": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz", + "integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==" + }, + "node_modules/@dnd-kit/modifiers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-6.0.1.tgz", + "integrity": "sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.1", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.0.6", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers/node_modules/tslib": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz", + "integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==" + }, + "node_modules/@dnd-kit/sortable": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz", + "integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.0", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.0.7", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable/node_modules/tslib": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz", + "integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==" + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.1.tgz", + "integrity": "sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities/node_modules/tslib": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz", + "integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==" + }, "node_modules/@emotion/babel-plugin": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", @@ -7349,9 +7440,9 @@ } }, "node_modules/react-icons": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.9.0.tgz", - "integrity": "sha512-ijUnFr//ycebOqujtqtV9PFS7JjhWg0QU6ykURVHuL4cbofvRCf3f6GMn9+fBktEFQOIVZnuAYLZdiyadRQRFg==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.10.1.tgz", + "integrity": "sha512-/ngzDP/77tlCfqthiiGNZeYFACw85fUjZtLbedmJ5DTlNDIwETxhwBzdOJ21zj4iJdvc0J3y7yOsX3PpxAJzrw==", "peerDependencies": { "react": "*" } diff --git a/package.json b/package.json index 92c07a4..9923fca 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,10 @@ "test": "npm run eslint && npm run prettier && npm run check-types" }, "dependencies": { + "@dnd-kit/core": "^6.0.8", + "@dnd-kit/modifiers": "^6.0.1", + "@dnd-kit/sortable": "^7.0.2", + "@dnd-kit/utilities": "^3.2.1", "@emotion/css": "^11.11.2", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", @@ -33,7 +37,7 @@ "jszip": "^3.10.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-icons": "^4.9.0", + "react-icons": "^4.10.1", "react-router-dom": "^6.13.0", "react-use": "^17.4.0", "sanitize-html": "^2.11.0", diff --git a/src/components/layout/Article.tsx b/src/components/layout/Article.tsx index b242d93..699a782 100644 --- a/src/components/layout/Article.tsx +++ b/src/components/layout/Article.tsx @@ -1,3 +1,5 @@ +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import clsx from 'clsx'; import { memo, useCallback } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; diff --git a/src/components/layout/Feed.tsx b/src/components/layout/Feed.tsx index 38b95ee..72f6fe5 100644 --- a/src/components/layout/Feed.tsx +++ b/src/components/layout/Feed.tsx @@ -1,6 +1,9 @@ +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import clsx from 'clsx'; import { memo, useCallback, useMemo, MouseEvent } from 'react'; import { FaEdit } from 'react-icons/fa'; +import { RiDraggable } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; import useEditMode from '../../hooks/useEditMode'; @@ -55,33 +58,59 @@ function Feed(props: FeedProps) { [identifier, view.activeFeed], ); + const { + setNodeRef, + attributes, + listeners, + transition, + transform, + setActivatorNodeRef, + } = useSortable({ id: identifier }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + return (
-
- {isEditing && ( - - )} +
+
+ {isEditing && ( + + )} - + -
{displayName}
+
{displayName}
+
+ {unread > 0 && ( +
+ {unread} +
+ )}
- {unread > 0 && ( -
- {unread} + {isEditing && ( +
+
)}
diff --git a/src/components/layout/FeedList.tsx b/src/components/layout/FeedList.tsx index b926977..165e8d3 100644 --- a/src/components/layout/FeedList.tsx +++ b/src/components/layout/FeedList.tsx @@ -1,11 +1,27 @@ +import { + closestCenter, + DndContext, + DragEndEvent, + PointerSensor, + UniqueIdentifier, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; import clsx from 'clsx'; -import { memo, useCallback } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { FaCogs, FaEdit, FaPlus } from 'react-icons/fa'; import useData from '../../hooks/useData'; +import useDataDispatch from '../../hooks/useDataDispatch'; import useEditMode from '../../hooks/useEditMode'; import useModal from '../../hooks/useModal'; import usePreference from '../../hooks/usePreference'; +import { REORDER_FEED } from '../../state/data/DataActionType'; import { PreferenceState } from '../../state/preference/PreferenceReducer'; import SyncAllButton from '../SyncAllButton'; import IconButton from '../form/IconButton'; @@ -15,6 +31,7 @@ import Feed from './Feed'; function FeedList() { const data = useData(); + const dataDispatch = useDataDispatch(); const preference = usePreference(); const { toggleEditMode, isEditing } = useEditMode(); @@ -28,24 +45,60 @@ function FeedList() { openPreference(preference); }, [openPreference, preference]); + const identifiers: UniqueIdentifier[] = useMemo( + () => data.feeds.map(({ identifier }) => identifier), + [data.feeds], + ); + + const sensors = useSensors(useSensor(PointerSensor)); + const modifiers = useMemo(() => [restrictToVerticalAxis], []); + + const handleDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + if (over === null) { + return; + } + dataDispatch({ + type: REORDER_FEED, + payload: { + fromIdentifier: active.id as string, + toIdentifier: over.id as string, + }, + }); + }, + [dataDispatch], + ); + return ( -
-
- {data.feeds.map((feed) => ( - - ))} -
-
- - - - -
-
+ + +
+
+ {data.feeds.map((feed) => ( + + ))} +
+
+ + + + +
+
+
+
); } diff --git a/src/state/data/DataActionType.ts b/src/state/data/DataActionType.ts index 3f74a89..77076ca 100644 --- a/src/state/data/DataActionType.ts +++ b/src/state/data/DataActionType.ts @@ -4,6 +4,7 @@ export type DataActionType = Payload extends void export const LOAD = 'LOAD'; export const ADD_FEED = 'ADD_FEED'; export const UPDATE_FEED = 'UPDATE_FEED'; +export const REORDER_FEED = 'REORDER_FEED'; export const UPDATE_CONTENT = 'UPDATE_CONTENT'; export const UPDATE_MULTIPLE_CONTENT = 'UPDATE_MULTIPLE_CONTENT'; export const READ_ARTICLE = 'READ_ARTICLE'; diff --git a/src/state/data/DataReducer.ts b/src/state/data/DataReducer.ts index 0ac815f..df3c56a 100644 --- a/src/state/data/DataReducer.ts +++ b/src/state/data/DataReducer.ts @@ -8,6 +8,7 @@ import { LOAD, READ_ARTICLE, REMOVE_FEED, + REORDER_FEED, UPDATE_CONTENT, UPDATE_FEED, UPDATE_MULTIPLE_CONTENT, @@ -17,6 +18,7 @@ import { AddFeedAction, ReadArticleAction, RemoveFeedAction, + ReorderFeedAction, UpdateContentAction, UpdateFeedAction, UpdateMultipleContentAction, @@ -35,6 +37,7 @@ export const initialDataState: DataState = { export type DataActions = | AddFeedAction | UpdateFeedAction + | ReorderFeedAction | ReadArticleAction | RemoveFeedAction | LoadAction @@ -49,6 +52,8 @@ function innerDataReducer(draft: Draft, action: DataActions) { return FeedActions.addFeed(draft, action.payload); case UPDATE_FEED: return FeedActions.updateFeed(draft, action.payload); + case REORDER_FEED: + return FeedActions.reorderFeed(draft, action.payload); case READ_ARTICLE: return FeedActions.readArticle(draft, action.payload); case REMOVE_FEED: diff --git a/src/state/data/actions/FeedActions.ts b/src/state/data/actions/FeedActions.ts index a4f5887..5aed9cb 100644 --- a/src/state/data/actions/FeedActions.ts +++ b/src/state/data/actions/FeedActions.ts @@ -13,6 +13,7 @@ import { UPDATE_FEED, UPDATE_CONTENT, UPDATE_MULTIPLE_CONTENT, + REORDER_FEED, } from '../DataActionType'; import { DataState } from '../DataReducer'; @@ -26,6 +27,11 @@ export type UpdateFeedAction = DataActionType< { identifier: string; displayName: string; link: string; interval: string } >; +export type ReorderFeedAction = DataActionType< + typeof REORDER_FEED, + { fromIdentifier: string; toIdentifier: string } +>; + export type ReadArticleAction = DataActionType< typeof READ_ARTICLE, { identifier: string; articleIdentifier: string } @@ -88,6 +94,26 @@ export function updateFeed( feed.interval = Number.parseInt(interval, 10); } +export function reorderFeed( + draft: Draft, + { + fromIdentifier, + toIdentifier, + }: { fromIdentifier: string; toIdentifier: string }, +) { + const fromIndex = draft.feeds.findIndex( + (feed) => feed.identifier === fromIdentifier, + ); + const toIndex = draft.feeds.findIndex( + (feed) => feed.identifier === toIdentifier, + ); + + if (fromIndex === -1 || toIndex === -1) return; + + const [feed] = draft.feeds.splice(fromIndex, 1); + draft.feeds.splice(toIndex, 0, feed); +} + export function readArticle( draft: Draft, {