aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2026-02-08 23:34:06 +0000
committerGitHub <noreply@github.com>2026-02-08 23:34:06 +0000
commit485e9948b1d6d40df44a781c5133f6698b1f872b (patch)
tree5e2680e271dd85f260ae527c77ae35bd47dfbefb
parentc8464e303f6e7fba6b88c7f29c0570c2b49a494d (diff)
downloadkarakeep-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.tsx8
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx83
-rw-r--r--apps/web/components/dashboard/sidebar/AllLists.tsx265
-rw-r--r--apps/web/components/shared/sidebar/SidebarItem.tsx15
-rw-r--r--apps/web/lib/bookmark-drag.ts5
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";