Skip to content

Commit 4c67a67

Browse files
feat(media): implement drag and drop for file management
1 parent e82b92c commit 4c67a67

File tree

1 file changed

+215
-23
lines changed

1 file changed

+215
-23
lines changed

apps/web/src/routes/admin/media/index.tsx

Lines changed: 215 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ function MediaLibrary() {
9696
const [rootLoaded, setRootLoaded] = useState(false);
9797
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
9898
const [dragOver, setDragOver] = useState(false);
99+
const [draggingItem, setDraggingItem] = useState<MediaItem | null>(null);
100+
const [dropTargetPath, setDropTargetPath] = useState<string | null>(null);
99101
const fileInputRef = useRef<HTMLInputElement>(null);
100102
const [isMounted, setIsMounted] = useState(false);
101103
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
@@ -407,6 +409,33 @@ function MediaLibrary() {
407409
moveMutation.mutate({ fromPath: itemToMove.path, toPath });
408410
};
409411

412+
const handleDragItemStart = (item: MediaItem) => {
413+
setDraggingItem(item);
414+
};
415+
416+
const handleDragItemEnd = () => {
417+
setDraggingItem(null);
418+
setDropTargetPath(null);
419+
};
420+
421+
const handleDropOnFolder = (targetFolderPath: string) => {
422+
if (!draggingItem) return;
423+
if (draggingItem.path === targetFolderPath) return;
424+
if (draggingItem.path.startsWith(targetFolderPath + "/")) return;
425+
426+
const fileName = draggingItem.path.split("/").pop() || "";
427+
const toPath = targetFolderPath
428+
? `${targetFolderPath}/${fileName}`
429+
: fileName;
430+
431+
if (draggingItem.path !== toPath) {
432+
moveMutation.mutate({ fromPath: draggingItem.path, toPath });
433+
}
434+
435+
setDraggingItem(null);
436+
setDropTargetPath(null);
437+
};
438+
410439
const openMoveModal = (item: MediaItem) => {
411440
setItemToMove(item);
412441
setShowMoveModal(true);
@@ -579,6 +608,12 @@ function MediaLibrary() {
579608
canNavigateForward={historyIndex < navigationHistory.length - 1}
580609
onNavigateBack={handleNavigateBack}
581610
onNavigateForward={handleNavigateForward}
611+
draggingItem={draggingItem}
612+
dropTargetPath={dropTargetPath}
613+
onDragItemStart={handleDragItemStart}
614+
onDragItemEnd={handleDragItemEnd}
615+
onDropOnFolder={handleDropOnFolder}
616+
onSetDropTarget={setDropTargetPath}
582617
/>
583618
</ResizablePanel>
584619
</ResizablePanelGroup>
@@ -1044,6 +1079,12 @@ function ContentPanel({
10441079
canNavigateForward,
10451080
onNavigateBack,
10461081
onNavigateForward,
1082+
draggingItem,
1083+
dropTargetPath,
1084+
onDragItemStart,
1085+
onDragItemEnd,
1086+
onDropOnFolder,
1087+
onSetDropTarget,
10471088
}: {
10481089
tabs: Tab[];
10491090
currentTab: Tab | undefined;
@@ -1080,6 +1121,12 @@ function ContentPanel({
10801121
canNavigateForward: boolean;
10811122
onNavigateBack: () => void;
10821123
onNavigateForward: () => void;
1124+
draggingItem: MediaItem | null;
1125+
dropTargetPath: string | null;
1126+
onDragItemStart: (item: MediaItem) => void;
1127+
onDragItemEnd: () => void;
1128+
onDropOnFolder: (targetFolderPath: string) => void;
1129+
onSetDropTarget: (path: string | null) => void;
10831130
}) {
10841131
return (
10851132
<div className="h-full flex flex-col overflow-hidden">
@@ -1121,6 +1168,10 @@ function ContentPanel({
11211168
canNavigateForward={canNavigateForward}
11221169
onNavigateBack={onNavigateBack}
11231170
onNavigateForward={onNavigateForward}
1171+
draggingItem={draggingItem}
1172+
dropTargetPath={dropTargetPath}
1173+
onDropOnFolder={onDropOnFolder}
1174+
onSetDropTarget={onSetDropTarget}
11241175
/>
11251176

11261177
<div className="flex-1 overflow-hidden">
@@ -1142,6 +1193,12 @@ function ContentPanel({
11421193
onOpenFolder={onOpenFolder}
11431194
onMove={onMove}
11441195
onRename={onRename}
1196+
draggingItem={draggingItem}
1197+
dropTargetPath={dropTargetPath}
1198+
onDragItemStart={onDragItemStart}
1199+
onDragItemEnd={onDragItemEnd}
1200+
onDropOnFolder={onDropOnFolder}
1201+
onSetDropTarget={onSetDropTarget}
11451202
/>
11461203
) : (
11471204
<FilePreview
@@ -1381,6 +1438,10 @@ function HeaderBar({
13811438
canNavigateForward,
13821439
onNavigateBack,
13831440
onNavigateForward,
1441+
draggingItem,
1442+
dropTargetPath,
1443+
onDropOnFolder,
1444+
onSetDropTarget,
13841445
}: {
13851446
currentTab: Tab;
13861447
selectedItems: Set<string>;
@@ -1402,6 +1463,10 @@ function HeaderBar({
14021463
canNavigateForward: boolean;
14031464
onNavigateBack: () => void;
14041465
onNavigateForward: () => void;
1466+
draggingItem: MediaItem | null;
1467+
dropTargetPath: string | null;
1468+
onDropOnFolder: (targetFolderPath: string) => void;
1469+
onSetDropTarget: (path: string | null) => void;
14051470
}) {
14061471
const replaceFileInputRef = useRef<HTMLInputElement>(null);
14071472
const [showAddMenu, setShowAddMenu] = useState(false);
@@ -1441,32 +1506,77 @@ function HeaderBar({
14411506
<ChevronRightIcon className="size-4" />
14421507
</button>
14431508
</div>
1444-
{breadcrumbs.length === 0 ? (
1445-
<span className="text-neutral-700 font-medium">Home</span>
1446-
) : (
1447-
breadcrumbs.map((crumb, index) => {
1448-
const isLast = index === breadcrumbs.length - 1;
1449-
const folderPath = breadcrumbs.slice(0, index + 1).join("/");
1450-
return (
1451-
<span key={index} className="flex items-center gap-1">
1452-
{index > 0 && (
1453-
<ChevronRightIcon className="size-4 text-neutral-300" />
1454-
)}
1455-
{isLast ? (
1456-
<span className="text-neutral-700 font-medium">{crumb}</span>
1457-
) : (
1509+
<span
1510+
className={cn([
1511+
"px-1.5 py-0.5 rounded transition-colors",
1512+
draggingItem &&
1513+
dropTargetPath === "" &&
1514+
"bg-blue-100 ring-2 ring-blue-400",
1515+
draggingItem && "cursor-copy",
1516+
])}
1517+
onDragOver={(e) => {
1518+
if (!draggingItem) return;
1519+
e.preventDefault();
1520+
onSetDropTarget("");
1521+
}}
1522+
onDragLeave={() => onSetDropTarget(null)}
1523+
onDrop={(e) => {
1524+
e.preventDefault();
1525+
onDropOnFolder("");
1526+
}}
1527+
>
1528+
<button
1529+
type="button"
1530+
onClick={() => onOpenFolder("", "Home")}
1531+
className={cn([
1532+
"text-neutral-700 font-medium",
1533+
breadcrumbs.length > 0 && "hover:text-neutral-900",
1534+
])}
1535+
>
1536+
Home
1537+
</button>
1538+
</span>
1539+
{breadcrumbs.map((crumb, index) => {
1540+
const isLast = index === breadcrumbs.length - 1;
1541+
const folderPath = breadcrumbs.slice(0, index + 1).join("/");
1542+
const isDropTarget = draggingItem && dropTargetPath === folderPath;
1543+
return (
1544+
<span key={index} className="flex items-center gap-1">
1545+
<ChevronRightIcon className="size-4 text-neutral-300" />
1546+
{isLast ? (
1547+
<span className="text-neutral-700 font-medium px-1.5 py-0.5">
1548+
{crumb}
1549+
</span>
1550+
) : (
1551+
<span
1552+
className={cn([
1553+
"px-1.5 py-0.5 rounded transition-colors",
1554+
isDropTarget && "bg-blue-100 ring-2 ring-blue-400",
1555+
draggingItem && "cursor-copy",
1556+
])}
1557+
onDragOver={(e) => {
1558+
if (!draggingItem) return;
1559+
e.preventDefault();
1560+
onSetDropTarget(folderPath);
1561+
}}
1562+
onDragLeave={() => onSetDropTarget(null)}
1563+
onDrop={(e) => {
1564+
e.preventDefault();
1565+
onDropOnFolder(folderPath);
1566+
}}
1567+
>
14581568
<button
14591569
type="button"
14601570
onClick={() => onOpenFolder(folderPath, crumb)}
1461-
className="hover:text-neutral-700 cursor-pointer"
1571+
className="hover:text-neutral-700"
14621572
>
14631573
{crumb}
14641574
</button>
1465-
)}
1466-
</span>
1467-
);
1468-
})
1469-
)}
1575+
</span>
1576+
)}
1577+
</span>
1578+
);
1579+
})}
14701580
{currentFile && (
14711581
<span className="text-xs text-neutral-400 ml-2">
14721582
{formatFileSize(currentFile.size)}{currentFile.mimeType}
@@ -1625,6 +1735,12 @@ function FolderView({
16251735
onOpenFolder,
16261736
onMove,
16271737
onRename,
1738+
draggingItem,
1739+
dropTargetPath,
1740+
onDragItemStart,
1741+
onDragItemEnd,
1742+
onDropOnFolder,
1743+
onSetDropTarget,
16281744
}: {
16291745
dragOver: boolean;
16301746
onDrop: (e: React.DragEvent) => void;
@@ -1642,6 +1758,12 @@ function FolderView({
16421758
onOpenFolder: (path: string, name: string) => void;
16431759
onMove: (item: MediaItem) => void;
16441760
onRename: (path: string, newName: string) => void;
1761+
draggingItem: MediaItem | null;
1762+
dropTargetPath: string | null;
1763+
onDragItemStart: (item: MediaItem) => void;
1764+
onDragItemEnd: () => void;
1765+
onDropOnFolder: (targetFolderPath: string) => void;
1766+
onSetDropTarget: (path: string | null) => void;
16451767
}) {
16461768
return (
16471769
<div
@@ -1689,6 +1811,19 @@ function FolderView({
16891811
onOpenFolder={() => onOpenFolder(item.path, item.name)}
16901812
onMove={() => onMove(item)}
16911813
onRename={(newName) => onRename(item.path, newName)}
1814+
isDragging={draggingItem?.path === item.path}
1815+
isDropTarget={item.type === "dir" && dropTargetPath === item.path}
1816+
onDragStart={() => onDragItemStart(item)}
1817+
onDragEnd={onDragItemEnd}
1818+
onDropOnFolder={() => onDropOnFolder(item.path)}
1819+
onSetDropTarget={(isOver) =>
1820+
onSetDropTarget(isOver ? item.path : null)
1821+
}
1822+
canDrop={
1823+
item.type === "dir" &&
1824+
draggingItem !== null &&
1825+
draggingItem.path !== item.path
1826+
}
16921827
/>
16931828
))}
16941829
</div>
@@ -1708,6 +1843,13 @@ function MediaItemCard({
17081843
onOpenFolder,
17091844
onMove,
17101845
onRename,
1846+
isDragging,
1847+
isDropTarget,
1848+
onDragStart,
1849+
onDragEnd,
1850+
onDropOnFolder,
1851+
onSetDropTarget,
1852+
canDrop,
17111853
}: {
17121854
item: MediaItem;
17131855
isSelected: boolean;
@@ -1719,6 +1861,13 @@ function MediaItemCard({
17191861
onOpenFolder: () => void;
17201862
onMove: () => void;
17211863
onRename: (newName: string) => void;
1864+
isDragging: boolean;
1865+
isDropTarget: boolean;
1866+
onDragStart: () => void;
1867+
onDragEnd: () => void;
1868+
onDropOnFolder: () => void;
1869+
onSetDropTarget: (isOver: boolean) => void;
1870+
canDrop: boolean;
17221871
}) {
17231872
const fileInputRef = useRef<HTMLInputElement>(null);
17241873
const renameInputRef = useRef<HTMLInputElement>(null);
@@ -1792,17 +1941,52 @@ function MediaItemCard({
17921941
if (item.type === "dir") {
17931942
return (
17941943
<div
1944+
draggable={!isRenaming}
1945+
onDragStart={(e) => {
1946+
e.dataTransfer.effectAllowed = "move";
1947+
e.dataTransfer.setData("text/plain", item.path);
1948+
onDragStart();
1949+
}}
1950+
onDragEnd={onDragEnd}
1951+
onDragOver={(e) => {
1952+
if (!canDrop) return;
1953+
e.preventDefault();
1954+
e.stopPropagation();
1955+
onSetDropTarget(true);
1956+
}}
1957+
onDragLeave={(e) => {
1958+
e.stopPropagation();
1959+
onSetDropTarget(false);
1960+
}}
1961+
onDrop={(e) => {
1962+
e.preventDefault();
1963+
e.stopPropagation();
1964+
onDropOnFolder();
1965+
}}
17951966
className={cn([
17961967
"group relative rounded-lg border overflow-hidden cursor-pointer transition-all",
17971968
isSelected
17981969
? "border-blue-500 ring-2 ring-blue-500"
1799-
: "border-neutral-200 hover:border-neutral-300 hover:shadow-md",
1970+
: isDropTarget
1971+
? "border-blue-400 ring-2 ring-blue-400 bg-blue-50"
1972+
: "border-neutral-200 hover:border-neutral-300 hover:shadow-md",
1973+
isDragging && "opacity-50",
18001974
])}
18011975
onClick={isRenaming ? undefined : onOpenFolder}
18021976
onContextMenu={handleContextMenu}
18031977
>
1804-
<div className="aspect-square bg-neutral-100 flex items-center justify-center">
1805-
<FolderIcon className="size-12 text-neutral-400" />
1978+
<div
1979+
className={cn([
1980+
"aspect-square flex items-center justify-center",
1981+
isDropTarget ? "bg-blue-100" : "bg-neutral-100",
1982+
])}
1983+
>
1984+
<FolderIcon
1985+
className={cn([
1986+
"size-12",
1987+
isDropTarget ? "text-blue-500" : "text-neutral-400",
1988+
])}
1989+
/>
18061990
</div>
18071991
<div className="p-2 bg-white">
18081992
{isRenaming ? (
@@ -1952,11 +2136,19 @@ function MediaItemCard({
19522136

19532137
return (
19542138
<div
2139+
draggable={!isRenaming}
2140+
onDragStart={(e) => {
2141+
e.dataTransfer.effectAllowed = "move";
2142+
e.dataTransfer.setData("text/plain", item.path);
2143+
onDragStart();
2144+
}}
2145+
onDragEnd={onDragEnd}
19552146
className={cn([
19562147
"group relative rounded-lg border overflow-hidden cursor-pointer transition-all",
19572148
isSelected
19582149
? "border-blue-500 ring-2 ring-blue-500"
19592150
: "border-neutral-200 hover:border-neutral-300 hover:shadow-md",
2151+
isDragging && "opacity-50",
19602152
])}
19612153
onClick={onOpenPreview}
19622154
onContextMenu={handleContextMenu}

0 commit comments

Comments
 (0)