diff options
| author | Mohamed Bassem <me@mbassem.com> | 2026-02-08 23:34:06 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-08 23:34:06 +0000 |
| commit | 485e9948b1d6d40df44a781c5133f6698b1f872b (patch) | |
| tree | 5e2680e271dd85f260ae527c77ae35bd47dfbefb | |
| parent | c8464e303f6e7fba6b88c7f29c0570c2b49a494d (diff) | |
| download | karakeep-485e9948b1d6d40df44a781c5133f6698b1f872b.tar.zst | |
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 <noreply@anthropic.com>
| -rw-r--r-- | apps/web/components/dashboard/UploadDropzone.tsx | 8 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx | 83 | ||||
| -rw-r--r-- | apps/web/components/dashboard/sidebar/AllLists.tsx | 265 | ||||
| -rw-r--r-- | apps/web/components/shared/sidebar/SidebarItem.tsx | 15 | ||||
| -rw-r--r-- | apps/web/lib/bookmark-drag.ts | 5 |
5 files changed, 265 insertions, 111 deletions
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({ <DropZone noClick onDrop={onDrop} - onDragEnter={() => 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 ( + <div + draggable + onDragStart={handleDragStart} + className={cn( + "absolute z-40 cursor-grab rounded bg-background/70 p-0.5 opacity-0 shadow-sm transition-opacity duration-200 group-hover:opacity-100", + className, + )} + > + <GripVertical className="size-4 text-muted-foreground" /> + </div> + ); +} + function ListView({ bookmark, image, @@ -180,6 +248,10 @@ function ListView({ > <MultiBookmarkSelector bookmark={bookmark} /> <OwnerIndicator bookmark={bookmark} /> + <DragHandle + bookmark={bookmark} + className="left-1 top-1/2 -translate-y-1/2" + /> <div className="flex size-32 items-center justify-center overflow-hidden"> {image("list", cn("size-32 rounded-lg", imgFitClass))} </div> @@ -240,6 +312,7 @@ function GridView({ > <MultiBookmarkSelector bookmark={bookmark} /> <OwnerIndicator bookmark={bookmark} /> + <DragHandle bookmark={bookmark} className="left-2 top-2" /> {img && <div className="h-56 w-full shrink-0 overflow-hidden">{img}</div>} <div className="flex h-full flex-col justify-between gap-2 overflow-hidden p-2"> <div className="grow-1 flex flex-col gap-2 overflow-hidden"> @@ -278,6 +351,10 @@ function CompactView({ bookmark, title, footer, className }: Props) { > <MultiBookmarkSelector bookmark={bookmark} /> <OwnerIndicator bookmark={bookmark} /> + <DragHandle + bookmark={bookmark} + className="left-0.5 top-1/2 -translate-y-1/2" + /> <div className="flex h-full justify-between gap-2 overflow-hidden p-2"> <div className="flex items-center gap-2"> {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 ( + <SidebarItem + collapseButton={ + node.children.length > 0 && ( + <CollapsibleTriggerTriangle + className="absolute left-0.5 top-1/2 size-2 -translate-y-1/2" + open={open} + /> + ) + } + logo={ + <span className="flex"> + <span className="text-lg"> {node.item.icon}</span> + </span> + } + name={node.item.name} + path={`/dashboard/lists/${node.item.id}`} + className="group px-0.5" + right={ + <ListOptions + onOpenChange={(isOpen) => { + if (isOpen) { + setSelectedListId(node.item.id); + } else { + setSelectedListId(null); + } + }} + list={node.item} + > + <Button size="none" variant="ghost" className="relative"> + <MoreHorizontal + className={cn( + "absolute inset-0 m-auto size-4 opacity-0 transition-opacity duration-100 group-hover:opacity-100", + selectedListId == node.item.id ? "opacity-100" : "opacity-0", + )} + /> + <span + className={cn( + "px-2.5 text-xs font-light text-muted-foreground opacity-100 transition-opacity duration-100 group-hover:opacity-0", + selectedListId == node.item.id || numBookmarks === undefined + ? "opacity-0" + : "opacity-100", + )} + > + {numBookmarks} + </span> + </Button> + </ListOptions> + } + 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 }) => ( - <SidebarItem - collapseButton={ - node.children.length > 0 && ( - <CollapsibleTriggerTriangle - className="absolute left-0.5 top-1/2 size-2 -translate-y-1/2" - open={open} - /> - ) - } - logo={ - <span className="flex"> - <span className="text-lg"> {node.item.icon}</span> - </span> - } - name={node.item.name} - path={`/dashboard/lists/${node.item.id}`} - className="group px-0.5" - right={ - <ListOptions - onOpenChange={(isOpen) => { - if (isOpen) { - setSelectedListId(node.item.id); - } else { - setSelectedListId(null); - } - }} - list={node.item} - > - <Button size="none" variant="ghost" className="relative"> - <MoreHorizontal - className={cn( - "absolute inset-0 m-auto size-4 opacity-0 transition-opacity duration-100 group-hover:opacity-100", - selectedListId == node.item.id - ? "opacity-100" - : "opacity-0", - )} - /> - <span - className={cn( - "px-2.5 text-xs font-light text-muted-foreground opacity-100 transition-opacity duration-100 group-hover:opacity-0", - selectedListId == node.item.id || - numBookmarks === undefined - ? "opacity-0" - : "opacity-100", - )} - > - {numBookmarks} - </span> - </Button> - </ListOptions> - } - linkClassName="py-0.5" - style={{ marginLeft: `${level * 1}rem` }} + <DroppableListSidebarItem + node={node} + level={level} + open={open} + numBookmarks={numBookmarks} + selectedListId={selectedListId} + setSelectedListId={setSelectedListId} /> )} /> @@ -187,59 +284,13 @@ export default function AllLists({ isOpenFunc={isNodeOpen} indentOffset={1} render={({ node, level, open, numBookmarks }) => ( - <SidebarItem - collapseButton={ - node.children.length > 0 && ( - <CollapsibleTriggerTriangle - className="absolute left-0.5 top-1/2 size-2 -translate-y-1/2" - open={open} - /> - ) - } - logo={ - <span className="flex"> - <span className="text-lg"> {node.item.icon}</span> - </span> - } - name={node.item.name} - path={`/dashboard/lists/${node.item.id}`} - className="group px-0.5" - right={ - <ListOptions - onOpenChange={(isOpen) => { - if (isOpen) { - setSelectedListId(node.item.id); - } else { - setSelectedListId(null); - } - }} - list={node.item} - > - <Button size="none" variant="ghost" className="relative"> - <MoreHorizontal - className={cn( - "absolute inset-0 m-auto size-4 opacity-0 transition-opacity duration-100 group-hover:opacity-100", - selectedListId == node.item.id - ? "opacity-100" - : "opacity-0", - )} - /> - <span - className={cn( - "px-2.5 text-xs font-light text-muted-foreground opacity-100 transition-opacity duration-100 group-hover:opacity-0", - selectedListId == node.item.id || - numBookmarks === undefined - ? "opacity-0" - : "opacity-100", - )} - > - {numBookmarks} - </span> - </Button> - </ListOptions> - } - linkClassName="py-0.5" - style={{ marginLeft: `${level * 1}rem` }} + <DroppableListSidebarItem + node={node} + level={level} + open={open} + numBookmarks={numBookmarks} + selectedListId={selectedListId} + setSelectedListId={setSelectedListId} /> )} /> 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} > <div className="flex-1"> {collapseButton} diff --git a/apps/web/lib/bookmark-drag.ts b/apps/web/lib/bookmark-drag.ts new file mode 100644 index 00000000..8ae4a499 --- /dev/null +++ b/apps/web/lib/bookmark-drag.ts @@ -0,0 +1,5 @@ +/** + * MIME type used in HTML5 drag-and-drop dataTransfer to identify + * bookmark card drags (as opposed to file drops). + */ +export const BOOKMARK_DRAG_MIME = "application/x-karakeep-bookmark"; |
