From 485e9948b1d6d40df44a781c5133f6698b1f872b Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 8 Feb 2026 23:34:06 +0000 Subject: feat: Add drag-and-drop support for bookmarks to lists (#2469) * feat: add drag and drop bookmark cards into sidebar lists Co-authored-by: Claude --- apps/web/components/dashboard/UploadDropzone.tsx | 8 +- .../bookmarks/BookmarkLayoutAdaptingCard.tsx | 83 ++++++- apps/web/components/dashboard/sidebar/AllLists.tsx | 265 ++++++++++++--------- apps/web/components/shared/sidebar/SidebarItem.tsx | 15 ++ 4 files changed, 260 insertions(+), 111 deletions(-) (limited to 'apps/web/components') diff --git a/apps/web/components/dashboard/UploadDropzone.tsx b/apps/web/components/dashboard/UploadDropzone.tsx index d3945cc3..c76da523 100644 --- a/apps/web/components/dashboard/UploadDropzone.tsx +++ b/apps/web/components/dashboard/UploadDropzone.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useState } from "react"; import { toast } from "@/components/ui/sonner"; +import { BOOKMARK_DRAG_MIME } from "@/lib/bookmark-drag"; import useUpload from "@/lib/hooks/upload-file"; import { cn } from "@/lib/utils"; import { TRPCClientError } from "@trpc/client"; @@ -136,7 +137,12 @@ export default function UploadDropzone({ setDragging(true)} + onDragEnter={(e) => { + // Don't show overlay for internal bookmark card drags + if (!e.dataTransfer.types.includes(BOOKMARK_DRAG_MIME)) { + setDragging(true); + } + }} onDragLeave={() => setDragging(false)} > {({ getRootProps, getInputProps }) => ( diff --git a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx index bf7fe2ad..f164b275 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx @@ -2,10 +2,11 @@ import type { BookmarksLayoutTypes } from "@/lib/userLocalSettings/types"; import type { ReactNode } from "react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import Image from "next/image"; import Link from "next/link"; import { useSession } from "@/lib/auth/client"; +import { BOOKMARK_DRAG_MIME } from "@/lib/bookmark-drag"; import useBulkActionsStore from "@/lib/bulkActions"; import { bookmarkLayoutSwitch, @@ -14,14 +15,22 @@ import { } from "@/lib/userLocalSettings/bookmarksLayout"; import { cn } from "@/lib/utils"; import { useQuery } from "@tanstack/react-query"; -import { Check, Image as ImageIcon, NotebookPen } from "lucide-react"; +import { + Check, + GripVertical, + Image as ImageIcon, + NotebookPen, +} from "lucide-react"; import { useTheme } from "next-themes"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; import { useBookmarkListContext } from "@karakeep/shared-react/hooks/bookmark-list-context"; import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; -import { isBookmarkStillTagging } from "@karakeep/shared/utils/bookmarkUtils"; +import { + getBookmarkTitle, + isBookmarkStillTagging, +} from "@karakeep/shared/utils/bookmarkUtils"; import { switchCase } from "@karakeep/shared/utils/switch"; import BookmarkActionBar from "./BookmarkActionBar"; @@ -155,6 +164,65 @@ function MultiBookmarkSelector({ bookmark }: { bookmark: ZBookmark }) { ); } +function DragHandle({ + bookmark, + className, +}: { + bookmark: ZBookmark; + className?: string; +}) { + const { isBulkEditEnabled } = useBulkActionsStore(); + const handleDragStart = useCallback( + (e: React.DragEvent) => { + e.stopPropagation(); + e.dataTransfer.setData(BOOKMARK_DRAG_MIME, bookmark.id); + e.dataTransfer.effectAllowed = "copy"; + + // Create a small pill element as the drag preview + const pill = document.createElement("div"); + const title = getBookmarkTitle(bookmark) ?? "Untitled"; + pill.textContent = + title.length > 40 ? title.substring(0, 40) + "\u2026" : title; + Object.assign(pill.style, { + position: "fixed", + left: "-9999px", + top: "-9999px", + padding: "6px 12px", + borderRadius: "8px", + backgroundColor: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + boxShadow: "0 4px 12px rgba(0,0,0,0.15)", + fontSize: "13px", + fontFamily: "inherit", + color: "hsl(var(--foreground))", + maxWidth: "240px", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }); + document.body.appendChild(pill); + e.dataTransfer.setDragImage(pill, 0, 0); + requestAnimationFrame(() => pill.remove()); + }, + [bookmark], + ); + + if (isBulkEditEnabled) return null; + + return ( +
+ +
+ ); +} + function ListView({ bookmark, image, @@ -180,6 +248,10 @@ function ListView({ > +
{image("list", cn("size-32 rounded-lg", imgFitClass))}
@@ -240,6 +312,7 @@ function GridView({ > + {img &&
{img}
}
@@ -278,6 +351,10 @@ function CompactView({ bookmark, title, footer, className }: Props) { > +
{bookmark.content.type === BookmarkTypes.LINK && diff --git a/apps/web/components/dashboard/sidebar/AllLists.tsx b/apps/web/components/dashboard/sidebar/AllLists.tsx index 7d67115c..d1099231 100644 --- a/apps/web/components/dashboard/sidebar/AllLists.tsx +++ b/apps/web/components/dashboard/sidebar/AllLists.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import SidebarItem from "@/components/shared/sidebar/SidebarItem"; @@ -10,6 +10,8 @@ import { CollapsibleContent, CollapsibleTriggerTriangle, } from "@/components/ui/collapsible"; +import { toast } from "@/components/ui/sonner"; +import { BOOKMARK_DRAG_MIME } from "@/lib/bookmark-drag"; import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; import { MoreHorizontal, Plus } from "lucide-react"; @@ -17,6 +19,7 @@ import { MoreHorizontal, Plus } from "lucide-react"; import type { ZBookmarkList } from "@karakeep/shared/types/lists"; import { augmentBookmarkListsWithInitialData, + useAddBookmarkToList, useBookmarkLists, } from "@karakeep/shared-react/hooks/lists"; import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils"; @@ -26,6 +29,146 @@ import { EditListModal } from "../lists/EditListModal"; import { ListOptions } from "../lists/ListOptions"; import { InvitationNotificationBadge } from "./InvitationNotificationBadge"; +function useDropTarget(listId: string, listName: string) { + const { mutateAsync: addToList } = useAddBookmarkToList(); + const [dropHighlight, setDropHighlight] = useState(false); + const dragCounterRef = useRef(0); + const { t } = useTranslation(); + + const onDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes(BOOKMARK_DRAG_MIME)) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + } + }, []); + + const onDragEnter = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes(BOOKMARK_DRAG_MIME)) { + e.preventDefault(); + dragCounterRef.current++; + setDropHighlight(true); + } + }, []); + + const onDragLeave = useCallback(() => { + dragCounterRef.current--; + if (dragCounterRef.current <= 0) { + dragCounterRef.current = 0; + setDropHighlight(false); + } + }, []); + + const onDrop = useCallback( + async (e: React.DragEvent) => { + dragCounterRef.current = 0; + setDropHighlight(false); + const bookmarkId = e.dataTransfer.getData(BOOKMARK_DRAG_MIME); + if (!bookmarkId) return; + e.preventDefault(); + try { + await addToList({ bookmarkId, listId }); + toast({ + description: t("lists.add_to_list_success", { + list: listName, + defaultValue: `Added to "${listName}"`, + }), + }); + } catch { + toast({ + description: t("common.something_went_wrong", { + defaultValue: "Something went wrong", + }), + variant: "destructive", + }); + } + }, + [addToList, listId, listName, t], + ); + + return { dropHighlight, onDragOver, onDragEnter, onDragLeave, onDrop }; +} + +function DroppableListSidebarItem({ + node, + level, + open, + numBookmarks, + selectedListId, + setSelectedListId, +}: { + node: ZBookmarkListTreeNode; + level: number; + open: boolean; + numBookmarks?: number; + selectedListId: string | null; + setSelectedListId: (id: string | null) => void; +}) { + const canDrop = + node.item.type === "manual" && + (node.item.userRole === "owner" || node.item.userRole === "editor"); + const { dropHighlight, onDragOver, onDragEnter, onDragLeave, onDrop } = + useDropTarget(node.item.id, node.item.name); + + return ( + 0 && ( + + ) + } + logo={ + + {node.item.icon} + + } + name={node.item.name} + path={`/dashboard/lists/${node.item.id}`} + className="group px-0.5" + right={ + { + if (isOpen) { + setSelectedListId(node.item.id); + } else { + setSelectedListId(null); + } + }} + list={node.item} + > + + + } + linkClassName="py-0.5" + style={{ marginLeft: `${level * 1}rem` }} + dropHighlight={canDrop && dropHighlight} + onDragOver={canDrop ? onDragOver : undefined} + onDragEnter={canDrop ? onDragEnter : undefined} + onDragLeave={canDrop ? onDragLeave : undefined} + onDrop={canDrop ? onDrop : undefined} + /> + ); +} + export default function AllLists({ initialData, }: { @@ -107,59 +250,13 @@ export default function AllLists({ filter={(node) => node.item.userRole === "owner"} isOpenFunc={isNodeOpen} render={({ node, level, open, numBookmarks }) => ( - 0 && ( - - ) - } - logo={ - - {node.item.icon} - - } - name={node.item.name} - path={`/dashboard/lists/${node.item.id}`} - className="group px-0.5" - right={ - { - if (isOpen) { - setSelectedListId(node.item.id); - } else { - setSelectedListId(null); - } - }} - list={node.item} - > - - - } - linkClassName="py-0.5" - style={{ marginLeft: `${level * 1}rem` }} + )} /> @@ -187,59 +284,13 @@ export default function AllLists({ isOpenFunc={isNodeOpen} indentOffset={1} render={({ node, level, open, numBookmarks }) => ( - 0 && ( - - ) - } - logo={ - - {node.item.icon} - - } - name={node.item.name} - path={`/dashboard/lists/${node.item.id}`} - className="group px-0.5" - right={ - { - if (isOpen) { - setSelectedListId(node.item.id); - } else { - setSelectedListId(null); - } - }} - list={node.item} - > - - - } - linkClassName="py-0.5" - style={{ marginLeft: `${level * 1}rem` }} + )} /> diff --git a/apps/web/components/shared/sidebar/SidebarItem.tsx b/apps/web/components/shared/sidebar/SidebarItem.tsx index e602a435..eb61d48b 100644 --- a/apps/web/components/shared/sidebar/SidebarItem.tsx +++ b/apps/web/components/shared/sidebar/SidebarItem.tsx @@ -14,6 +14,11 @@ export default function SidebarItem({ style, collapseButton, right = null, + dropHighlight = false, + onDrop, + onDragOver, + onDragEnter, + onDragLeave, }: { name: string; logo: React.ReactNode; @@ -23,6 +28,11 @@ export default function SidebarItem({ linkClassName?: string; right?: React.ReactNode; collapseButton?: React.ReactNode; + dropHighlight?: boolean; + onDrop?: React.DragEventHandler; + onDragOver?: React.DragEventHandler; + onDragEnter?: React.DragEventHandler; + onDragLeave?: React.DragEventHandler; }) { const currentPath = usePathname(); return ( @@ -32,9 +42,14 @@ export default function SidebarItem({ path == currentPath ? "bg-accent/50 text-foreground" : "text-muted-foreground", + dropHighlight && "bg-accent ring-2 ring-primary", className, )} style={style} + onDrop={onDrop} + onDragOver={onDragOver} + onDragEnter={onDragEnter} + onDragLeave={onDragLeave} >
{collapseButton} -- cgit v1.2.3-70-g09d2