From 7c1ccebf153bdfc96daec9a9ecdb30f10b4bac55 Mon Sep 17 00:00:00 2001 From: kamtschatka Date: Sat, 18 May 2024 14:36:19 +0200 Subject: [PATCH 1/2] [Feature request] Drag and Drop Items to Lists #123 reworked the drag and drop mechanism to still have change detection, but not so much that it has a huge overhead Changed the layout a bit to allow proper drag/drop of elements from the main section to the sidebar Added the possibility to drag/drop bookmarks onto lists --- .../dashboard/bookmarks/Bookmark.tsx | 113 ++++++++++++++++++ .../dashboard/bookmarks/BookmarkActionBar.tsx | 3 +- .../dashboard/bookmarks/BookmarksGrid.tsx | 23 +--- .../components/dashboard/sidebar/AllLists.tsx | 1 + .../dashboard/sidebar/SidebarItem.tsx | 3 + .../components/dashboard/tags/AllTagsView.tsx | 77 ++---------- .../web/components/dashboard/tags/TagPill.tsx | 96 ++++++++++++--- apps/web/lib/drag-and-drop.ts | 55 ++++++--- 8 files changed, 248 insertions(+), 123 deletions(-) create mode 100644 apps/web/components/dashboard/bookmarks/Bookmark.tsx diff --git a/apps/web/components/dashboard/bookmarks/Bookmark.tsx b/apps/web/components/dashboard/bookmarks/Bookmark.tsx new file mode 100644 index 00000000..423c026e --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/Bookmark.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import { toast } from "@/components/ui/use-toast"; +import { useDragAndDrop } from "@/lib/drag-and-drop"; +import { Slot } from "@radix-ui/react-slot"; +import * as LucideReact from "lucide-react"; +import Draggable from "react-draggable"; + +import { useAddBookmarkToList } from "@hoarder/shared-react/hooks/lists"; +import { ZBookmark } from "@hoarder/shared/types/bookmarks"; + +import AssetCard from "./AssetCard"; +import LinkCard from "./LinkCard"; +import TextCard from "./TextCard"; + +export function BookmarkCard({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +export default function Bookmark({ bookmark }: { bookmark: ZBookmark }) { + const [draggingState, setDraggingState] = React.useState({ + isDragging: false, + initialX: 0, + initialY: 0, + }); + + const { mutate: addToList } = useAddBookmarkToList({ + onSuccess: () => { + toast({ + description: "List has been updated!", + }); + }, + onError: (e) => { + if (e.data?.code == "BAD_REQUEST") { + toast({ + variant: "destructive", + description: e.message, + }); + } else { + toast({ + variant: "destructive", + title: "Something went wrong", + }); + } + }, + }); + + const dragAndDropFunction = useDragAndDrop( + "data-list-id", + (dragTargetId: string) => { + addToList({ + bookmarkId: bookmark.id, + listId: dragTargetId, + }); + }, + setDraggingState, + ); + + let comp; + switch (bookmark.content.type) { + case "link": + comp = ; + break; + case "text": + comp = ; + break; + case "asset": + comp = ( + + ); + break; + } + + return ( +
+ + {draggingState.isDragging ? ( +
+ +
+ ) : ( +
+ {comp} +
+ )} +
+ {draggingState.isDragging ? ( +
+ {comp} +
+ ) : ( + "" + )} +
+ ); +} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx b/apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx index 6cc8e44e..a3bdc02a 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { Maximize2 } from "lucide-react"; +import { GripVertical, Maximize2 } from "lucide-react"; import type { ZBookmark } from "@hoarder/shared/types/bookmarks"; @@ -25,6 +25,7 @@ export default function BookmarkActionBar({ + ); } diff --git a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx index 540546fe..7c926189 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx @@ -1,27 +1,18 @@ -import { useMemo } from "react"; +import React, { useMemo } from "react"; import { ActionButton } from "@/components/ui/action-button"; import { bookmarkLayoutSwitch, useBookmarkLayout, } from "@/lib/userLocalSettings/bookmarksLayout"; import tailwindConfig from "@/tailwind.config"; -import { Slot } from "@radix-ui/react-slot"; import Masonry from "react-masonry-css"; import resolveConfig from "tailwindcss/resolveConfig"; import type { ZBookmark } from "@hoarder/shared/types/bookmarks"; -import BookmarkCard from "./BookmarkCard"; +import Bookmark, { BookmarkCard } from "./Bookmark"; import EditorCard from "./EditorCard"; -function StyledBookmarkCard({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - function getBreakpointConfig() { const fullConfig = resolveConfig(tailwindConfig); @@ -56,15 +47,11 @@ export default function BookmarksGrid({ const children = [ showEditorCard && ( - + - + ), - ...bookmarks.map((b) => ( - - - - )), + ...bookmarks.map((b) => ), ]; return ( <> diff --git a/apps/web/components/dashboard/sidebar/AllLists.tsx b/apps/web/components/dashboard/sidebar/AllLists.tsx index b1c6ddb2..b352e19a 100644 --- a/apps/web/components/dashboard/sidebar/AllLists.tsx +++ b/apps/web/components/dashboard/sidebar/AllLists.tsx @@ -74,6 +74,7 @@ export default function AllLists({ } name={node.item.name} + id={node.item.id} path={`/dashboard/lists/${node.item.id}`} right={ diff --git a/apps/web/components/dashboard/sidebar/SidebarItem.tsx b/apps/web/components/dashboard/sidebar/SidebarItem.tsx index 262fd9ae..b1c93325 100644 --- a/apps/web/components/dashboard/sidebar/SidebarItem.tsx +++ b/apps/web/components/dashboard/sidebar/SidebarItem.tsx @@ -6,6 +6,7 @@ import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; export default function SidebarItem({ + id, name, logo, path, @@ -14,6 +15,7 @@ export default function SidebarItem({ collapseButton, right = null, }: { + id?: string; name: string; logo: React.ReactNode; path: string; @@ -25,6 +27,7 @@ export default function SidebarItem({ const currentPath = usePathname(); return (
  • { - mergeTag({ - fromTagIds: [dragSourceId], - intoTagId: dragTargetId, - }); - }, - ); - function toggleSortByName(): void { setSortByName(!sortByName); } @@ -98,40 +82,9 @@ export default function AllTagsView({ setDraggingEnabled(!draggingEnabled); } - const { mutate: mergeTag } = useMergeTag({ - onSuccess: () => { - toast({ - description: "Tags have been merged!", - }); - }, - onError: (e) => { - if (e.data?.code == "BAD_REQUEST") { - if (e.data.zodError) { - toast({ - variant: "destructive", - description: Object.values(e.data.zodError.fieldErrors) - .flat() - .join("\n"), - }); - } else { - toast({ - variant: "destructive", - description: e.message, - }); - } - } else { - toast({ - variant: "destructive", - title: "Something went wrong", - }); - } - }, - }); - const { data } = api.tags.list.useQuery(undefined, { initialData: { tags: initialData }, }); - // Sort tags by usage desc const allTags = data.tags.sort(sortByName ? byNameSorter : byUsageSorter); @@ -145,21 +98,13 @@ export default function AllTagsView({ tagPill = (
    {tags.map((t) => ( - -
    - -
    -
    + id={t.id} + name={t.name} + count={t.count} + isDraggable={draggingEnabled} + /> ))}
    ); @@ -173,6 +118,7 @@ export default function AllTagsView({
    @@ -184,6 +130,7 @@ export default function AllTagsView({ @@ -196,22 +143,16 @@ export default function AllTagsView({

    Tags that were attached at least once by you

    - {tagsToPill(humanTags)} - -

    AI Tags

    Tags that were only attached automatically (by AI)

    - {tagsToPill(aiTags)} - -

    Unused Tags

    diff --git a/apps/web/components/dashboard/tags/TagPill.tsx b/apps/web/components/dashboard/tags/TagPill.tsx index f1c99d70..88b88b52 100644 --- a/apps/web/components/dashboard/tags/TagPill.tsx +++ b/apps/web/components/dashboard/tags/TagPill.tsx @@ -1,7 +1,13 @@ +import React from "react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { toast } from "@/components/ui/use-toast"; +import { useDragAndDrop } from "@/lib/drag-and-drop"; import { X } from "lucide-react"; +import Draggable from "react-draggable"; + +import { useMergeTag } from "@hoarder/shared-react/hooks/tags"; import DeleteTagConfirmationDialog from "./DeleteTagConfirmationDialog"; @@ -9,32 +15,84 @@ export function TagPill({ id, name, count, + isDraggable, }: { id: string; name: string; count: number; + isDraggable: boolean; }) { - return ( -
    - { + toast({ + description: "Tags have been merged!", + }); + }, + onError: (e) => { + if (e.data?.code == "BAD_REQUEST") { + if (e.data.zodError) { + toast({ + variant: "destructive", + description: Object.values(e.data.zodError.fieldErrors) + .flat() + .join("\n"), + }); + } else { + toast({ + variant: "destructive", + description: e.message, + }); } - href={`/dashboard/tags/${id}`} - data-id={id} - > - {name} {count} - + } else { + toast({ + variant: "destructive", + title: "Something went wrong", + }); + } + }, + }); + + const dragAndDropFunction = useDragAndDrop( + "data-id", + (dragTargetId: string) => { + mergeTag({ + fromTagIds: [id], + intoTagId: dragTargetId, + }); + }, + ); - - - -
    + {name} {count} + + + + + +
    + ); } diff --git a/apps/web/lib/drag-and-drop.ts b/apps/web/lib/drag-and-drop.ts index e005a6d0..ec37e810 100644 --- a/apps/web/lib/drag-and-drop.ts +++ b/apps/web/lib/drag-and-drop.ts @@ -1,31 +1,48 @@ import React from "react"; -import { DraggableData, DraggableEvent } from "react-draggable"; +import { DraggableEvent } from "react-draggable"; + +export interface DraggingState { + isDragging: boolean; + initialX: number; + initialY: number; +} export function useDragAndDrop( - dragSourceIdAttribute: string, dragTargetIdAttribute: string, - onDragOver: (dragSourceId: string, dragTargetId: string) => void, + onDragOver: (dragTargetId: string) => void, + setDraggingState?: React.Dispatch>, ) { - const [dragSourceId, setDragSourceId] = React.useState(null); + function findTargetId(element: HTMLElement): string | null { + let currentElement: HTMLElement | null = element; + while (currentElement) { + const listId = currentElement.getAttribute(dragTargetIdAttribute); + if (listId) { + return listId; + } + currentElement = currentElement.parentElement; + } + return null; + } const handleDragStart = React.useCallback( - (_e: DraggableEvent, { node }: DraggableData) => { - const id = node.getAttribute(dragSourceIdAttribute); - setDragSourceId(id); + (e: DraggableEvent) => { + const rect = (e.target as HTMLElement).getBoundingClientRect(); + setDraggingState?.({ + isDragging: true, + initialX: rect.x, + initialY: rect.y, + }); }, - [], + [setDraggingState], ); const handleDragEnd = React.useCallback( (e: DraggableEvent) => { const { target } = e; - const dragTargetId = (target as HTMLElement).getAttribute( - dragTargetIdAttribute, - ); + const dragTargetId = findTargetId(target as HTMLElement); - if (dragSourceId && dragTargetId && dragSourceId !== dragTargetId) { - /* - As Draggable tries to setState when the + if (dragTargetId) { + /* As Draggable tries to setState when the component is unmounted, it is needed to push onCombine to the event loop queue. onCombine would be run after setState on @@ -33,12 +50,16 @@ export function useDragAndDrop( they fix it on their end. */ setTimeout(() => { - onDragOver(dragSourceId, dragTargetId); + onDragOver(dragTargetId); }, 0); } - setDragSourceId(null); + setDraggingState?.({ + isDragging: false, + initialX: 0, + initialY: 0, + }); }, - [dragSourceId, onDragOver], + [onDragOver], ); return { From d0df8b01d0e20e26f2a6e240370c44f22a87bc8a Mon Sep 17 00:00:00 2001 From: kamtschatka Date: Sun, 30 Jun 2024 16:58:28 +0200 Subject: [PATCH 2/2] [Feature request] Drag and Drop Items to Lists #123 Removed the changes to allow dragging&dropping bookmarks --- .../dashboard/bookmarks/Bookmark.tsx | 113 ------------------ .../dashboard/bookmarks/BookmarkActionBar.tsx | 3 +- .../dashboard/bookmarks/BookmarksGrid.tsx | 23 +++- .../components/dashboard/sidebar/AllLists.tsx | 1 - .../dashboard/sidebar/SidebarItem.tsx | 3 - 5 files changed, 19 insertions(+), 124 deletions(-) delete mode 100644 apps/web/components/dashboard/bookmarks/Bookmark.tsx diff --git a/apps/web/components/dashboard/bookmarks/Bookmark.tsx b/apps/web/components/dashboard/bookmarks/Bookmark.tsx deleted file mode 100644 index 423c026e..00000000 --- a/apps/web/components/dashboard/bookmarks/Bookmark.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React from "react"; -import { toast } from "@/components/ui/use-toast"; -import { useDragAndDrop } from "@/lib/drag-and-drop"; -import { Slot } from "@radix-ui/react-slot"; -import * as LucideReact from "lucide-react"; -import Draggable from "react-draggable"; - -import { useAddBookmarkToList } from "@hoarder/shared-react/hooks/lists"; -import { ZBookmark } from "@hoarder/shared/types/bookmarks"; - -import AssetCard from "./AssetCard"; -import LinkCard from "./LinkCard"; -import TextCard from "./TextCard"; - -export function BookmarkCard({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} -export default function Bookmark({ bookmark }: { bookmark: ZBookmark }) { - const [draggingState, setDraggingState] = React.useState({ - isDragging: false, - initialX: 0, - initialY: 0, - }); - - const { mutate: addToList } = useAddBookmarkToList({ - onSuccess: () => { - toast({ - description: "List has been updated!", - }); - }, - onError: (e) => { - if (e.data?.code == "BAD_REQUEST") { - toast({ - variant: "destructive", - description: e.message, - }); - } else { - toast({ - variant: "destructive", - title: "Something went wrong", - }); - } - }, - }); - - const dragAndDropFunction = useDragAndDrop( - "data-list-id", - (dragTargetId: string) => { - addToList({ - bookmarkId: bookmark.id, - listId: dragTargetId, - }); - }, - setDraggingState, - ); - - let comp; - switch (bookmark.content.type) { - case "link": - comp = ; - break; - case "text": - comp = ; - break; - case "asset": - comp = ( - - ); - break; - } - - return ( -
    - - {draggingState.isDragging ? ( -
    - -
    - ) : ( -
    - {comp} -
    - )} -
    - {draggingState.isDragging ? ( -
    - {comp} -
    - ) : ( - "" - )} -
    - ); -} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx b/apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx index a3bdc02a..6cc8e44e 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { GripVertical, Maximize2 } from "lucide-react"; +import { Maximize2 } from "lucide-react"; import type { ZBookmark } from "@hoarder/shared/types/bookmarks"; @@ -25,7 +25,6 @@ export default function BookmarkActionBar({ - ); } diff --git a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx index 7c926189..540546fe 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx @@ -1,18 +1,27 @@ -import React, { useMemo } from "react"; +import { useMemo } from "react"; import { ActionButton } from "@/components/ui/action-button"; import { bookmarkLayoutSwitch, useBookmarkLayout, } from "@/lib/userLocalSettings/bookmarksLayout"; import tailwindConfig from "@/tailwind.config"; +import { Slot } from "@radix-ui/react-slot"; import Masonry from "react-masonry-css"; import resolveConfig from "tailwindcss/resolveConfig"; import type { ZBookmark } from "@hoarder/shared/types/bookmarks"; -import Bookmark, { BookmarkCard } from "./Bookmark"; +import BookmarkCard from "./BookmarkCard"; import EditorCard from "./EditorCard"; +function StyledBookmarkCard({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + function getBreakpointConfig() { const fullConfig = resolveConfig(tailwindConfig); @@ -47,11 +56,15 @@ export default function BookmarksGrid({ const children = [ showEditorCard && ( - + - + ), - ...bookmarks.map((b) => ), + ...bookmarks.map((b) => ( + + + + )), ]; return ( <> diff --git a/apps/web/components/dashboard/sidebar/AllLists.tsx b/apps/web/components/dashboard/sidebar/AllLists.tsx index b352e19a..b1c6ddb2 100644 --- a/apps/web/components/dashboard/sidebar/AllLists.tsx +++ b/apps/web/components/dashboard/sidebar/AllLists.tsx @@ -74,7 +74,6 @@ export default function AllLists({ } name={node.item.name} - id={node.item.id} path={`/dashboard/lists/${node.item.id}`} right={ diff --git a/apps/web/components/dashboard/sidebar/SidebarItem.tsx b/apps/web/components/dashboard/sidebar/SidebarItem.tsx index b1c93325..262fd9ae 100644 --- a/apps/web/components/dashboard/sidebar/SidebarItem.tsx +++ b/apps/web/components/dashboard/sidebar/SidebarItem.tsx @@ -6,7 +6,6 @@ import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; export default function SidebarItem({ - id, name, logo, path, @@ -15,7 +14,6 @@ export default function SidebarItem({ collapseButton, right = null, }: { - id?: string; name: string; logo: React.ReactNode; path: string; @@ -27,7 +25,6 @@ export default function SidebarItem({ const currentPath = usePathname(); return (