From 485e9948b1d6d40df44a781c5133f6698b1f872b Mon Sep 17 00:00:00 2001
From: Mohamed Bassem <me@mbassem.com>
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 <noreply@anthropic.com>
---
 .../bookmarks/BookmarkLayoutAdaptingCard.tsx       | 83 +++++++++++++++++++++-
 1 file changed, 80 insertions(+), 3 deletions(-)

(limited to 'apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx')

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 &&
-- 
cgit v1.2.3-70-g09d2

