@@ -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