Skip to content

Commit fd81e94

Browse files
committed
frontend/projects/starred: dnd sort the visible starred project buttons
1 parent 7ce51a1 commit fd81e94

File tree

2 files changed

+225
-100
lines changed

2 files changed

+225
-100
lines changed

src/packages/frontend/projects/projects-starred.tsx

Lines changed: 203 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@
55

66
import { Avatar, Button, Dropdown, Space, Tooltip } from "antd";
77
import { 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

922
import { CSS, useActions, useTypedRedux } from "@cocalc/frontend/app-framework";
1023
import { 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+
30115
export 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
}

src/packages/frontend/projects/use-bookmarked-projects.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,13 +141,35 @@ export function useBookmarkedProjects() {
141141
}
142142
}
143143

144+
function setBookmarkedProjectsOrder(
145+
newBookmarkedProjects: BookmarkedProjects,
146+
) {
147+
if (!bookmarks || !isInitialized) {
148+
console.warn("Conat bookmarks not yet initialized");
149+
return;
150+
}
151+
152+
// Update local state immediately for responsive UI
153+
setBookmarkedProjects(newBookmarkedProjects);
154+
155+
// Store to conat (this will also trigger the change event for other clients)
156+
try {
157+
bookmarks.set(PROJECTS_KEY, newBookmarkedProjects);
158+
} catch (err) {
159+
console.warn(`conat bookmark storage warning -- ${err}`);
160+
// Revert local state on error
161+
setBookmarkedProjects(bookmarkedProjects);
162+
}
163+
}
164+
144165
function isProjectBookmarked(project_id: string): boolean {
145166
return bookmarkedProjects.includes(project_id);
146167
}
147168

148169
return {
149170
bookmarkedProjects,
150171
setProjectBookmarked,
172+
setBookmarkedProjectsOrder,
151173
isProjectBookmarked,
152174
isInitialized,
153175
};

0 commit comments

Comments
 (0)