Đỗ Hải Nam
change hardcoded url
c107c22
import React, { useState, useEffect, useMemo } from 'react'
import { Search, X, MessageSquare, Clock, ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Maximize, Download, RotateCcw, Settings, User, Sun, Edit, Archive } from 'lucide-react'
export const SearchModal = ({ onSelect, onClose, conversations = [], isRestored = false }) => {
// Lock in the initial isRestored value to prevent re-triggering animations if App re-renders
const [wasRestored] = useState(isRestored)
const [query, setQuery] = useState(() => {
return sessionStorage.getItem('searchQuery') || ''
})
const [results, setResults] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [debouncedQuery, setDebouncedQuery] = useState('')
useEffect(() => {
sessionStorage.setItem('searchQuery', query)
const timer = setTimeout(() => setDebouncedQuery(query), 300)
return () => clearTimeout(timer)
}, [query])
useEffect(() => {
if (!debouncedQuery || debouncedQuery.trim().length < 1) {
setResults([])
return
}
const fetchResults = async () => {
setIsLoading(true)
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`)
if (res.ok) {
const data = await res.json()
setResults(data)
}
} catch (error) {
console.error("Search failed", error)
} finally {
setIsLoading(false)
}
}
fetchResults()
}, [debouncedQuery])
const groupedConversations = React.useMemo(() => {
if (query.trim().length >= 1) return null;
const groups = { today: [], yesterday: [], last7Days: [], older: [] }
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
const last7Days = new Date(today)
last7Days.setDate(last7Days.getDate() - 7)
conversations.forEach(conv => {
const dateStr = conv.updated_at || conv.created_at
if (!dateStr) { groups.older.push(conv); return; }
const date = new Date(dateStr)
if (date >= today) groups.today.push(conv)
else if (date >= yesterday) groups.yesterday.push(conv)
else if (date >= last7Days) groups.last7Days.push(conv)
else groups.older.push(conv)
})
return groups
}, [conversations, query])
useEffect(() => {
const handleEsc = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', handleEsc)
return () => window.removeEventListener('keydown', handleEsc)
}, [onClose])
const highlightText = (text, highlight) => {
if (!highlight) return text
// Function to strip Vietnamese accents for matching
const stripAccents = (str) => {
return str.normalize('NFD')
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d")
.replace(/Đ/g, "D");
}
const normalizedText = stripAccents(text.toLowerCase())
const normalizedQuery = stripAccents(highlight.toLowerCase())
if (!normalizedText.includes(normalizedQuery)) return text
const parts = []
let lastIdx = 0
let idx = normalizedText.indexOf(normalizedQuery)
while (idx !== -1) {
// Push non-matching part
parts.push(text.substring(lastIdx, idx))
// Push matching part with original formatting
const matchedContent = text.substring(idx, idx + highlight.length)
parts.push(
<span key={idx} className="bg-brand-primary/20 text-brand-primary rounded px-0.5">
{text.substring(idx, idx + highlight.length)}
</span>
)
lastIdx = idx + highlight.length
idx = normalizedText.indexOf(normalizedQuery, lastIdx)
}
parts.push(text.substring(lastIdx))
return parts
}
const renderGroup = (title, items) => {
if (!items || items.length === 0) return null
return (
<div className="search-group">
<h3 className="group-title">{title}</h3>
<div className="group-items">
{items.map(conv => (
<button
key={conv.id}
onClick={() => { onSelect(conv.id); onClose(); }}
className="search-result-item"
>
<div className="item-icon">
<MessageSquare size={14} />
</div>
<span className="item-title truncate">{conv.title || 'Đoạn chat mới'}</span>
</button>
))}
</div>
</div>
)
}
return (
<div className={`modal-overlay centered ${wasRestored ? 'no-animation' : ''}`} onClick={onClose}>
<div className={`search-modal-container glass ${wasRestored ? 'no-animation' : ''}`} onClick={e => e.stopPropagation()}>
<div className="search-modal-header">
<Search className="w-5 h-5 text-text-tertiary" />
<input
type="text"
placeholder="Tìm kiếm tin nhắn, hội thoại..."
className="search-input"
value={query}
onChange={e => setQuery(e.target.value)}
autoFocus
/>
<div className="search-shortcut">ESC</div>
</div>
<div className="search-modal-content custom-scrollbar">
{query.trim().length >= 1 ? (
<div className="p-2">
{results.length > 0 ? (
<div className="flex flex-col gap-1">
{results.map((item) => (
<button
key={`${item.type}-${item.id}`}
onClick={() => { onSelect(item.conversation_id || item.id); onClose(); }}
className="search-result-item"
>
<div className="item-icon">
{item.type === 'conversation' ? <MessageSquare size={14} /> : <Search size={14} />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="item-title font-medium text-sm truncate">{highlightText(item.title, debouncedQuery)}</span>
<span className="item-date text-[10px] opacity-40 uppercase">{new Date(item.created_at).toLocaleDateString()}</span>
</div>
{item.content && (
<p className="text-xs opacity-60 line-clamp-1 mt-0.5">{highlightText(item.content, debouncedQuery)}</p>
)}
</div>
</button>
))}
</div>
) : (!isLoading && query.trim() === debouncedQuery.trim()) ? (
<div className="empty-state">Không tìm thấy kết quả nào cho "{query}"</div>
) : null}
</div>
) : (
<div className="py-2">
{groupedConversations && Object.entries(groupedConversations).map(([key, items]) => {
const labels = { today: 'Hôm nay', yesterday: 'Hôm qua', last7Days: '7 ngày qua', older: 'Cũ hơn' }
return renderGroup(labels[key], items)
})}
</div>
)}
</div>
</div>
</div>
)
}
export const SettingsModal = ({
onClose,
darkMode,
onToggleTheme,
archivedSessions = [],
onRestoreSession,
onUpdateProfile,
userProfile,
initialTab = 'general',
isRestored = false,
onTabChange
}) => {
// Lock in the initial isRestored value to prevent re-triggering animations if App re-renders
const [wasRestored] = useState(isRestored)
const [activeTab, setActiveTab] = useState(initialTab)
const [editName, setEditName] = useState(userProfile.name)
const [editEmail, setEditEmail] = useState(userProfile.email)
const [editAvatar, setEditAvatar] = useState(userProfile.avatar)
const [isSaving, setIsSaving] = useState(false)
const [showUnsavedWarning, setShowUnsavedWarning] = useState(false)
const [pendingAction, setPendingAction] = useState(null) // { type: 'tab', value: '...' } or { type: 'close' }
const tabs = [
{ id: 'general', label: 'Chung', icon: Settings },
{ id: 'account', label: 'Tài khoản', icon: User },
{ id: 'appearance', label: 'Giao diện', icon: Sun },
{ id: 'archived', label: 'Lưu trữ', icon: Archive }
]
const hasUnsavedChanges = useMemo(() => {
return editName !== userProfile.name ||
editEmail !== userProfile.email ||
editAvatar !== userProfile.avatar
}, [editName, editEmail, editAvatar, userProfile])
const handleTabClick = (tabId) => {
if (activeTab === 'account' && hasUnsavedChanges) {
setPendingAction({ type: 'tab', value: tabId })
setShowUnsavedWarning(true)
} else {
setActiveTab(tabId)
if (onTabChange) onTabChange(tabId)
}
}
const handleCloseClick = () => {
if (activeTab === 'account' && hasUnsavedChanges) {
setPendingAction({ type: 'close' })
setShowUnsavedWarning(true)
} else {
onClose()
}
}
const handleDiscard = () => {
// Reset local state to original
setEditName(userProfile.name)
setEditEmail(userProfile.email)
setEditAvatar(userProfile.avatar)
setShowUnsavedWarning(false)
if (pendingAction.type === 'tab') {
setActiveTab(pendingAction.value)
if (onTabChange) onTabChange(pendingAction.value)
} else if (pendingAction.type === 'close') {
onClose()
}
}
const handleSaveAndContinue = async () => {
setIsSaving(true)
await onUpdateProfile({ name: editName, email: editEmail, avatar: editAvatar })
setIsSaving(false)
setShowUnsavedWarning(false)
if (pendingAction.type === 'tab') {
setActiveTab(pendingAction.value)
} else if (pendingAction.type === 'close') {
onClose()
}
}
return (
<div className={`modal-overlay centered ${wasRestored ? 'no-animation' : ''}`} onClick={handleCloseClick}>
<div className={`settings-container glass ${wasRestored ? 'no-animation' : ''}`} onClick={e => e.stopPropagation()}>
{/* Sidebar Tabs */}
<div className="settings-nav">
<div className="nav-title">Cài đặt</div>
<div className="nav-items">
{tabs.map(tab => (
<button
key={tab.id}
className={`nav-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => handleTabClick(tab.id)}
>
<tab.icon size={18} />
<span>{tab.label}</span>
</button>
))}
</div>
</div>
<button className="body-close" onClick={handleCloseClick}><X size={20} /></button>
{/* Main Content */}
<div className="settings-body">
<div className="body-content custom-scrollbar">
<header className="content-header">
<h2>{tabs.find(t => t.id === activeTab).label}</h2>
</header>
{activeTab === 'general' && (
<div className="content-section">
<section className="setting-group">
<div className="group-info">
<h3>Dữ liệu huấn luyện</h3>
<p>Đóng góp các cuộc hội thoại để cải thiện mô hình AI cho mọi người.</p>
</div>
<div className="group-action">
<button className="toggle-pill on">Bật</button>
</div>
</section>
<section className="setting-group">
<div className="group-info">
<h3>Lưu trữ hội thoại</h3>
<p>Tự động lưu lịch sử chat vào tài khoản của bạn.</p>
</div>
<div className="group-action">
<button className="toggle-pill on">Bật</button>
</div>
</section>
</div>
)}
{activeTab === 'account' && (
<div className="content-section">
<div className="user-profile-card editing">
<div className="card-avatar large">
{editAvatar ? <img src={editAvatar} alt="Avatar" /> : <div className="avatar-placeholder large">{editName.charAt(0)}</div>}
<label className="avatar-edit-overlay">
<Edit size={16} />
<span>Thay đổi</span>
<input type="file" hidden accept="image/*" onChange={e => {
const file = e.target.files[0]
if (file) {
const reader = new FileReader()
reader.onloadend = () => {
setEditAvatar(reader.result)
}
reader.readAsDataURL(file)
}
}} />
</label>
</div>
<div className="card-form">
<div className="form-group">
<label>Họ và tên</label>
<input
type="text"
value={editName}
onChange={e => setEditName(e.target.value)}
placeholder="Nhập tên của bạn"
/>
</div>
<div className="form-group">
<label>Email</label>
<input
type="email"
value={editEmail}
onChange={e => setEditEmail(e.target.value)}
placeholder="social@example.com"
/>
</div>
<button
className={`save-profile-btn ${hasUnsavedChanges ? 'active' : ''}`}
disabled={isSaving || !hasUnsavedChanges}
onClick={() => {
setIsSaving(true)
onUpdateProfile({ name: editName, email: editEmail, avatar: editAvatar })
setTimeout(() => setIsSaving(false), 500)
}}
>
{isSaving ? 'Đang lưu...' : 'Lưu thay đổi'}
</button>
</div>
</div>
<div className="account-danger-zone">
<button className="danger-btn-outline">Đăng xuất khỏi tất cả các thiết bị</button>
<button className="danger-text">Xóa tài khoản</button>
</div>
</div>
)}
{activeTab === 'appearance' && (
<div className="content-section">
<div className="appearance-grid">
<button
className={`theme-card ${!darkMode ? 'active' : ''}`}
onClick={() => !darkMode || onToggleTheme()}
>
<div className="theme-preview light" />
<span>Giao diện sáng</span>
</button>
<button
className={`theme-card ${darkMode ? 'active' : ''}`}
onClick={() => darkMode || onToggleTheme()}
>
<div className="theme-preview dark" />
<span>Giao diện tối</span>
</button>
</div>
</div>
)}
{activeTab === 'archived' && (
<div className="content-section">
{archivedSessions.length > 0 ? (
<div className="archived-list">
{archivedSessions.map(session => (
<div key={session.id} className="archived-row">
<div className="row-info">
<span className="row-title">{session.title || 'Không có tiêu đề'}</span>
<span className="row-date">{new Date(session.created_at).toLocaleDateString()}</span>
</div>
<button
className="restore-btn"
onClick={() => onRestoreSession(session.id)}
title="Khôi phục"
>
<Archive size={14} />
Khôi phục
</button>
</div>
))}
</div>
) : (
<div className="empty-state">
<Clock size={40} className="mb-4 opacity-20" />
<p>Không có hội thoại nào được lưu trữ</p>
</div>
)}
</div>
)}
</div>
</div>
{/* Unsaved Changes Warning Overlay */}
{showUnsavedWarning && (
<div className="unsaved-warning-overlay glass">
<div className="warning-content">
<div className="warning-icon-wrapper">
<Clock size={28} className="text-amber-500 animate-pulse" />
</div>
<h3>Bạn chưa lưu thay đổi</h3>
<p>Các chỉnh sửa trong phần tài khoản của bạn chưa được lưu lại. Bạn muốn làm gì tiếp theo?</p>
<div className="warning-actions">
<button className="warning-btn discard" onClick={handleDiscard}>Bỏ qua</button>
<button className="warning-btn cancel" onClick={() => setShowUnsavedWarning(false)}>Quay lại</button>
<button className="warning-btn save" onClick={handleSaveAndContinue}>Lưu & Tiếp tục</button>
</div>
</div>
</div>
)}
</div>
</div>
)
}
// Helper needed for the above
const renderedGroups = (groups, renderer) => {
if (!groups) return null;
return (
<>
{renderer("Hôm nay", groups.today)}
{renderer("Hôm qua", groups.yesterday)}
{renderer("7 ngày trước", groups.last7Days)}
{renderer("Cũ hơn", groups.older)}
</>
)
}
export const ImageViewer = ({ images = [], startIndex = 0, onClose, onIndexChange, isRestored = false }) => {
// Lock in the initial isRestored value to prevent re-triggering animations if App re-renders
const [wasRestored] = useState(isRestored)
const [currentIndex, setCurrentIndex] = useState(startIndex)
const [zoom, setZoom] = useState(1)
const [rotation, setRotation] = useState(0)
// Sync current index to parent for persistence
useEffect(() => {
if (onIndexChange) onIndexChange(currentIndex)
}, [currentIndex, onIndexChange])
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') handlePrev()
if (e.key === 'ArrowRight') handleNext()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentIndex, images.length])
const handleNext = () => {
setCurrentIndex((prev) => (prev + 1) % images.length)
setZoom(1)
setRotation(0)
}
const handlePrev = () => {
setCurrentIndex((prev) => (prev - 1 + images.length) % images.length)
setZoom(1)
setRotation(0)
}
const handleZoomIn = () => setZoom(prev => Math.min(prev + 0.25, 3))
const handleZoomOut = () => setZoom(prev => Math.max(prev - 0.25, 0.5))
const handleReset = () => {
setZoom(1)
setRotation(0)
}
const handleDownload = () => {
const link = document.createElement('a')
link.href = images[currentIndex]
link.download = `image-${currentIndex + 1}.png`
link.click()
}
if (!images || images.length === 0) return null
return (
<div className={`premium-viewer-overlay ${wasRestored ? 'is-restored' : ''}`} onClick={onClose}>
{/* Top Controls */}
<div className="viewer-top-bar" onClick={e => e.stopPropagation()}>
<div className="viewer-info">
{currentIndex + 1} / {images.length}
</div>
<div className="viewer-actions">
<button onClick={handleZoomOut} title="Thu nhỏ"><ZoomOut size={20} /></button>
<button onClick={handleZoomIn} title="Phóng to"><ZoomIn size={20} /></button>
<button onClick={handleReset} title="Đặt lại"><RotateCcw size={20} /></button>
<div className="viewer-divider"></div>
<button onClick={handleDownload} title="Tải về"><Download size={20} /></button>
<button onClick={onClose} className="viewer-close-btn" title="Đóng (Esc)"><X size={20} /></button>
</div>
</div>
{/* Navigation Arrows */}
{images.length > 1 && (
<>
<button
className="viewer-nav-btn prev"
onClick={(e) => { e.stopPropagation(); handlePrev(); }}
>
<ChevronLeft size={32} />
</button>
<button
className="viewer-nav-btn next"
onClick={(e) => { e.stopPropagation(); handleNext(); }}
>
<ChevronRight size={32} />
</button>
</>
)}
{/* Image Container */}
<div className="viewer-image-container" onClick={e => e.stopPropagation()}>
<img
src={images[currentIndex]}
alt={`Preview ${currentIndex + 1}`}
style={{
transform: `scale(${zoom}) rotate(${rotation}deg)`,
transition: zoom === 1 ? 'transform 0.3s cubic-bezier(0.16, 1, 0.3, 1)' : 'none'
}}
className="viewer-main-image"
/>
</div>
{/* Thumbnails Strip */}
{images.length > 1 && (
<div className="viewer-thumbnails-strip" onClick={e => e.stopPropagation()}>
{images.map((img, idx) => (
<div
key={idx}
className={`viewer-thumb-item ${idx === currentIndex ? 'active' : ''}`}
onClick={() => {
setCurrentIndex(idx)
setZoom(1)
setRotation(0)
}}
>
<img src={img} alt={`Thumb ${idx}`} />
</div>
))}
</div>
)}
</div>
)
}