55
66import { Avatar , Button , Dropdown , Space , Tooltip } from "antd" ;
77import { useCallback , useLayoutEffect , useMemo , useRef , useState } from "react" ;
8+ import {
9+ DndContext ,
10+ DragEndEvent ,
11+ MouseSensor ,
12+ TouchSensor ,
13+ useSensor ,
14+ useSensors ,
15+ } from "@dnd-kit/core" ;
16+ import {
17+ SortableContext ,
18+ horizontalListSortingStrategy ,
19+ useSortable ,
20+ } from "@dnd-kit/sortable" ;
821
922import { CSS , useActions , useTypedRedux } from "@cocalc/frontend/app-framework" ;
1023import { Icon , TimeAgo } from "@cocalc/frontend/components" ;
@@ -27,9 +40,82 @@ const STARRED_BUTTON_STYLE: CSS = {
2740 whiteSpace : "nowrap" ,
2841} as const ;
2942
43+ function DraggableProjectButton ( {
44+ project,
45+ showTooltip = true ,
46+ visibility,
47+ isOverlay = false ,
48+ onProjectClick,
49+ renderTooltipContent,
50+ } : {
51+ project : any ;
52+ showTooltip ?: boolean ;
53+ visibility ?: "hidden" | "visible" ;
54+ isOverlay ?: boolean ;
55+ onProjectClick : (
56+ project_id : string ,
57+ e : React . MouseEvent < HTMLElement > ,
58+ ) => void ;
59+ renderTooltipContent : ( project : any ) => React . ReactNode ;
60+ } ) {
61+ const { attributes, listeners, setNodeRef, transform, isDragging } =
62+ useSortable ( { id : project . project_id } ) ;
63+
64+ const buttonStyle = {
65+ ...STARRED_BUTTON_STYLE ,
66+ ...( project . color && { borderColor : project . color , borderWidth : 2 } ) ,
67+ ...( visibility && { visibility } ) ,
68+ ...( isDragging && ! isOverlay && { opacity : 0.5 } ) ,
69+ transform : transform
70+ ? `translate3d(${ transform . x } px, ${ transform . y } px, 0)`
71+ : undefined ,
72+ } as const ;
73+
74+ const button = (
75+ < Button
76+ className = "starred-project-button"
77+ style = { buttonStyle }
78+ icon = {
79+ project . avatar_image_tiny ? (
80+ < Avatar src = { project . avatar_image_tiny } size = { 20 } />
81+ ) : (
82+ < Icon name = "star-filled" style = { { color : COLORS . STAR } } />
83+ )
84+ }
85+ onClick = { ( e ) => onProjectClick ( project . project_id , e ) }
86+ onMouseDown = { ( e ) => {
87+ // Support middle-click
88+ if ( e . button === 1 ) {
89+ onProjectClick ( project . project_id , e ) ;
90+ }
91+ } }
92+ { ...attributes }
93+ { ...listeners }
94+ ref = { setNodeRef }
95+ >
96+ { trunc ( project . title , 15 ) }
97+ </ Button >
98+ ) ;
99+
100+ if ( ! showTooltip ) {
101+ return button ;
102+ }
103+
104+ return (
105+ < Tooltip
106+ key = { project . project_id }
107+ title = { renderTooltipContent ( project ) }
108+ placement = "bottom"
109+ >
110+ { button }
111+ </ Tooltip >
112+ ) ;
113+ }
114+
30115export function StarredProjectsBar ( ) {
31116 const actions = useActions ( "projects" ) ;
32- const { bookmarkedProjects } = useBookmarkedProjects ( ) ;
117+ const { bookmarkedProjects, setBookmarkedProjectsOrder } =
118+ useBookmarkedProjects ( ) ;
33119 const project_map = useTypedRedux ( "projects" , "project_map" ) ;
34120
35121 // Get starred projects in bookmarked order (newest bookmarked first)
@@ -57,6 +143,15 @@ export function StarredProjectsBar() {
57143 return projects ;
58144 } , [ bookmarkedProjects , project_map ] ) ;
59145
146+ // Drag and drop sensors
147+ const mouseSensor = useSensor ( MouseSensor , {
148+ activationConstraint : { distance : 5 } , // 5px to activate drag
149+ } ) ;
150+ const touchSensor = useSensor ( TouchSensor , {
151+ activationConstraint : { delay : 100 , tolerance : 5 } ,
152+ } ) ;
153+ const sensors = useSensors ( mouseSensor , touchSensor ) ;
154+
60155 // State for tracking how many projects can be shown
61156 const [ visibleCount , setVisibleCount ] = useState < number > ( 0 ) ;
62157 const [ measurementPhase , setMeasurementPhase ] = useState < boolean > ( true ) ;
@@ -184,6 +279,38 @@ export function StarredProjectsBar() {
184279 } ;
185280 } , [ calculateVisibleCount ] ) ;
186281
282+ const handleDragEnd = useCallback (
283+ ( event : DragEndEvent ) => {
284+ const { active, over } = event ;
285+
286+ if ( ! over || active . id === over . id ) {
287+ return ;
288+ }
289+
290+ // Find the indices of the dragged and target items
291+ const activeIndex = starredProjects . findIndex (
292+ ( p ) => p . project_id === active . id ,
293+ ) ;
294+ const overIndex = starredProjects . findIndex (
295+ ( p ) => p . project_id === over . id ,
296+ ) ;
297+
298+ if ( activeIndex === - 1 || overIndex === - 1 ) {
299+ return ;
300+ }
301+
302+ // Create new ordered list
303+ const newProjects = [ ...starredProjects ] ;
304+ const [ movedProject ] = newProjects . splice ( activeIndex , 1 ) ;
305+ newProjects . splice ( overIndex , 0 , movedProject ) ;
306+
307+ // Update bookmarked projects with new order
308+ const newBookmarkedOrder = newProjects . map ( ( p ) => p . project_id ) ;
309+ setBookmarkedProjectsOrder ( newBookmarkedOrder ) ;
310+ } ,
311+ [ starredProjects , setBookmarkedProjectsOrder ] ,
312+ ) ;
313+
187314 const handleProjectClick = (
188315 project_id : string ,
189316 e : React . MouseEvent < HTMLElement > ,
@@ -224,56 +351,6 @@ export function StarredProjectsBar() {
224351 ) ;
225352 } ;
226353
227- // Helper to render a project button
228- function renderProjectButton (
229- project : any ,
230- showTooltip : boolean = true ,
231- visibility ?: "hidden" | "visible" ,
232- ) {
233- const buttonStyle = {
234- ...STARRED_BUTTON_STYLE ,
235- ...( project . color && { borderColor : project . color , borderWidth : 2 } ) ,
236- ...( visibility && { visibility } ) ,
237- } as const ;
238-
239- const button = (
240- < Button
241- className = "starred-project-button"
242- style = { buttonStyle }
243- icon = {
244- project . avatar_image_tiny ? (
245- < Avatar src = { project . avatar_image_tiny } size = { 20 } />
246- ) : (
247- < Icon name = "star-filled" style = { { color : COLORS . STAR } } />
248- )
249- }
250- onClick = { ( e ) => handleProjectClick ( project . project_id , e ) }
251- onMouseDown = { ( e ) => {
252- // Support middle-click
253- if ( e . button === 1 ) {
254- handleProjectClick ( project . project_id , e ) ;
255- }
256- } }
257- >
258- { trunc ( project . title , 15 ) }
259- </ Button >
260- ) ;
261-
262- if ( ! showTooltip ) {
263- return button ;
264- }
265-
266- return (
267- < Tooltip
268- key = { project . project_id }
269- title = { renderTooltipContent ( project ) }
270- placement = "bottom"
271- >
272- { button }
273- </ Tooltip >
274- ) ;
275- }
276-
277354 // Create dropdown menu items for overflow projects
278355 const overflowMenuItems = overflowProjects . map ( ( project ) => ( {
279356 key : project . project_id ,
@@ -286,7 +363,10 @@ export function StarredProjectsBar() {
286363 project . color ? project . color : "transparent"
287364 } `,
288365 } }
289- onClick = { ( e ) => handleProjectClick ( project . project_id , e as any ) }
366+ onClick = { ( e ) => {
367+ e . stopPropagation ( ) ;
368+ handleProjectClick ( project . project_id , e as any ) ;
369+ } }
290370 >
291371 < span
292372 style = { {
@@ -308,60 +388,83 @@ export function StarredProjectsBar() {
308388 ) ,
309389 } ) ) ;
310390
391+ // Get all project IDs for SortableContext
392+ const allProjectIds = starredProjects . map ( ( p ) => p . project_id ) ;
393+
311394 return (
312- < div
313- ref = { containerRef }
314- style = { {
315- ...STARRED_BAR_STYLE ,
316- minHeight : containerHeight > 0 ? `${ containerHeight } px` : undefined ,
317- } }
318- >
319- { /* Hidden measurement container - rendered off-screen so it doesn't cause visual flicker */ }
320- { measurementPhase && (
395+ < DndContext onDragEnd = { handleDragEnd } sensors = { sensors } >
396+ < SortableContext
397+ items = { allProjectIds }
398+ strategy = { horizontalListSortingStrategy }
399+ >
321400 < div
322- ref = { measurementContainerRef }
401+ ref = { containerRef }
323402 style = { {
324- position : "fixed" ,
325- visibility : "hidden" ,
326- width : containerRef . current ?. offsetWidth ?? "100%" ,
327- display : "flex" ,
328- gap : "8px" ,
329- pointerEvents : "none" ,
330- top : - 9999 ,
331- left : - 9999 ,
403+ ...STARRED_BAR_STYLE ,
404+ minHeight : containerHeight > 0 ? `${ containerHeight } px` : undefined ,
332405 } }
333406 >
334- { starredProjects . map ( ( project ) =>
335- renderProjectButton ( project , false , "visible" ) ,
407+ { /* Hidden measurement container - rendered off-screen so it doesn't cause visual flicker */ }
408+ { measurementPhase && (
409+ < div
410+ ref = { measurementContainerRef }
411+ style = { {
412+ position : "fixed" ,
413+ visibility : "hidden" ,
414+ width : containerRef . current ?. offsetWidth ?? "100%" ,
415+ display : "flex" ,
416+ gap : "8px" ,
417+ pointerEvents : "none" ,
418+ top : - 9999 ,
419+ left : - 9999 ,
420+ } }
421+ >
422+ { starredProjects . map ( ( project ) => (
423+ < DraggableProjectButton
424+ key = { project . project_id }
425+ project = { project }
426+ showTooltip = { false }
427+ visibility = "visible"
428+ onProjectClick = { handleProjectClick }
429+ renderTooltipContent = { renderTooltipContent }
430+ />
431+ ) ) }
432+ </ div >
336433 ) }
337- </ div >
338- ) }
339-
340- { /* Actual visible content - only rendered after measurement phase */ }
341- < Space size = "small" ref = { spaceRef } >
342- { ! measurementPhase && (
343- < >
344- { starredProjects
345- . slice ( 0 , visibleCount )
346- . map ( ( project ) => renderProjectButton ( project ) ) }
347- { /* Show overflow dropdown if there are hidden projects */ }
348- { overflowProjects . length > 0 && (
349- < Dropdown
350- menu = { { items : overflowMenuItems } }
351- placement = "bottomRight"
352- trigger = { [ "click" ] }
353- >
354- < Button
355- icon = { < Icon name = "ellipsis" /> }
356- style = { { backgroundColor : "white" , marginLeft : "auto" } }
357- >
358- +{ overflowProjects . length }
359- </ Button >
360- </ Dropdown >
434+
435+ { /* Actual visible content - only rendered after measurement phase */ }
436+ < Space size = "small" ref = { spaceRef } >
437+ { ! measurementPhase && (
438+ < >
439+ { starredProjects . slice ( 0 , visibleCount ) . map ( ( project ) => (
440+ < DraggableProjectButton
441+ key = { project . project_id }
442+ project = { project }
443+ showTooltip = { true }
444+ onProjectClick = { handleProjectClick }
445+ renderTooltipContent = { renderTooltipContent }
446+ />
447+ ) ) }
448+ { /* Show overflow dropdown if there are hidden projects */ }
449+ { overflowProjects . length > 0 && (
450+ < Dropdown
451+ menu = { { items : overflowMenuItems } }
452+ placement = "bottomRight"
453+ trigger = { [ "click" ] }
454+ >
455+ < Button
456+ icon = { < Icon name = "ellipsis" /> }
457+ style = { { backgroundColor : "white" , marginLeft : "auto" } }
458+ >
459+ +{ overflowProjects . length }
460+ </ Button >
461+ </ Dropdown >
462+ ) }
463+ </ >
361464 ) }
362- </ >
363- ) }
364- </ Space >
365- </ div >
465+ </ Space >
466+ </ div >
467+ </ SortableContext >
468+ </ DndContext >
366469 ) ;
367470}
0 commit comments