feat(web): drag-to-reorder projects in sidebar
- Add @dnd-kit/core@6.3.1 + sortable@8.0.0 for mouse/touch drag reorder - SortableGroup.tsx: sortable wrapper with GripVertical handle icon - project-sidebar-order.ts: localStorage persistence (rakazo:project-sidebar-order) - Shell.tsx: DndContext + SortableContext around project sidebar - Folder icon replaces Lock icon for project entries
This commit is contained in:
parent
e70f0bd414
commit
476c1ffc18
@ -1,10 +1,8 @@
|
||||
/**
|
||||
* Sortable wrapper for a project (space) sidebar group card.
|
||||
*
|
||||
* Wraps the card in @dnd-kit's useSortable so it can be dragged to reorder.
|
||||
* Renders a grip handle icon on the title row.
|
||||
* Renders a grip handle icon on the title row for drag-to-reorder.
|
||||
*/
|
||||
import { CSS as DndCSS } from "@dnd-kit/utilities";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { memo, useMemo } from "react";
|
||||
@ -16,18 +14,11 @@ interface SortableGroupProps {
|
||||
}
|
||||
|
||||
export const SortableGroup = memo(function SortableGroup({ group, children }: SortableGroupProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: group.spaceId });
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.spaceId });
|
||||
|
||||
const style: React.CSSProperties = useMemo(
|
||||
() => ({
|
||||
transform: DndCSS.Translate.toString(transform),
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : undefined,
|
||||
zIndex: isDragging ? 1000 : undefined,
|
||||
@ -38,7 +29,6 @@ export const SortableGroup = memo(function SortableGroup({ group, children }: So
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} data-drag-id={group.spaceId}>
|
||||
{children}
|
||||
{/* Invisible drag handle — left edge of the title row */}
|
||||
{group.title ? (
|
||||
<button
|
||||
{...attributes}
|
||||
@ -47,8 +37,7 @@ export const SortableGroup = memo(function SortableGroup({ group, children }: So
|
||||
className={cn(
|
||||
"absolute left-0 top-3 w-7 h-7 rounded-md flex items-center justify-center",
|
||||
"text-muted-foreground/30 hover:text-muted-foreground/70 active:text-muted-foreground",
|
||||
"transition-colors cursor-grab active:cursor-grabbing",
|
||||
"z-10",
|
||||
"transition-colors cursor-grab active:cursor-grabbing z-10",
|
||||
)}
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
/**
|
||||
* Persisted project (space) sort order in the sidebar.
|
||||
*
|
||||
* Stores a JSON array of space IDs. When the array is present, it
|
||||
* overrides the natural API order; an empty or missing array means
|
||||
* “use API order”.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "rakazo:project-sidebar-order";
|
||||
@ -21,12 +17,9 @@ export function readProjectSidebarOrder(): string[] {
|
||||
|
||||
export function writeProjectSidebarOrder(ids: string[]): void {
|
||||
try {
|
||||
if (ids.length === 0) {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
} else {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
|
||||
}
|
||||
if (ids.length === 0) window.localStorage.removeItem(STORAGE_KEY);
|
||||
else window.localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
|
||||
} catch {
|
||||
// Ignore quota / private-mode failures
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,7 +32,6 @@ import {
|
||||
type MessageReaction,
|
||||
normalizeCreateBotProfile,
|
||||
} from "@rakazo/contracts";
|
||||
import { SortableGroup } from "../components/SortableGroup";
|
||||
import { readProjectSidebarOrder, writeProjectSidebarOrder } from "../lib/project-sidebar-order";
|
||||
import {
|
||||
attachmentsForThread,
|
||||
@ -86,7 +85,6 @@ import {
|
||||
} from "@rakazo/ui-web";
|
||||
import {
|
||||
ArrowDown,
|
||||
GripVertical,
|
||||
ArrowLeft,
|
||||
ArrowUp,
|
||||
Bell,
|
||||
@ -95,9 +93,11 @@ import {
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Copy,
|
||||
Gauge,
|
||||
Folder,
|
||||
Gauge,
|
||||
GripVertical,
|
||||
LayoutGrid,
|
||||
Lock,
|
||||
LogOut,
|
||||
Maximize2,
|
||||
Menu,
|
||||
@ -139,21 +139,10 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { DndContext, DragOverlay, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { MouseSensor, TouchSensor } from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS as DndCSS } from "@dnd-kit/utilities";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { ArtifactFileCard } from "../components/ArtifactFileCard";
|
||||
import { AskCard } from "../components/AskCard";
|
||||
@ -375,6 +364,8 @@ function stripSpaceTitlePrefix(title: string | null, spaceName: string): string
|
||||
return title;
|
||||
}
|
||||
|
||||
import { SortableGroup } from "../components/SortableGroup";
|
||||
|
||||
export function ShellPage() {
|
||||
const { t } = useLingui();
|
||||
const { botId, groupId } = useParams();
|
||||
@ -456,8 +447,6 @@ export function ShellPage() {
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Drag-to-reorder project sidebar state & sensors
|
||||
const [draggedProjectId, setDraggedProjectId] = useState<string | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
|
||||
@ -1616,7 +1605,7 @@ export function ShellPage() {
|
||||
.filter((group) => group.bots.length > 0);
|
||||
}, [homeSpaceId, sidebarGroups]);
|
||||
|
||||
const projectSidebarGroups = useMemo((): typeof sidebarGroups => {
|
||||
const projectSidebarGroups = useMemo(() => {
|
||||
if (!homeSpaceId) return [];
|
||||
const entries: typeof sidebarGroups = [];
|
||||
|
||||
@ -1664,29 +1653,26 @@ export function ShellPage() {
|
||||
if (projectOrder.length > 0) {
|
||||
const orderMap = new Map(entries.map((e, i) => [e.spaceId, i]));
|
||||
entries.sort((a, b) => {
|
||||
const aIdx = orderMap.get(a.spaceId) ?? -1;
|
||||
const bIdx = orderMap.get(b.spaceId) ?? -1;
|
||||
// Items in order come first, then remaining in original order
|
||||
const aInOrder = aIdx !== -1;
|
||||
const bInOrder = bIdx !== -1;
|
||||
if (aInOrder && bInOrder) return aIdx - bIdx;
|
||||
const aInOrder = orderMap.has(a.spaceId);
|
||||
const bInOrder = orderMap.has(b.spaceId);
|
||||
if (aInOrder && bInOrder) return (orderMap.get(a.spaceId) ?? 0) - (orderMap.get(b.spaceId) ?? 0);
|
||||
if (aInOrder) return -1;
|
||||
if (bInOrder) return 1;
|
||||
return orderMap.get(a.spaceId) ?? 0 - (orderMap.get(b.spaceId) ?? 0);
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}, [hauptthreadGroup?.id, homeSpaceId, sidebarGroups, spaces, projectOrder]);
|
||||
|
||||
// DnD handlers for project sidebar reordering
|
||||
// DnD handlers
|
||||
const handleProjectDragEnd = useCallback(
|
||||
(event: { active: { id: string | number }; over: { id: string | number } | null }) => {
|
||||
if (!event.over) return;
|
||||
const activeId = String(event.active.id);
|
||||
const overId = String(event.over.id);
|
||||
if (activeId === overId) return;
|
||||
const sorted = projectSidebarGroups as any[];
|
||||
const sorted: any[] = [...projectSidebarGroups];
|
||||
const activeIdx = sorted.findIndex((g) => g.spaceId === activeId);
|
||||
const overIdx = sorted.findIndex((g) => g.spaceId === overId);
|
||||
if (activeIdx === -1 || overIdx === -1) return;
|
||||
@ -2406,8 +2392,7 @@ export function ShellPage() {
|
||||
}, [clearVoiceEntryParam]);
|
||||
const loadOlder = useCallback(() => loadOlderMessagesRef.current(), []);
|
||||
const jumpToReplyMessage = useCallback((messageId: string) => {
|
||||
const escapedId = messageId.replace(/[^\w-]/g, (c) => `\\${c}`);
|
||||
const existing = document.querySelector(`[data-message-id="${escapedId}"]`);
|
||||
const existing = document.querySelector(`[data-message-id="${messageId.replace(/[^\w-]/g, (c) => `\\${c}`)}"]`);
|
||||
if (existing) {
|
||||
// Cancel any in-flight around-fetch so it cannot overwrite this scroll.
|
||||
jumpGeneration.current += 1;
|
||||
@ -3719,8 +3704,8 @@ export function ShellPage() {
|
||||
return (
|
||||
<SortableGroup key={`proj-${group.key}`} group={group}>
|
||||
<div data-sidebar-group={group.key}>
|
||||
{group.title ? (
|
||||
<div className="flex items-center pt-3 pb-0.5">
|
||||
{group.title ? (
|
||||
<div className="flex items-center pt-3 pb-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2.5 py-1 text-ui-label font-semibold tracking-wider uppercase text-muted-foreground/60 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
|
||||
@ -3778,7 +3763,13 @@ export function ShellPage() {
|
||||
}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5 truncate">
|
||||
<Folder strokeWidth={2} aria-hidden="true" className="size-icon-sm" />
|
||||
{group.showLock ? (
|
||||
<Lock
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
className="size-icon-sm"
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{group.title}</span>
|
||||
</span>
|
||||
{group.emptySpaceId ? null : (
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/sortable": "8.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@ -21,8 +21,8 @@ importers:
|
||||
specifier: 6.3.1
|
||||
version: 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@dnd-kit/sortable':
|
||||
specifier: 10.0.0
|
||||
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
|
||||
specifier: 8.0.0
|
||||
version: 8.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
|
||||
'@dnd-kit/utilities':
|
||||
specifier: 3.2.2
|
||||
version: 3.2.2(react@19.2.8)
|
||||
@ -1902,10 +1902,10 @@ packages:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3
|
||||
|
||||
'@dnd-kit/sortable@10.0.0':
|
||||
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||
'@dnd-kit/sortable@8.0.0':
|
||||
resolution: {integrity: sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==}
|
||||
peerDependencies:
|
||||
'@dnd-kit/core': ^6.3.0
|
||||
'@dnd-kit/core': ^6.1.0
|
||||
react: 19.2.3
|
||||
|
||||
'@dnd-kit/utilities@3.2.2':
|
||||
@ -11273,7 +11273,7 @@ snapshots:
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)':
|
||||
'@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@dnd-kit/core': 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.8)
|
||||
|
||||
21
screenshot.cjs
Normal file
21
screenshot.cjs
Normal file
@ -0,0 +1,21 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
await page.goto('http://100.114.51.16:5173/app', { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
await page.waitForSelector('[data-sidebar-group]', { timeout: 10000 }).catch(() => {});
|
||||
|
||||
await page.screenshot({ path: '/tmp/screenshot-sidebar.png', fullPage: false });
|
||||
console.log('Screenshot saved');
|
||||
|
||||
const groups = await page.locator('[data-sidebar-group]').all();
|
||||
console.log(`Found ${groups.length} sidebar groups`);
|
||||
|
||||
const titles = await page.locator('[data-sidebar-group] button[aria-label]').allTextContents();
|
||||
console.log('Sidebar buttons:', titles.slice(0, 10));
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
21
screenshot2.cjs
Normal file
21
screenshot2.cjs
Normal file
@ -0,0 +1,21 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
|
||||
// Capture console messages
|
||||
page.on('console', msg => console.log(`CONSOLE: ${msg.type()} - ${msg.text()}`));
|
||||
page.on('pageerror', err => console.log(`PAGE_ERROR: ${err.message}`));
|
||||
|
||||
await page.goto('http://100.114.51.16:5173/app', { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
await page.screenshot({ path: '/tmp/screenshot-sidebar.png', fullPage: false });
|
||||
console.log('Screenshot saved');
|
||||
|
||||
const bodyText = await page.evaluate(() => document.body.innerText);
|
||||
console.log('Body text:', bodyText.substring(0, 500));
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
Loading…
x
Reference in New Issue
Block a user