Spaces:
Running
Running
| import { useEffect, useRef, useState } from 'react'; | |
| import { ChevronDown, ChevronLeft, ChevronRight, Folder, Loader2, MessageSquarePlus, Pencil, Settings, Trash2, X } from 'lucide-react'; | |
| import { compactPath, formatTime } from '../app-core-utils.js'; | |
| import { DrawerQuota } from './DrawerQuota.jsx'; | |
| // Keep longer than the .drawer transform transition so close animation can finish. | |
| const DRAWER_UNMOUNT_DELAY_MS = 230; | |
| export function Drawer({ | |
| open, | |
| onClose, | |
| projects, | |
| selectedProject, | |
| selectedSession, | |
| expandedProjectIds, | |
| sessionsByProject, | |
| loadingProjectId, | |
| onToggleProject, | |
| onHideProject, | |
| onSelectSession, | |
| onRenameSession, | |
| onDeleteSession, | |
| onNewConversation, | |
| onSync, | |
| syncing, | |
| hiddenProjectIds, | |
| theme, | |
| setTheme, | |
| backgroundInert = false | |
| }) { | |
| const [drawerView, setDrawerView] = useState('main'); | |
| const closeButtonRef = useRef(null); | |
| const [mounted, setMounted] = useState(open); | |
| const [swipedProjectId, setSwipedProjectId] = useState(null); | |
| const projectSwipeRef = useRef(null); | |
| const closeDrawer = () => { | |
| onClose(); | |
| }; | |
| const hiddenFromAssistive = backgroundInert || !open; | |
| const dialogProps = { | |
| role: 'dialog', | |
| 'aria-modal': open && !backgroundInert ? true : undefined, | |
| 'aria-label': '导航菜单', | |
| inert: hiddenFromAssistive ? '' : undefined | |
| }; | |
| useEffect(() => { | |
| if (!open || backgroundInert) { | |
| return; | |
| } | |
| closeButtonRef.current?.focus(); | |
| }, [backgroundInert, open]); | |
| useEffect(() => { | |
| if (open) { | |
| setMounted(true); | |
| return undefined; | |
| } | |
| const timer = window.setTimeout(() => { | |
| setMounted(false); | |
| setDrawerView('main'); | |
| }, DRAWER_UNMOUNT_DELAY_MS); | |
| return () => window.clearTimeout(timer); | |
| }, [open]); | |
| function handleProjectTouchStart(event, projectId) { | |
| const touch = event.touches?.[0]; | |
| if (!touch) { | |
| return; | |
| } | |
| projectSwipeRef.current = { | |
| projectId, | |
| x: touch.clientX, | |
| y: touch.clientY | |
| }; | |
| } | |
| function handleProjectTouchMove(event) { | |
| const start = projectSwipeRef.current; | |
| const touch = event.touches?.[0]; | |
| if (!start || !touch) { | |
| return; | |
| } | |
| const deltaX = touch.clientX - start.x; | |
| const deltaY = touch.clientY - start.y; | |
| if (Math.abs(deltaX) < 28 || Math.abs(deltaX) < Math.abs(deltaY) * 1.15) { | |
| return; | |
| } | |
| if (deltaX < 0) { | |
| setSwipedProjectId(start.projectId); | |
| } else if (swipedProjectId === start.projectId) { | |
| setSwipedProjectId(null); | |
| } | |
| } | |
| function handleProjectTouchEnd() { | |
| projectSwipeRef.current = null; | |
| } | |
| if (!mounted) { | |
| return null; | |
| } | |
| if (drawerView === 'settings') { | |
| return ( | |
| <> | |
| <div className={`drawer-backdrop ${open ? 'is-open' : ''}`} onClick={closeDrawer} /> | |
| <aside className={`drawer ${open ? 'is-open' : ''}`} {...dialogProps}> | |
| <div className="drawer-subheader"> | |
| <button className="icon-button" onClick={() => setDrawerView('main')} aria-label="返回"> | |
| <ChevronLeft size={22} /> | |
| </button> | |
| <strong>设置</strong> | |
| <button ref={closeButtonRef} className="icon-button" onClick={closeDrawer} aria-label="关闭菜单"> | |
| <X size={20} /> | |
| </button> | |
| </div> | |
| <div className="settings-view"> | |
| <section className="settings-group"> | |
| <div className="drawer-heading">外观</div> | |
| <div className="theme-setting"> | |
| <div className="theme-setting-title"> | |
| <span>主题选择</span> | |
| </div> | |
| <div className="theme-segment" role="group" aria-label="主题选择"> | |
| <button | |
| type="button" | |
| className={theme === 'light' ? 'is-selected' : ''} | |
| onClick={() => setTheme('light')} | |
| > | |
| 白色 | |
| </button> | |
| <button | |
| type="button" | |
| className={theme === 'dark' ? 'is-selected' : ''} | |
| onClick={() => setTheme('dark')} | |
| > | |
| 黑色 | |
| </button> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| </aside> | |
| </> | |
| ); | |
| } | |
| const visibleProjects = projects.filter((project) => !hiddenProjectIds?.has(project.id)); | |
| return ( | |
| <> | |
| <div className={`drawer-backdrop ${open ? 'is-open' : ''}`} onClick={closeDrawer} /> | |
| <aside className={`drawer ${open ? 'is-open' : ''}`} {...dialogProps}> | |
| <div className="drawer-grip"> | |
| <button ref={closeButtonRef} className="icon-button" onClick={closeDrawer} aria-label="关闭菜单"> | |
| <X size={20} /> | |
| </button> | |
| </div> | |
| <button className="drawer-action" onClick={onNewConversation}> | |
| <MessageSquarePlus size={20} /> | |
| <span> | |
| <strong>新对话</strong> | |
| <small>在当前项目中新建</small> | |
| </span> | |
| </button> | |
| <section className="drawer-section project-section"> | |
| <div className="drawer-heading">项目</div> | |
| <div className="project-list"> | |
| {!visibleProjects.length ? ( | |
| <div className="project-empty">项目已隐藏,点“对话同步”恢复</div> | |
| ) : null} | |
| {visibleProjects.map((project) => { | |
| const isSelected = selectedProject?.id === project.id; | |
| const isExpanded = Boolean(expandedProjectIds[project.id]); | |
| const projectSessions = sessionsByProject[project.id] || []; | |
| return ( | |
| <div key={project.id} className="project-group"> | |
| <div | |
| className={`project-swipe ${swipedProjectId === project.id ? 'is-open' : ''}`} | |
| onTouchStart={(event) => handleProjectTouchStart(event, project.id)} | |
| onTouchMove={handleProjectTouchMove} | |
| onTouchEnd={handleProjectTouchEnd} | |
| onTouchCancel={handleProjectTouchEnd} | |
| > | |
| <button | |
| type="button" | |
| className="project-hide-action" | |
| onClick={(event) => { | |
| event.stopPropagation(); | |
| setSwipedProjectId(null); | |
| onHideProject(project); | |
| }} | |
| aria-label={`隐藏项目 ${project.name}`} | |
| > | |
| 隐藏 | |
| </button> | |
| <button | |
| className={`project-row ${isSelected ? 'is-selected' : ''} ${isExpanded ? 'is-expanded' : ''}`} | |
| onClick={() => { | |
| if (swipedProjectId === project.id) { | |
| setSwipedProjectId(null); | |
| return; | |
| } | |
| onToggleProject(project); | |
| }} | |
| > | |
| <Folder size={18} /> | |
| <span> | |
| <strong>{project.name}</strong> | |
| <small>{compactPath(project.path)}</small> | |
| </span> | |
| <small className="project-count">{project.sessionCount || projectSessions.length || 0}</small> | |
| <ChevronDown size={15} className="project-chevron" /> | |
| </button> | |
| </div> | |
| {isExpanded ? ( | |
| <div className="thread-list"> | |
| {loadingProjectId === project.id ? ( | |
| <div className="thread-empty"> | |
| <Loader2 className="spin" size={14} /> | |
| 加载中... | |
| </div> | |
| ) : projectSessions.length ? ( | |
| projectSessions.map((session) => ( | |
| <div | |
| key={session.id} | |
| className={`thread-row ${selectedSession?.id === session.id ? 'is-selected' : ''} ${session.draft ? 'is-draft' : ''}`} | |
| > | |
| <button | |
| type="button" | |
| className="thread-main" | |
| onClick={() => onSelectSession(project, session)} | |
| > | |
| <span>{session.title || '对话'}</span> | |
| <small>{session.draft ? '待发送' : formatTime(session.updatedAt)}</small> | |
| </button> | |
| <button | |
| type="button" | |
| className="thread-rename" | |
| onClick={() => onRenameSession(project, session)} | |
| aria-label="重命名线程" | |
| title="重命名线程" | |
| > | |
| <Pencil size={14} /> | |
| </button> | |
| <button | |
| type="button" | |
| className="thread-delete" | |
| onClick={() => onDeleteSession(project, session)} | |
| aria-label="删除线程" | |
| title="删除线程" | |
| > | |
| <Trash2 size={14} /> | |
| </button> | |
| </div> | |
| )) | |
| ) : ( | |
| <div className="thread-empty">暂无线程</div> | |
| )} | |
| </div> | |
| ) : null} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </section> | |
| <section className="drawer-section drawer-controls"> | |
| <div className="control-row sync-row"> | |
| <span> | |
| 对话同步 | |
| </span> | |
| <button className="sync-button" onClick={onSync} disabled={syncing}> | |
| {syncing ? <Loader2 className="spin" size={16} /> : null} | |
| 同步 | |
| </button> | |
| <span className="sync-spacer" aria-hidden="true" /> | |
| </div> | |
| <DrawerQuota /> | |
| <button type="button" className="settings-entry" onClick={() => setDrawerView('settings')}> | |
| <span> | |
| <Settings size={18} /> | |
| 设置 | |
| </span> | |
| <ChevronRight size={17} /> | |
| </button> | |
| </section> | |
| </aside> | |
| </> | |
| ); | |
| } | |