fix: proper drag&drop with dataTransfer, context menu (rename/delete/new), bold active session
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {
|
import {
|
||||||
fetchSessions, createSession, deleteSession, updateSession,
|
fetchSessions, createSession, deleteSession, updateSession,
|
||||||
@@ -19,6 +19,13 @@ interface TreeNode {
|
|||||||
children: TreeNode[];
|
children: TreeNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ContextMenuState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
type: 'session' | 'folder' | 'root';
|
||||||
|
id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
function buildTree(folders: ChatFolder[], sessions: ChatSession[]): TreeNode[] {
|
function buildTree(folders: ChatFolder[], sessions: ChatSession[]): TreeNode[] {
|
||||||
const rootSessions = sessions.filter((s) => !s.folder_id);
|
const rootSessions = sessions.filter((s) => !s.folder_id);
|
||||||
const root: TreeNode = { folder: null, sessions: rootSessions, children: [] };
|
const root: TreeNode = { folder: null, sessions: rootSessions, children: [] };
|
||||||
@@ -36,14 +43,13 @@ function buildTree(folders: ChatFolder[], sessions: ChatSession[]): TreeNode[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TreeItem({
|
function TreeItem({
|
||||||
node, activeSessionId, onSelectSession, depth, onDeleteFolder, onAddFolder, onDragSession, onDropToFolder,
|
node, activeSessionId, onSelectSession, depth, onContextMenu, onDragSession, onDropToFolder,
|
||||||
}: {
|
}: {
|
||||||
node: TreeNode;
|
node: TreeNode;
|
||||||
activeSessionId: string | null;
|
activeSessionId: string | null;
|
||||||
onSelectSession: (id: string) => void;
|
onSelectSession: (id: string) => void;
|
||||||
depth: number;
|
depth: number;
|
||||||
onDeleteFolder: (folderId: string) => void;
|
onContextMenu: (e: React.MouseEvent, type: 'session' | 'folder' | 'root', id: string | null) => void;
|
||||||
onAddFolder: (parentId: string | null) => void;
|
|
||||||
onDragSession: (sessionId: string) => void;
|
onDragSession: (sessionId: string) => void;
|
||||||
onDropToFolder: (folderId: string | null) => void;
|
onDropToFolder: (folderId: string | null) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -52,7 +58,6 @@ function TreeItem({
|
|||||||
const isRoot = node.folder === null;
|
const isRoot = node.folder === null;
|
||||||
const folderId = node.folder?.id || null;
|
const folderId = node.folder?.id || null;
|
||||||
|
|
||||||
// Drop handler for this specific folder - stopPropagation so parent doesn't also fire
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -63,23 +68,18 @@ function TreeItem({
|
|||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsDragOver(true);
|
if (!isRoot) setIsDragOver(true);
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
// Only clear if leaving to a non-child element
|
|
||||||
setIsDragOver(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Folder header - this is the drop target */}
|
{/* Folder header - drop target */}
|
||||||
{!isRoot && (
|
{!isRoot && (
|
||||||
<div
|
<div
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={() => setIsDragOver(false)}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
|
onContextMenu={(e) => onContextMenu(e, 'folder', node.folder!.id)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'group flex items-center gap-1 px-2 py-1.5 text-sm font-medium rounded-md cursor-pointer',
|
'group flex items-center gap-1 px-2 py-1.5 text-sm font-medium rounded-md cursor-pointer',
|
||||||
isDragOver ? 'bg-primary-100 ring-2 ring-primary-400' : 'text-secondary-700 hover:bg-secondary-100'
|
isDragOver ? 'bg-primary-100 ring-2 ring-primary-400' : 'text-secondary-700 hover:bg-secondary-100'
|
||||||
@@ -94,46 +94,51 @@ function TreeItem({
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="flex-1 truncate">{node.folder!.name}</span>
|
<span className="flex-1 truncate">{node.folder!.name}</span>
|
||||||
<button onClick={(e) => { e.stopPropagation(); onAddFolder(node.folder!.id); }} className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-primary-600" title="Unterordner">+</button>
|
|
||||||
<button onClick={(e) => { e.stopPropagation(); onDeleteFolder(node.folder!.id); }} className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-red-600" title="Löschen">✕</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sessions in this folder */}
|
{/* Sessions */}
|
||||||
{(isRoot || expanded) && (
|
{(isRoot || expanded) && (
|
||||||
<div>
|
<div>
|
||||||
{node.sessions.map((session) => (
|
{node.sessions.map((session) => (
|
||||||
<div
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => { e.stopPropagation(); onDragSession(session.id); }}
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData('text/plain', session.id);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
onDragSession(session.id);
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => onContextMenu(e, 'session', session.id)}
|
||||||
onClick={() => onSelectSession(session.id)}
|
onClick={() => onSelectSession(session.id)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'group flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer rounded-md',
|
'group flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer rounded-md',
|
||||||
'hover:bg-secondary-100',
|
'hover:bg-secondary-100',
|
||||||
activeSessionId === session.id && 'bg-primary-100 text-primary-700'
|
activeSessionId === session.id && 'bg-primary-50'
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: depth * 12 + 24 }}
|
style={{ paddingLeft: depth * 12 + 24 }}
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-3.5 h-3.5 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h8M8 8h8m-8 4h8m-8 4h8" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h8M8 8h8m-8 4h8m-8 4h8" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="flex-1 truncate">{session.title}</span>
|
<span className={clsx('flex-1 truncate', activeSessionId === session.id && 'font-bold text-primary-700')}>
|
||||||
<button onClick={(e) => { e.stopPropagation(); deleteSession(session.id).then(() => window.location.reload()); }} className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-red-600" title="Löschen">✕</button>
|
{session.title}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Root drop zone - only for root level, separate from sessions */}
|
{/* Root drop zone */}
|
||||||
{isRoot && (
|
{isRoot && (
|
||||||
<div
|
<div
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={() => setIsDragOver(false)}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
|
onContextMenu={(e) => onContextMenu(e, 'root', null)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'min-h-[8px] rounded-md transition-colors',
|
'min-h-[12px] mt-1 rounded-md transition-colors',
|
||||||
isDragOver && 'bg-primary-50 ring-1 ring-primary-300'
|
isDragOver ? 'bg-primary-50 ring-1 ring-primary-300' : 'hover:bg-secondary-50'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -146,8 +151,7 @@ function TreeItem({
|
|||||||
activeSessionId={activeSessionId}
|
activeSessionId={activeSessionId}
|
||||||
onSelectSession={onSelectSession}
|
onSelectSession={onSelectSession}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
onDeleteFolder={onDeleteFolder}
|
onContextMenu={onContextMenu}
|
||||||
onAddFolder={onAddFolder}
|
|
||||||
onDragSession={onDragSession}
|
onDragSession={onDragSession}
|
||||||
onDropToFolder={onDropToFolder}
|
onDropToFolder={onDropToFolder}
|
||||||
/>
|
/>
|
||||||
@@ -156,12 +160,69 @@ function TreeItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ContextMenu({
|
||||||
|
state, onClose, onRename, onDelete, onNewFolder, onNewChat,
|
||||||
|
}: {
|
||||||
|
state: ContextMenuState;
|
||||||
|
onClose: () => void;
|
||||||
|
onRename: (id: string, type: 'session' | 'folder') => void;
|
||||||
|
onDelete: (id: string, type: 'session' | 'folder') => void;
|
||||||
|
onNewFolder: () => void;
|
||||||
|
onNewChat: () => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const items: { label: string; action: () => void; danger?: boolean }[] = [];
|
||||||
|
|
||||||
|
if (state.type === 'session') {
|
||||||
|
items.push({ label: 'Umbenennen', action: () => { onRename(state.id!, 'session'); onClose(); } });
|
||||||
|
items.push({ label: 'Löschen', action: () => { onDelete(state.id!, 'session'); onClose(); }, danger: true });
|
||||||
|
} else if (state.type === 'folder') {
|
||||||
|
items.push({ label: 'Umbenennen', action: () => { onRename(state.id!, 'folder'); onClose(); } });
|
||||||
|
items.push({ label: 'Neuer Unterordner', action: () => { onClose(); /* TODO */ } });
|
||||||
|
items.push({ label: 'Löschen', action: () => { onDelete(state.id!, 'folder'); onClose(); }, danger: true });
|
||||||
|
} else {
|
||||||
|
items.push({ label: 'Neuer Chat', action: () => { onNewChat(); onClose(); } });
|
||||||
|
items.push({ label: 'Neuer Ordner', action: () => { onNewFolder(); onClose(); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="fixed z-50 bg-white border border-secondary-200 rounded-lg shadow-lg py-1 min-w-[160px]"
|
||||||
|
style={{ left: state.x, top: state.y }}
|
||||||
|
>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={item.action}
|
||||||
|
className={clsx(
|
||||||
|
'w-full text-left px-3 py-1.5 text-sm hover:bg-secondary-100',
|
||||||
|
item.danger && 'text-red-600 hover:bg-red-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SessionList({ activeSessionId, onSelectSession, isSidebar, className }: SessionListProps) {
|
export function SessionList({ activeSessionId, onSelectSession, isSidebar, className }: SessionListProps) {
|
||||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||||
const [folders, setFolders] = useState<ChatFolder[]>([]);
|
const [folders, setFolders] = useState<ChatFolder[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [dragSessionId, setDragSessionId] = useState<string | null>(null);
|
const [dragSessionId, setDragSessionId] = useState<string | null>(null);
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -174,15 +235,50 @@ export function SessionList({ activeSessionId, onSelectSession, isSidebar, class
|
|||||||
|
|
||||||
useEffect(() => { loadData(); }, [isSidebar]);
|
useEffect(() => { loadData(); }, [isSidebar]);
|
||||||
|
|
||||||
const handleAddFolder = (parentId: string | null) => {
|
const handleContextMenu = useCallback((e: React.MouseEvent, type: 'session' | 'folder' | 'root', id: string | null) => {
|
||||||
const name = prompt('Ordnername:');
|
e.preventDefault();
|
||||||
if (!name) return;
|
e.stopPropagation();
|
||||||
createFolder({ name, parent_id: parentId || undefined }).then(() => loadData()).catch((e) => setError(e?.message));
|
setContextMenu({ x: e.clientX, y: e.clientY, type, id });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRename = async (id: string, type: 'session' | 'folder') => {
|
||||||
|
const newName = prompt('Neuer Name:', type === 'session'
|
||||||
|
? sessions.find(s => s.id === id)?.title || ''
|
||||||
|
: folders.find(f => f.id === id)?.name || ''
|
||||||
|
);
|
||||||
|
if (!newName) return;
|
||||||
|
try {
|
||||||
|
if (type === 'session') await updateSession(id, { title: newName });
|
||||||
|
else await updateFolder(id, { name: newName });
|
||||||
|
loadData();
|
||||||
|
} catch (e) { setError(e instanceof Error ? e.message : 'Rename failed'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteFolder = (folderId: string) => {
|
const handleDelete = async (id: string, type: 'session' | 'folder') => {
|
||||||
if (!confirm('Ordner löschen? Chats bleiben erhalten.')) return;
|
if (!confirm(type === 'session' ? 'Chat löschen?' : 'Ordner löschen? Chats bleiben erhalten.')) return;
|
||||||
deleteFolder(folderId).then(() => loadData()).catch((e) => setError(e?.message));
|
try {
|
||||||
|
if (type === 'session') {
|
||||||
|
await deleteSession(id);
|
||||||
|
if (activeSessionId === id) onSelectSession('');
|
||||||
|
} else {
|
||||||
|
await deleteFolder(id);
|
||||||
|
}
|
||||||
|
loadData();
|
||||||
|
} catch (e) { setError(e instanceof Error ? e.message : 'Delete failed'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewFolder = () => {
|
||||||
|
const name = prompt('Ordnername:');
|
||||||
|
if (!name) return;
|
||||||
|
createFolder({ name }).then(() => loadData()).catch((e) => setError(e?.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewChat = async () => {
|
||||||
|
try {
|
||||||
|
const session = await createSession({ is_sidebar: isSidebar || false });
|
||||||
|
onSelectSession(session.id);
|
||||||
|
loadData();
|
||||||
|
} catch (e) { setError(e instanceof Error ? e.message : 'Failed'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDropToFolder = async (folderId: string | null) => {
|
const handleDropToFolder = async (folderId: string | null) => {
|
||||||
@@ -201,7 +297,12 @@ export function SessionList({ activeSessionId, onSelectSession, isSidebar, class
|
|||||||
return (
|
return (
|
||||||
<div className={clsx('flex flex-col h-full', className)}>
|
<div className={clsx('flex flex-col h-full', className)}>
|
||||||
{error && <div className="p-2 text-xs text-red-600">{error}</div>}
|
{error && <div className="p-2 text-xs text-red-600">{error}</div>}
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<div
|
||||||
|
className="flex-1 overflow-y-auto p-2"
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if (e.target === e.currentTarget) handleContextMenu(e, 'root', null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{tree.map((node) => (
|
{tree.map((node) => (
|
||||||
<TreeItem
|
<TreeItem
|
||||||
key={node.folder?.id || 'root'}
|
key={node.folder?.id || 'root'}
|
||||||
@@ -209,13 +310,22 @@ export function SessionList({ activeSessionId, onSelectSession, isSidebar, class
|
|||||||
activeSessionId={activeSessionId}
|
activeSessionId={activeSessionId}
|
||||||
onSelectSession={onSelectSession}
|
onSelectSession={onSelectSession}
|
||||||
depth={0}
|
depth={0}
|
||||||
onDeleteFolder={handleDeleteFolder}
|
onContextMenu={handleContextMenu}
|
||||||
onAddFolder={handleAddFolder}
|
|
||||||
onDragSession={setDragSessionId}
|
onDragSession={setDragSessionId}
|
||||||
onDropToFolder={handleDropToFolder}
|
onDropToFolder={handleDropToFolder}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{contextMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
state={contextMenu}
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
onRename={handleRename}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onNewFolder={handleNewFolder}
|
||||||
|
onNewChat={handleNewChat}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user