import React, { useEffect, useState, useRef, useCallback } from 'react'; import clsx from 'clsx'; import { fetchSessions, createSession, deleteSession, updateSession, fetchFolders, createFolder, deleteFolder, updateFolder, type ChatSession, type ChatFolder, } from '@/api/ai'; interface SessionListProps { activeSessionId: string | null; onSelectSession: (id: string) => void; isSidebar?: boolean; className?: string; } interface TreeNode { folder: ChatFolder | null; sessions: ChatSession[]; children: TreeNode[]; } interface ContextMenuState { x: number; y: number; type: 'session' | 'folder' | 'root'; id: string | null; } function buildTree(folders: ChatFolder[], sessions: ChatSession[]): TreeNode[] { const rootSessions = sessions.filter((s) => !s.folder_id); const root: TreeNode = { folder: null, sessions: rootSessions, children: [] }; const folderMap = new Map(); for (const f of folders) folderMap.set(f.id, { folder: f, sessions: [], children: [] }); for (const f of folders) { const node = folderMap.get(f.id)!; if (f.parent_id && folderMap.has(f.parent_id)) folderMap.get(f.parent_id)!.children.push(node); else root.children.push(node); } for (const s of sessions) { if (s.folder_id && folderMap.has(s.folder_id)) folderMap.get(s.folder_id)!.sessions.push(s); } return [root]; } function TreeItem({ node, activeSessionId, onSelectSession, depth, onContextMenu, onDragSession, onDragFolder, onDropToFolder, onDelete, }: { node: TreeNode; activeSessionId: string | null; onSelectSession: (id: string) => void; depth: number; onContextMenu: (e: React.MouseEvent, type: 'session' | 'folder' | 'root', id: string | null) => void; onDragSession: (sessionId: string) => void; onDragFolder: (folderId: string) => void; onDropToFolder: (folderId: string | null) => void; onDelete: (id: string, type: 'session' | 'folder') => void; }) { const [expanded, setExpanded] = useState(true); const [isDragOver, setIsDragOver] = useState(false); const isRoot = node.folder === null; const folderId = node.folder?.id || null; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); const data = e.dataTransfer.getData('text/plain'); if (data.startsWith('folder:')) { const draggedFolderId = data.slice(7); if (draggedFolderId !== folderId) { onDropToFolder(folderId); } } else { onDropToFolder(folderId); } }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (!isRoot) setIsDragOver(true); }; return (
{/* Folder header - drop target */} {!isRoot && (
{ if (e.button !== 0) { e.preventDefault(); return; } e.dataTransfer.setData('text/plain', `folder:${node.folder!.id}`); e.dataTransfer.effectAllowed = 'move'; onDragFolder(node.folder!.id); }} onDragOver={handleDragOver} onDragLeave={() => setIsDragOver(false)} onDrop={handleDrop} onContextMenu={(e) => onContextMenu(e, 'folder', node.folder!.id)} className={clsx( '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' )} style={{ paddingLeft: depth * 12 + 8 }} onClick={() => setExpanded(!expanded)} > {node.folder!.name}
)} {/* Sessions */} {(isRoot || expanded) && (
{node.sessions.map((session) => (
{ 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)} className={clsx( 'group flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer rounded-md', 'hover:bg-secondary-100', activeSessionId === session.id && 'bg-primary-50' )} style={{ paddingLeft: depth * 12 + 24 }} > {session.title}
))}
)} {/* Root drop zone */} {isRoot && (
setIsDragOver(false)} onDrop={handleDrop} onContextMenu={(e) => onContextMenu(e, 'root', null)} className={clsx( 'min-h-[12px] mt-1 rounded-md transition-colors', isDragOver ? 'bg-primary-50 ring-1 ring-primary-300' : 'hover:bg-secondary-50' )} /> )} {/* Child folders */} {(isRoot || expanded) && node.children.map((child) => ( ))}
); } 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(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 (
{items.map((item, i) => ( ))}
); } export function SessionList({ activeSessionId, onSelectSession, isSidebar, className }: SessionListProps) { const [sessions, setSessions] = useState([]); const [folders, setFolders] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [dragSessionId, setDragSessionId] = useState(null); const [dragFolderId, setDragFolderId] = useState(null); const [contextMenu, setContextMenu] = useState(null); const loadData = async () => { try { setLoading(true); const [s, f] = await Promise.all([fetchSessions(isSidebar ? true : undefined), fetchFolders()]); setSessions(s); setFolders(f); setError(null); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to load'); } finally { setLoading(false); } }; useEffect(() => { loadData(); }, [isSidebar]); const handleContextMenu = useCallback((e: React.MouseEvent, type: 'session' | 'folder' | 'root', id: string | null) => { e.preventDefault(); e.stopPropagation(); 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 handleDelete = async (id: string, type: 'session' | 'folder') => { if (!confirm(type === 'session' ? 'Chat löschen?' : 'Ordner löschen? Chats bleiben erhalten.')) return; 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) => { if (dragFolderId) { try { await updateFolder(dragFolderId, { parent_id: folderId }); setDragFolderId(null); loadData(); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to move folder'); } return; } if (!dragSessionId) return; try { await updateSession(dragSessionId, { folder_id: folderId }); setDragSessionId(null); loadData(); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to move'); } }; if (loading) return
Laden...
; const tree = buildTree(folders, sessions); return (
{error &&
{error}
}
{ if (e.target === e.currentTarget) handleContextMenu(e, 'root', null); }} > {tree.map((node) => ( ))}
{contextMenu && ( setContextMenu(null)} onRename={handleRename} onDelete={handleDelete} onNewFolder={handleNewFolder} onNewChat={handleNewChat} /> )}
); }