diff --git a/apps/web/package.json b/apps/web/package.json index 14889ab27..2a74b65e8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,9 @@ "@aws-sdk/client-s3": "^3.802.0", "@aws-sdk/lib-storage": "^3.802.0", "@aws-sdk/s3-request-presigner": "^3.812.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@headlessui/react": "^2.2.0", "@hookform/resolvers": "^3.3.4", "@kan/api": "workspace:*", diff --git a/apps/web/src/views/boards/components/BoardsList.tsx b/apps/web/src/views/boards/components/BoardsList.tsx index 0e2c03586..f7b994cb5 100644 --- a/apps/web/src/views/boards/components/BoardsList.tsx +++ b/apps/web/src/views/boards/components/BoardsList.tsx @@ -1,40 +1,123 @@ -import Link from "next/link"; +import type { DragEndEvent } from "@dnd-kit/core"; +import { useRouter } from "next/router"; +import { + closestCenter, + DndContext, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + rectSortingStrategy, + SortableContext, +} from "@dnd-kit/sortable"; import { t } from "@lingui/core/macro"; -import { HiOutlineRectangleStack, HiOutlineStar, HiStar } from "react-icons/hi2"; -import { motion } from "framer-motion"; +import { useRef } from "react"; +import { HiOutlineRectangleStack } from "react-icons/hi2"; + import Button from "~/components/Button"; -import PatternedBackground from "~/components/PatternedBackground"; import { Tooltip } from "~/components/Tooltip"; import { usePermissions } from "~/hooks/usePermissions"; import { useModal } from "~/providers/modal"; import { useWorkspace } from "~/providers/workspace"; import { api } from "~/utils/api"; +import { SortableBoardCard } from "./SortableBoardCard"; -export function BoardsList({ isTemplate, archived = false }: { isTemplate?: boolean; archived?: boolean }) { +export function BoardsList({ + isTemplate, + archived = false, +}: { + isTemplate?: boolean; + archived?: boolean; +}) { const { workspace } = useWorkspace(); const { openModal } = useModal(); const { canCreateBoard } = usePermissions(); + const router = useRouter(); const utils = api.useUtils(); + + const queryInput = { + workspacePublicId: workspace.publicId, + type: isTemplate ? ("template" as const) : ("regular" as const), + archived, + }; + const updateBoard = api.board.update.useMutation({ onSuccess: () => { void utils.board.all.invalidate(); }, }); - const { data, isLoading } = api.board.all.useQuery( - { - workspacePublicId: workspace.publicId, - type: isTemplate ? "template" : "regular", - archived: archived, + const reorderBoard = api.board.reorder.useMutation({ + onMutate: ({ boardPublicId, newPosition }) => { + const previousData = utils.board.all.getData(queryInput); + + // Apply the optimistic reorder synchronously (before any await) so it + // batches into the same render as dnd-kit clearing the drag transform. + // Otherwise the card snaps back to its original slot for one frame before + // jumping to the new slot. + utils.board.all.setData(queryInput, (old) => { + if (!old) return old; + + const moved = old.find((b) => b.publicId === boardPublicId); + if (!moved) return old; + + // newPosition is the index within the board's favourite group; map it + // back to an absolute index in the full (favourites-first) list. + const group = old.filter((b) => b.favorite === moved.favorite); + const target = group[newPosition]; + if (!target) return old; + + const oldIndex = old.findIndex((b) => b.publicId === boardPublicId); + const newIndex = old.findIndex((b) => b.publicId === target.publicId); + if (oldIndex === -1 || newIndex === -1) return old; + + return arrayMove(old, oldIndex, newIndex); + }); + + // Cancel in-flight refetches so they can't clobber the optimistic order. + void utils.board.all.cancel(queryInput); + + return { previousData }; + }, + onError: (_err, _vars, context) => { + if (context?.previousData) { + utils.board.all.setData(queryInput, context.previousData); + } }, - { enabled: workspace.publicId ? true : false }, + onSuccess: () => { + // The optimistic order already matches what the server computes, so mark + // the query stale WITHOUT refetching now. Refetching immediately replaces + // the array mid-animation, which makes dnd-kit re-measure from stale + // positions and the cards fly in from a default spot. It refreshes on the + // next natural trigger (focus/remount). + void utils.board.all.invalidate(queryInput, { refetchType: "none" }); + }, + }); + + const { data, isLoading } = api.board.all.useQuery(queryInput, { + enabled: workspace.publicId ? true : false, + }); + + const sensors = useSensors( + useSensor(PointerSensor, { + // Require an 8px drag before activating so plain clicks still navigate. + activationConstraint: { distance: 8 }, + }), ); + // Whether the current pointer interaction became a drag. The browser fires a + // `click` on pointer-release after a drag; `handleOpenBoard` reads this to skip + // navigation for that click. Reset on pointer-down, set in `onDragStart` (which + // only fires once the 8px threshold is passed). + const didDragRef = useRef(false); + const handleToggleFavorite = ( e: React.MouseEvent, boardPublicId: string, - currentFavorite: boolean | undefined + currentFavorite: boolean | undefined, ) => { e.preventDefault(); e.stopPropagation(); @@ -44,6 +127,52 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool }); }; + const handlePointerDownCapture = () => { + didDragRef.current = false; + }; + + const handleDragStart = () => { + didDragRef.current = true; + }; + + const handleOpenBoard = ( + e: React.MouseEvent | React.KeyboardEvent, + boardPublicId: string, + ) => { + // Skip the click the browser fires right after a drag. + if (didDragRef.current) return; + + const path = `/${isTemplate ? "templates" : "boards"}/${boardPublicId}`; + + if (e.metaKey || e.ctrlKey) { + window.open(path, "_blank"); + return; + } + + void router.push(path); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id || !data) return; + + const activeBoard = data.find((b) => b.publicId === active.id); + const overBoard = data.find((b) => b.publicId === over.id); + if (!activeBoard || !overBoard) return; + + // Reordering only happens within the same favourite group; cross-group + // moves are handled via the favourite toggle. + if (activeBoard.favorite !== overBoard.favorite) return; + + const group = data.filter((b) => b.favorite === activeBoard.favorite); + const newPosition = group.findIndex((b) => b.publicId === over.id); + if (newPosition === -1) return; + + reorderBoard.mutate({ + boardPublicId: active.id as string, + newPosition, + }); + }; if (isLoading) return ( @@ -60,16 +189,18 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
- {archived ? t`No archived boards` : t`No ${isTemplate ? "templates" : "boards"}`} + {archived + ? t`No archived boards` + : t`No ${isTemplate ? "templates" : "boards"}`}
- {archived ? t`Boards you archive will appear here.` : t`Get started by creating a new ${isTemplate ? "template" : "board"}`} + {archived + ? t`Boards you archive will appear here.` + : t`Get started by creating a new ${isTemplate ? "template" : "board"}`}