feat: drag & drop session reordering in sidebar
- Drag sessions to reorder within pinned/unpinned groups - Custom order persists in localStorage - Visual feedback: dragged item fades, drop target highlights - Disabled during search filtering - Works alongside existing pin feature (pinned group stays on top)
This commit is contained in:
@@ -8,6 +8,7 @@ import { relativeTime } from '../lib/relativeTime';
|
|||||||
|
|
||||||
const PINNED_KEY = 'pinchchat-pinned-sessions';
|
const PINNED_KEY = 'pinchchat-pinned-sessions';
|
||||||
const WIDTH_KEY = 'pinchchat-sidebar-width';
|
const WIDTH_KEY = 'pinchchat-sidebar-width';
|
||||||
|
const ORDER_KEY = 'pinchchat-session-order';
|
||||||
const MIN_WIDTH = 220;
|
const MIN_WIDTH = 220;
|
||||||
const MAX_WIDTH = 480;
|
const MAX_WIDTH = 480;
|
||||||
const DEFAULT_WIDTH = 288; // w-72
|
const DEFAULT_WIDTH = 288; // w-72
|
||||||
@@ -37,6 +38,20 @@ function savePinnedSessions(pinned: Set<string>) {
|
|||||||
} catch { /* noop */ }
|
} catch { /* noop */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSavedOrder(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(ORDER_KEY);
|
||||||
|
if (raw) return JSON.parse(raw) as string[];
|
||||||
|
} catch { /* noop */ }
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveOrder(order: string[]) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(ORDER_KEY, JSON.stringify(order));
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
activeSession: string;
|
activeSession: string;
|
||||||
@@ -54,6 +69,9 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
|
|||||||
const [width, setWidth] = useState(getSavedWidth);
|
const [width, setWidth] = useState(getSavedWidth);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||||
|
const [customOrder, setCustomOrder] = useState<string[]>(getSavedOrder);
|
||||||
|
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||||
|
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const dragRef = useRef({ startX: 0, startW: 0 });
|
const dragRef = useRef({ startX: 0, startW: 0 });
|
||||||
@@ -132,12 +150,20 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
|
|||||||
// Sort pinned sessions to top (preserving relative order within each group)
|
// Sort pinned sessions to top (preserving relative order within each group)
|
||||||
const pinnedList = list.filter(s => pinned.has(s.key));
|
const pinnedList = list.filter(s => pinned.has(s.key));
|
||||||
const unpinnedList = list.filter(s => !pinned.has(s.key));
|
const unpinnedList = list.filter(s => !pinned.has(s.key));
|
||||||
// Sort each group by most recently updated
|
// Sort each group: use custom order if set, then fall back to most recently updated
|
||||||
const byRecent = (a: Session, b: Session) => (b.updatedAt || 0) - (a.updatedAt || 0);
|
const orderMap = new Map(customOrder.map((k, i) => [k, i]));
|
||||||
pinnedList.sort(byRecent);
|
const byCustomThenRecent = (a: Session, b: Session) => {
|
||||||
unpinnedList.sort(byRecent);
|
const aIdx = orderMap.get(a.key);
|
||||||
|
const bIdx = orderMap.get(b.key);
|
||||||
|
if (aIdx !== undefined && bIdx !== undefined) return aIdx - bIdx;
|
||||||
|
if (aIdx !== undefined) return -1;
|
||||||
|
if (bIdx !== undefined) return 1;
|
||||||
|
return (b.updatedAt || 0) - (a.updatedAt || 0);
|
||||||
|
};
|
||||||
|
pinnedList.sort(byCustomThenRecent);
|
||||||
|
unpinnedList.sort(byCustomThenRecent);
|
||||||
return [...pinnedList, ...unpinnedList];
|
return [...pinnedList, ...unpinnedList];
|
||||||
}, [sessions, filter, pinned]);
|
}, [sessions, filter, pinned, customOrder]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -224,6 +250,8 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
|
|||||||
const isFocused = idx === focusIdx;
|
const isFocused = idx === focusIdx;
|
||||||
const isPinned = pinned.has(s.key);
|
const isPinned = pinned.has(s.key);
|
||||||
const isFirstUnpinned = !isPinned && idx > 0 && pinned.has(filtered[idx - 1].key);
|
const isFirstUnpinned = !isPinned && idx > 0 && pinned.has(filtered[idx - 1].key);
|
||||||
|
const isDragged = dragKey === s.key;
|
||||||
|
const isDropTarget = dropTarget === s.key && dragKey !== s.key;
|
||||||
return (
|
return (
|
||||||
<div key={s.key}>
|
<div key={s.key}>
|
||||||
{isFirstUnpinned && (
|
{isFirstUnpinned && (
|
||||||
@@ -234,6 +262,38 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
|
|||||||
<button
|
<button
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
|
draggable={!filter.trim()}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
setDragKey(s.key);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', s.key);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => { setDragKey(null); setDropTarget(null); }}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
if (dragKey && dragKey !== s.key) setDropTarget(s.key);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => { if (dropTarget === s.key) setDropTarget(null); }}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!dragKey || dragKey === s.key) return;
|
||||||
|
// Only reorder within same group (pinned or unpinned)
|
||||||
|
const dragPinned = pinned.has(dragKey);
|
||||||
|
const dropPinned = pinned.has(s.key);
|
||||||
|
if (dragPinned !== dropPinned) { setDragKey(null); setDropTarget(null); return; }
|
||||||
|
// Build new order from current filtered list
|
||||||
|
const keys = filtered.map(f => f.key);
|
||||||
|
const fromIdx = keys.indexOf(dragKey);
|
||||||
|
const toIdx = keys.indexOf(s.key);
|
||||||
|
if (fromIdx === -1 || toIdx === -1) return;
|
||||||
|
keys.splice(fromIdx, 1);
|
||||||
|
keys.splice(toIdx, 0, dragKey);
|
||||||
|
setCustomOrder(keys);
|
||||||
|
saveOrder(keys);
|
||||||
|
setDragKey(null);
|
||||||
|
setDropTarget(null);
|
||||||
|
}}
|
||||||
onClick={() => { onSwitch(s.key); onClose(); }}
|
onClick={() => { onSwitch(s.key); onClose(); }}
|
||||||
onMouseEnter={() => setFocusIdx(idx)}
|
onMouseEnter={() => setFocusIdx(idx)}
|
||||||
className={`group/item w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl text-left text-sm transition-all mb-1 ${
|
className={`group/item w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl text-left text-sm transition-all mb-1 ${
|
||||||
@@ -242,7 +302,7 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
|
|||||||
: s.isActive
|
: s.isActive
|
||||||
? 'bg-violet-500/5 text-violet-200 border border-violet-500/15 shadow-[0_0_10px_rgba(168,85,247,0.06)]'
|
? 'bg-violet-500/5 text-violet-200 border border-violet-500/15 shadow-[0_0_10px_rgba(168,85,247,0.06)]'
|
||||||
: 'text-pc-text-secondary hover:bg-[var(--pc-hover)] border border-transparent'
|
: 'text-pc-text-secondary hover:bg-[var(--pc-hover)] border border-transparent'
|
||||||
} ${isFocused && !isActive ? 'ring-1 ring-[var(--pc-accent-dim)]' : ''}`}
|
} ${isFocused && !isActive ? 'ring-1 ring-[var(--pc-accent-dim)]' : ''} ${isDragged ? 'opacity-40' : ''} ${isDropTarget ? 'ring-1 ring-[var(--pc-accent)] bg-[var(--pc-accent-glow)]' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<SessionIcon session={s} isActive={s.isActive} isCurrentSession={isActive} />
|
<SessionIcon session={s} isActive={s.isActive} isCurrentSession={isActive} />
|
||||||
|
|||||||
Reference in New Issue
Block a user