Spaces:
Sleeping
Sleeping
Đỗ Hải Nam
fix: move stable assets to /assets/ prefix to ensures they are served by FastAPI in production
99a99c6
| import React, { useState, useEffect, useRef } from 'react' | |
| import { | |
| ChevronLeft, | |
| ChevronDown, | |
| ChevronRight, | |
| Search, | |
| MessageSquare, | |
| Edit, | |
| Image, | |
| LayoutGrid, | |
| Folder, | |
| Settings, | |
| User, | |
| Pin, | |
| MoreHorizontal, | |
| Archive, | |
| Trash2, | |
| PanelLeft, | |
| PanelRight | |
| } from 'lucide-react' | |
| const pochiLogo = '/assets/pochi.jpeg' | |
| const Sidebar = ({ | |
| isOpen, | |
| toggleSidebar, | |
| conversations = [], | |
| currentConversationId, | |
| onSelectConversation, | |
| onNewChat, | |
| onDeleteConversation, | |
| onRenameConversation, | |
| onTogglePin, | |
| onToggleArchive, | |
| onSearchClick, | |
| onSettingsClick, | |
| darkMode, | |
| userProfile | |
| }) => { | |
| const sidebarRef = useRef(null) | |
| const [activeMenuId, setActiveMenuId] = React.useState(() => { | |
| return sessionStorage.getItem('activeMenuId') || null | |
| }) | |
| const [isHistoryCollapsed, setIsHistoryCollapsed] = useState(() => { | |
| return localStorage.getItem('isHistoryCollapsed') === 'true' | |
| }) | |
| const [editingId, setEditingId] = useState(() => { | |
| return sessionStorage.getItem('editingId') || null | |
| }) | |
| const [editTitle, setEditTitle] = useState(() => { | |
| return sessionStorage.getItem('editTitle') || '' | |
| }) | |
| const [menuPlacement, setMenuPlacement] = useState(() => { | |
| return sessionStorage.getItem('menuPlacement') || 'bottom' | |
| }) | |
| const pinnedChats = conversations.filter(c => c.isPinned && !c.isArchived) | |
| const activeChats = conversations.filter(c => !c.isPinned && !c.isArchived) | |
| const handleStartRename = (conv) => { | |
| setEditingId(conv.id) | |
| setEditTitle(conv.title || 'Đoạn chat mới') | |
| setActiveMenuId(null) | |
| sessionStorage.setItem('isNewRename', 'true') | |
| } | |
| const handleSaveRename = (id) => { | |
| if (editTitle.trim()) { | |
| onRenameConversation(id, editTitle.trim()) | |
| } | |
| setEditingId(null) | |
| } | |
| const handleCancelRename = () => { | |
| setEditingId(null) | |
| } | |
| // Close menu when clicking outside | |
| React.useEffect(() => { | |
| const handleClickOutside = () => setActiveMenuId(null) | |
| window.addEventListener('click', handleClickOutside) | |
| return () => window.removeEventListener('click', handleClickOutside) | |
| }, []) | |
| useEffect(() => { | |
| localStorage.setItem('isHistoryCollapsed', isHistoryCollapsed) | |
| }, [isHistoryCollapsed]) | |
| useEffect(() => { | |
| if (activeMenuId) { | |
| sessionStorage.setItem('activeMenuId', activeMenuId) | |
| sessionStorage.setItem('menuPlacement', menuPlacement) | |
| } else { | |
| sessionStorage.removeItem('activeMenuId') | |
| sessionStorage.removeItem('menuPlacement') | |
| } | |
| }, [activeMenuId, menuPlacement]) | |
| useEffect(() => { | |
| if (editingId) { | |
| sessionStorage.setItem('editingId', editingId) | |
| sessionStorage.setItem('editTitle', editTitle) | |
| } else { | |
| sessionStorage.removeItem('editingId') | |
| sessionStorage.removeItem('editTitle') | |
| } | |
| }, [editingId, editTitle]) | |
| const historyScrollRef = useRef(null) | |
| // Handle history scroll persistence | |
| const handleHistoryScroll = (e) => { | |
| if (!isOpen) return // Only save if open | |
| sessionStorage.setItem('sidebarScrollTop', e.target.scrollTop) | |
| } | |
| React.useLayoutEffect(() => { | |
| if (!isHistoryCollapsed && historyScrollRef.current) { | |
| const savedScroll = sessionStorage.getItem('sidebarScrollTop') | |
| if (savedScroll) { | |
| historyScrollRef.current.scrollTop = parseInt(savedScroll, 10) | |
| } | |
| } | |
| }, [isHistoryCollapsed, conversations]) // Restore when expanded or conversations change | |
| const renderChatItem = (conv) => ( | |
| <div | |
| key={conv.id} | |
| className={`sidebar-chat-item group ${conv.id === currentConversationId ? 'active' : ''} ${activeMenuId === conv.id ? 'menu-open' : ''} ${editingId === conv.id ? 'editing' : ''}`} | |
| onClick={() => onSelectConversation(conv.id)} | |
| > | |
| <div className="active-indicator" /> | |
| {editingId === conv.id ? ( | |
| <div className="chat-item-text editing" onClick={e => e.stopPropagation()}> | |
| <input | |
| className="inline-rename-input" | |
| value={editTitle} | |
| onChange={(e) => setEditTitle(e.target.value)} | |
| onBlur={() => handleSaveRename(conv.id)} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter') handleSaveRename(conv.id) | |
| if (e.key === 'Escape') handleCancelRename() | |
| }} | |
| autoFocus | |
| spellCheck="false" | |
| autoComplete="off" | |
| onFocus={e => { | |
| // Only select all if this is NOT a restored state from refresh | |
| const isRestored = !sessionStorage.getItem('isNewRename'); | |
| if (!isRestored) { | |
| e.target.select(); | |
| sessionStorage.removeItem('isNewRename'); | |
| } | |
| }} | |
| /> | |
| </div> | |
| ) : ( | |
| <div className="chat-item-text truncate"> | |
| {conv.isPinned && <Pin size={12} className="chat-pin-icon" fill="currentColor" />} | |
| <span>{conv.title || 'Đoạn chat mới'}</span> | |
| </div> | |
| )} | |
| {/* Chat Actions Menu */} | |
| {!editingId && ( | |
| <div className="chat-item-actions"> | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| const rect = e.currentTarget.getBoundingClientRect(); | |
| const spaceBelow = window.innerHeight - rect.bottom; | |
| const newPlacement = spaceBelow < 200 ? 'top' : 'bottom'; | |
| setMenuPlacement(newPlacement); | |
| setActiveMenuId(activeMenuId === conv.id ? null : conv.id) | |
| }} | |
| className="menu-trigger-btn" | |
| > | |
| <MoreHorizontal size={15.5} /> | |
| </button> | |
| {activeMenuId === conv.id && ( | |
| <div className={`chat-menu-dropdown glass placement-${menuPlacement}`} onClick={e => e.stopPropagation()}> | |
| <button className="menu-item" onClick={() => handleStartRename(conv)}> | |
| <Edit size={15} /> | |
| <span>Đổi tên</span> | |
| </button> | |
| <button className="menu-item" onClick={() => { onTogglePin(conv.id); setActiveMenuId(null); }}> | |
| <Pin size={15} fill={conv.isPinned ? "currentColor" : "none"} /> | |
| <span>{conv.isPinned ? "Bỏ ghim" : "Ghim"}</span> | |
| </button> | |
| <button className="menu-item" onClick={() => { onToggleArchive(conv.id); setActiveMenuId(null); }}> | |
| <Archive size={15} /> | |
| <span>Lưu trữ</span> | |
| </button> | |
| <div className="menu-separator" /> | |
| <button className="menu-item delete" onClick={() => { onDeleteConversation(conv.id); setActiveMenuId(null); }}> | |
| <Trash2 size={15} /> | |
| <span>Xóa</span> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| return ( | |
| <div className={`sidebar-container ${isOpen ? 'open' : 'closed'}`}> | |
| <div className="sidebar-inner"> | |
| {/* Header Actions */} | |
| <div className="sidebar-top-nav"> | |
| <div className="nav-header"> | |
| <div className="sidebar-brand"> | |
| <img src={pochiLogo} alt="Pochi Logo" className="brand-logo" id="tour-logo" /> | |
| </div> | |
| {isOpen && ( | |
| <button | |
| className="toggle-sidebar-trigger" | |
| onClick={toggleSidebar} | |
| title="Đóng sidebar" | |
| id="tour-toggle-sidebar" | |
| > | |
| <PanelLeft size={20} /> | |
| </button> | |
| )} | |
| </div> | |
| <div className="nav-list"> | |
| <button className="nav-item new-chat-btn" onClick={onNewChat} id="tour-new-chat"> | |
| <div className="nav-item-icon"> | |
| <Edit size={18} /> | |
| </div> | |
| <span>Đoạn chat mới</span> | |
| {isOpen && ( | |
| <div className="shortcut-hint"> | |
| {navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? '⌘E' : 'Ctrl E'} | |
| </div> | |
| )} | |
| </button> | |
| <button className="nav-item search-btn" onClick={onSearchClick} id="tour-search"> | |
| <div className="nav-item-icon"> | |
| <Search size={18} /> | |
| </div> | |
| <span>Tìm kiếm đoạn chat</span> | |
| {isOpen && ( | |
| <div className="shortcut-hint"> | |
| {navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? '⌘F' : 'Ctrl F'} | |
| </div> | |
| )} | |
| </button> | |
| <div className="nav-item-pill disabled"> | |
| <div className="nav-item-icon"> | |
| <Image size={18} /> | |
| </div> | |
| <span>Ảnh <span className="badge-new">MỚI</span></span> | |
| </div> | |
| <div className="nav-item-pill disabled"> | |
| <div className="nav-item-icon"> | |
| <LayoutGrid size={18} /> | |
| </div> | |
| <span>Ứng dụng</span> | |
| </div> | |
| <div className="nav-item-pill disabled"> | |
| <div className="nav-item-icon"> | |
| <Folder size={18} /> | |
| </div> | |
| <span>Dự án</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="sidebar-history" id="tour-history-section"> | |
| {/* Clickable area when sidebar is closed - from "Dự án" down to footer (but not including footer) */} | |
| {!isOpen && ( | |
| <div | |
| className="sidebar-clickable-area" | |
| onClick={toggleSidebar} | |
| /> | |
| )} | |
| <div | |
| className="history-label collapsible" | |
| onClick={() => setIsHistoryCollapsed(!isHistoryCollapsed)} | |
| id="tour-history-label" | |
| > | |
| <span>Các đoạn chat của bạn</span> | |
| {isHistoryCollapsed ? <ChevronRight size={15} /> : <ChevronDown size={15} />} | |
| </div> | |
| {!isHistoryCollapsed && ( | |
| <div | |
| className="history-scroll-area custom-scrollbar" | |
| ref={historyScrollRef} | |
| onScroll={handleHistoryScroll} | |
| > | |
| {pinnedChats.length > 0 && ( | |
| <div className="history-section"> | |
| {pinnedChats.map(renderChatItem)} | |
| </div> | |
| )} | |
| <div className="history-section"> | |
| {activeChats.length > 0 ? ( | |
| activeChats.map(renderChatItem) | |
| ) : ( | |
| <div className="history-empty">Chưa có cuộc trò chuyện nào</div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| <div className="sidebar-footer" id="tour-profile"> | |
| <div className="profile-section" onClick={() => onSettingsClick('account')}> | |
| <div className="profile-avatar" id="tour-profile-sidebar-avatar"> | |
| {userProfile?.avatar ? ( | |
| <img src={userProfile.avatar} alt="Avatar" className="sidebar-avatar-img" /> | |
| ) : ( | |
| <div className="avatar-circle">{userProfile?.name?.charAt(0) || 'U'}</div> | |
| )} | |
| </div> | |
| <div className="profile-info"> | |
| <div className="profile-name">{userProfile?.name || 'Vô danh'}</div> | |
| <div className="profile-plan">Free</div> | |
| </div> | |
| <button className="upgrade-btn" onClick={(e) => { e.stopPropagation(); /* handle upgrade */ }}> | |
| Nâng cấp | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| export default Sidebar | |