Đỗ Hải Nam
fix: move stable assets to /assets/ prefix to ensures they are served by FastAPI in production
99a99c6
import { useState, useEffect, useLayoutEffect, useRef } from 'react'
import Header from './components/Header'
import Sidebar from './components/Sidebar'
import MessageList from './components/MessageList'
import ChatInput from './components/ChatInput'
import { SearchModal, ImageViewer, SettingsModal } from './components/Modals'
import { Menu, MoreHorizontal } from 'lucide-react'
import './App.css'
import GuideTour from './components/GuideTour'
const pochiAsset = '/assets/pochi.jpeg'
const hnamAsset = '/assets/hnam.jpeg'
const defaultAvatar = hnamAsset // Set Hnam as default for Guest as requested
const API_BASE = '/api'
function App() {
// --- State Management ---
const [darkMode, setDarkMode] = useState(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('darkMode')
if (saved !== null) return saved === 'true'
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
return false
})
const [conversations, setConversations] = useState([])
const [currentConversation, setCurrentConversation] = useState(() => {
const lastId = localStorage.getItem('lastConversationId')
return (lastId === 'null' || !lastId) ? null : lastId
})
const [messages, setMessages] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [isInitializing, setIsInitializing] = useState(true) // Prevents flash on refresh
// User Profile State
const [userProfile, setUserProfile] = useState(() => {
const saved = localStorage.getItem('user_profile')
if (saved) {
try {
const profile = JSON.parse(saved)
// Migrate old static paths or broken hashed paths to new stable assets
if (profile.avatar && typeof profile.avatar === 'string') {
if (profile.avatar.includes('pochi') && profile.avatar !== pochiAsset) {
profile.avatar = pochiAsset
localStorage.setItem('user_profile', JSON.stringify(profile))
} else if (profile.avatar.includes('hnam') && profile.avatar !== hnamAsset) {
// Only update if it's an old asset path (not a base64 string)
if (!profile.avatar.startsWith('data:')) {
profile.avatar = hnamAsset
localStorage.setItem('user_profile', JSON.stringify(profile))
}
}
}
return profile
} catch (e) {
console.error('Failed to parse user_profile', e)
}
}
return {
name: 'Guest',
email: 'guest@example.com',
avatar: hnamAsset
}
})
const handleUpdateProfile = (newProfile) => {
const updated = { ...userProfile, ...newProfile }
setUserProfile(updated)
localStorage.setItem('user_profile', JSON.stringify(updated))
}
// UI State
const [sidebarOpen, setSidebarOpen] = useState(() => {
const saved = localStorage.getItem('sidebarOpen')
return saved !== null ? saved === 'true' : true
})
const [showSearch, setShowSearch] = useState(() => {
return sessionStorage.getItem('showSearch') === 'true'
})
const [tourVersion, setTourVersion] = useState(0)
const [showTour, setShowTour] = useState(() => {
const hasSeenTour = localStorage.getItem('hasSeenTour')
const savedIndex = localStorage.getItem('tourStepIndex')
// Auto-show if never seen or if interrupted (index exists and is not -1)
return !hasSeenTour || (savedIndex !== null && savedIndex !== '-1')
})
const [showSettings, setShowSettings] = useState(() => {
return sessionStorage.getItem('showSettings') === 'true'
})
const [settingsTab, setSettingsTab] = useState(() => {
return localStorage.getItem('settingsTab') || 'general'
})
const [viewerData, setViewerData] = useState(() => {
try {
const saved = sessionStorage.getItem('viewerData')
return saved ? JSON.parse(saved) : null
} catch (e) {
console.error('Failed to parse viewerData:', e)
return null
}
}) // { images: [], index: 0 }
const [memoryStatus, setMemoryStatus] = useState(null) // For future memory blocking features
const [showPinLimitToast, setShowPinLimitToast] = useState(false)
const [isMobile, setIsMobile] = useState(window.innerWidth < 768)
const [showRenameModal, setShowRenameModal] = useState(() => {
return sessionStorage.getItem('showRenameModal') === 'true'
})
const [modalTempTitle, setModalTempTitle] = useState(() => {
return sessionStorage.getItem('modalTempTitle') || ''
})
const appRef = useRef(null)
const isCreatingSessionRef = useRef(false)
const isSendingMessageRef = useRef(false) // Track if we're currently sending a message
const isInitialMountRef = useRef(true) // Track if this is the first mount after refresh
// --- Effects ---
// Note: Removed useLayoutEffect scroll reset - it caused more issues than it solved.
// Scroll persistence is now handled in MessageList.
// Dark Mode - Apply synchronously before paint to prevent flickering
useLayoutEffect(() => {
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(darkMode ? 'dark' : 'light')
localStorage.setItem('darkMode', darkMode)
}, [darkMode])
useEffect(() => {
localStorage.setItem('sidebarOpen', sidebarOpen)
}, [sidebarOpen])
useEffect(() => {
sessionStorage.setItem('showSearch', showSearch)
}, [showSearch])
useEffect(() => {
sessionStorage.setItem('showSettings', showSettings)
}, [showSettings])
useEffect(() => {
localStorage.setItem('settingsTab', settingsTab)
}, [settingsTab])
useEffect(() => {
sessionStorage.setItem('showRenameModal', showRenameModal)
sessionStorage.setItem('modalTempTitle', modalTempTitle)
}, [showRenameModal, modalTempTitle])
useEffect(() => {
if (viewerData) {
sessionStorage.setItem('viewerData', JSON.stringify(viewerData))
} else {
sessionStorage.removeItem('viewerData')
}
}, [viewerData])
// Scroll Restoration & Event Listeners
useEffect(() => {
// Disable native scroll restoration to prevent jump on refresh
if ('scrollRestoration' in window.history) {
window.history.scrollRestoration = 'manual'
}
const handleResize = () => setIsMobile(window.innerWidth < 768)
const handleKeyDown = (e) => {
// Cmd+F or Ctrl+F for Search
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
e.preventDefault()
setShowSearch(true)
}
// Cmd+E or Ctrl+E for New Chat
if ((e.metaKey || e.ctrlKey) && e.key === 'e') {
e.preventDefault()
createConversation()
}
// Cmd+X or Ctrl+X for General Settings
if ((e.metaKey || e.ctrlKey) && e.key === 'x') {
e.preventDefault()
setSettingsTab('general')
setShowSettings(true)
}
// Cmd+I or Ctrl+I for Account Settings
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
e.preventDefault()
setSettingsTab('account')
setShowSettings(true)
}
// Cmd+K or Ctrl+K for Dark Mode Toggle
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setDarkMode(prev => !prev)
}
}
window.addEventListener('resize', handleResize)
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('resize', handleResize)
window.removeEventListener('keydown', handleKeyDown)
}
}, [])
// Load Conversations on Mount
useEffect(() => {
const initConversations = async () => {
try {
const res = await fetch(`${API_BASE}/conversations`)
const data = await res.json()
// Merge persisted metadata (Pin/Archive)
const persistedMetadata = JSON.parse(localStorage.getItem('chat_metadata') || '{}')
const mergedData = data.map(conv => ({
...conv,
isPinned: persistedMetadata[conv.id]?.isPinned || false,
isArchived: persistedMetadata[conv.id]?.isArchived || false
}))
setConversations(mergedData)
const savedId = localStorage.getItem('lastConversationId')
if (savedId) {
const exists = data.find(c => c.id === savedId)
if (exists) {
// Load messages for saved conversation before showing UI
await loadMessagesSync(savedId)
} else {
localStorage.removeItem('lastConversationId')
setCurrentConversation(null)
setMessages([])
}
}
} catch (error) {
console.error('Failed to fetch conversations:', error)
} finally {
// Always mark initialization as complete
setIsInitializing(false)
}
}
initConversations()
}, [])
// Mark initial mount as complete after first render cycle of the REAL UI
useEffect(() => {
if (!isInitializing) {
const timer = setTimeout(() => {
isInitialMountRef.current = false
}, 300) // Slightly longer to be safe
return () => clearTimeout(timer)
}
}, [isInitializing])
// Load Messages when Conversation Changes
useEffect(() => {
if (currentConversation) {
if (isCreatingSessionRef.current) {
// If we JUST created this session, don't clear messages!
// The current messages state already contains the optimistic user message.
isCreatingSessionRef.current = false
return
}
// Skip loading if we're currently sending a message (to prevent race condition)
if (isSendingMessageRef.current) {
return
}
loadMessages(currentConversation)
localStorage.setItem('lastConversationId', currentConversation)
} else {
setMessages([])
}
}, [currentConversation])
// Auto-refresh for pending messages (e.g., if user returns to a generating session)
useEffect(() => {
if (!currentConversation || isLoading || messages.length === 0) return
const lastMsg = messages[messages.length - 1]
const isPending = lastMsg.role === 'assistant' && !lastMsg.content
if (isPending) {
const pollInterval = setInterval(() => {
loadMessagesSync(currentConversation)
}, 3000) // Poll every 3s
return () => clearInterval(pollInterval)
}
}, [messages, currentConversation, isLoading])
// Simulation Engine for "Resume Streaming" experience
useEffect(() => {
if (messages.length === 0 || isLoading) return
const lastIdx = messages.length - 1
const lastMsg = messages[lastIdx]
if (lastMsg.isSimulating && lastMsg._fullContent) {
const simulationTimer = setTimeout(() => {
setMessages(prev => {
const newMessages = [...prev]
const msg = newMessages[lastIdx]
if (!msg || !msg.isSimulating) return prev
const currentLen = msg.content.length
const fullContent = msg._fullContent
// Batch size for simulation (approx 24 chars for that "super fast" feel)
const batchSize = 24
const nextContent = fullContent.substring(0, currentLen + batchSize)
msg.content = nextContent
if (nextContent.length >= fullContent.length) {
msg.isStreaming = false
msg.isSimulating = false
delete msg._fullContent
}
return newMessages
})
}, 5) // Very fast update for simulation
return () => clearTimeout(simulationTimer)
}
}, [messages, isLoading])
// --- Actions ---
const loadMessages = async (conversationId) => {
try {
const res = await fetch(`${API_BASE}/conversations/${conversationId}/messages`)
const data = await res.json()
setMessages(parseMessagesData(data))
} catch (error) {
console.error('Failed to load messages:', error)
}
}
// Sync version for initial load (doesn't trigger effects that cause scroll)
const loadMessagesSync = async (conversationId) => {
try {
const res = await fetch(`${API_BASE}/conversations/${conversationId}/messages`)
const data = await res.json()
setMessages(parseMessagesData(data))
} catch (error) {
console.error('Failed to load messages:', error)
}
}
// Helper to parse messages data
const parseMessagesData = (data) => {
const TYPING_SPEED = 0.25 // chars/ms (approx 250 chars/sec)
const now = new Date().getTime()
return data.map((m, idx) => {
let images = []
if (m.image_data) {
try {
const parsed = JSON.parse(m.image_data)
if (Array.isArray(parsed)) {
images = parsed.map(b64 => `data:image/jpeg;base64,${b64}`)
} else {
images = [`data:image/jpeg;base64,${m.image_data}`]
}
} catch (e) {
images = [`data:image/jpeg;base64,${m.image_data}`]
}
}
const createdAt = new Date(m.created_at).getTime()
const elapsed = now - createdAt
const fullContent = m.content || ''
// Only simulate for the VERY LAST message if it's an assistant message
const isLastMessage = idx === data.length - 1
const shouldSimulate = isLastMessage && m.role === 'assistant' && fullContent.length > 0
if (shouldSimulate) {
const expectedVisibleLength = Math.floor(elapsed * TYPING_SPEED)
if (expectedVisibleLength < fullContent.length) {
return {
role: m.role,
content: fullContent.substring(0, Math.max(0, expectedVisibleLength)),
_fullContent: fullContent, // Hidden property for simulation
images: images,
isStreaming: true,
isSimulating: true // Flag for simulation
}
}
}
return {
role: m.role,
content: fullContent,
images: images,
// If assistant message is empty, it means it's still being generated in background
isStreaming: m.role === 'assistant' && !fullContent
}
})
}
const createConversation = () => {
setCurrentConversation(null)
setMessages([])
localStorage.removeItem('lastConversationId') // Clear instead of setting "null"
if (window.innerWidth < 768) setSidebarOpen(false)
}
const deleteConversation = async (id) => {
try {
await fetch(`${API_BASE}/conversations/${id}`, { method: 'DELETE' })
const remaining = conversations.filter(c => c.id !== id)
setConversations(remaining)
if (currentConversation === id) {
setCurrentConversation(null)
setMessages([])
localStorage.removeItem('lastConversationId')
}
// Cleanup metadata
const metadata = JSON.parse(localStorage.getItem('chat_metadata') || '{}')
if (metadata[id]) {
delete metadata[id]
localStorage.setItem('chat_metadata', JSON.stringify(metadata))
}
// Cleanup message expansion states
Object.keys(localStorage).forEach(key => {
if (key.startsWith(`expand_${id}`)) {
localStorage.removeItem(key)
}
})
} catch (error) {
console.error('Failed to delete conversation:', error)
}
}
const renameConversation = async (id, newTitle) => {
// Optimistic UI update
setConversations(prev => prev.map(c =>
c.id === id ? { ...c, title: newTitle } : c
))
try {
const res = await fetch(`${API_BASE}/conversations/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle })
})
if (!res.ok) throw new Error('Failed to rename on server')
} catch (error) {
console.error('Failed to rename conversation:', error)
// Revert on error? (Optional, maybe just notify)
}
}
const handleSaveRename = () => {
if (modalTempTitle.trim() && currentConversation) {
renameConversation(currentConversation, modalTempTitle)
}
setShowRenameModal(false)
}
const togglePin = (id) => {
setConversations(prev => {
const isCurrentlyPinned = prev.find(c => c.id === id)?.isPinned
const currentPinnedCount = prev.filter(c => c.isPinned).length
// If we are pinning (not unpinning) and already at 5, stop and warn.
if (!isCurrentlyPinned && currentPinnedCount >= 5) {
setShowPinLimitToast(true)
setTimeout(() => setShowPinLimitToast(false), 3000)
return prev
}
const updated = prev.map(c => {
if (c.id === id) {
const newPinned = !c.isPinned
return { ...c, isPinned: newPinned, isArchived: newPinned ? false : c.isArchived }
}
return c
})
// Persist metadata
const metadata = JSON.parse(localStorage.getItem('chat_metadata') || '{}')
const conv = updated.find(c => c.id === id)
metadata[id] = { ...metadata[id], isPinned: conv.isPinned, isArchived: conv.isArchived }
localStorage.setItem('chat_metadata', JSON.stringify(metadata))
return updated
})
}
const toggleArchive = (id) => {
setConversations(prev => {
const updated = prev.map(c => {
if (c.id === id) {
const newArchived = !c.isArchived
return { ...c, isArchived: newArchived, isPinned: newArchived ? false : c.isPinned }
}
return c
})
// If the current conversation is archived, deselect it
if (currentConversation === id) {
const isArchiving = !prev.find(c => c.id === id)?.isArchived
if (isArchiving) {
setCurrentConversation(null)
setMessages([])
}
}
// Persist metadata
const metadata = JSON.parse(localStorage.getItem('chat_metadata') || '{}')
const conv = updated.find(c => c.id === id)
metadata[id] = { ...metadata[id], isPinned: conv.isPinned, isArchived: conv.isArchived }
localStorage.setItem('chat_metadata', JSON.stringify(metadata))
return updated
})
}
const sendMessage = async (text, uploadedImages) => {
const userMessage = text.trim()
const imagePreviews = uploadedImages.map(img => img.preview)
// Set flag to prevent loadMessages from overwriting optimistic updates
isSendingMessageRef.current = true
// Optimistic UI update
setMessages(prev => [...prev, {
role: 'user',
content: userMessage,
images: imagePreviews
}])
setIsLoading(true)
const formData = new FormData()
formData.append('message', userMessage)
if (currentConversation) formData.append('session_id', currentConversation)
uploadedImages.forEach(img => {
formData.append('images', img.file)
})
try {
const res = await fetch(`${API_BASE}/chat`, { method: 'POST', body: formData })
const sessionId = res.headers.get('X-Session-Id')
// Handle New Session
if (sessionId && !currentConversation) {
isCreatingSessionRef.current = true
setCurrentConversation(sessionId)
localStorage.setItem('lastConversationId', sessionId)
setConversations(prev => {
if (prev.find(c => c.id === sessionId)) return prev
return [{ id: sessionId, title: userMessage.slice(0, 50), created_at: new Date().toISOString() }, ...prev]
})
}
// Stream Response
const reader = res.body.getReader()
const decoder = new TextDecoder()
let assistantMessage = ''
setMessages(prev => [...prev, { role: 'assistant', content: '', isStreaming: true }])
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n').filter(l => l.trim().length > 0)
let batchTokens = 0
const BATCH_SIZE = 8 // Update UI every 8 tokens if they arrive at once
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') break
let parsed = null
try { parsed = JSON.parse(data) }
catch { parsed = data } // Legacy fallback
if (typeof parsed === 'object' && parsed !== null && parsed.type) {
if (parsed.type === 'token') {
assistantMessage += parsed.content
batchTokens++
} else if (parsed.type === 'status') {
setMessages(prev => {
const newMessages = [...prev]
if (newMessages.length > 0) newMessages[newMessages.length - 1].status = parsed.status
return newMessages
})
} else if (parsed.type === 'done') {
break;
}
} else if (typeof parsed === 'string') {
assistantMessage += parsed
batchTokens++
}
// Update UI either on batch size or end of chunk lines
if (batchTokens >= BATCH_SIZE || i === lines.length - 1) {
setMessages(prev => {
const newMessages = [...prev]
const lastMsg = newMessages[newMessages.length - 1]
if (lastMsg) lastMsg.content = assistantMessage
return newMessages
})
batchTokens = 0
// A very tiny delay between batches to keep the main thread breathing
// and maintain the typewriter feel
await new Promise(resolve => setTimeout(resolve, 2))
}
}
}
}
// Finish Streaming
setMessages(prev => {
const newMessages = [...prev]
if (newMessages.length > 0) {
newMessages[newMessages.length - 1].isStreaming = false
newMessages[newMessages.length - 1].status = null // Clear any persistent "Thinking..." status
}
return newMessages
})
} catch (error) {
console.error('Failed to send message:', error)
setMessages(prev => {
const newMessages = [...prev]
// If the last message was the streaming one, mark it as finished/error
if (newMessages.length > 0 && newMessages[newMessages.length - 1].role === 'assistant') {
newMessages[newMessages.length - 1].isStreaming = false
newMessages[newMessages.length - 1].status = null
}
return [...newMessages, { role: 'assistant', content: 'Xin lỗi, đã có lỗi xảy ra.' }]
})
} finally {
setIsLoading(false)
// Clear flag after message sending is complete
isSendingMessageRef.current = false
}
}
return (
<div className="app-container" ref={appRef}>
{isInitializing ? (
// Only show loading if viewer is NOT active
!viewerData && (
<div className="flex items-center justify-center h-screen w-screen bg-bg-primary">
<div className="animate-spin w-8 h-8 border-4 border-primary border-t-transparent rounded-full"></div>
</div>
)
) : (
<>
{showTour && (
<GuideTour
darkMode={darkMode}
tourVersion={tourVersion}
onTourEnd={() => {
setTourVersion(0)
setShowTour(false)
}}
/>
)}
<Sidebar
isOpen={sidebarOpen}
toggleSidebar={() => setSidebarOpen(!sidebarOpen)}
conversations={[...conversations].sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1
if (!a.isPinned && b.isPinned) return 1
const dateA = a.created_at ? new Date(a.created_at).getTime() : 0
const dateB = b.created_at ? new Date(b.created_at).getTime() : 0
return dateB - dateA
})}
currentConversationId={currentConversation}
onSelectConversation={(id) => {
setCurrentConversation(id)
if (window.innerWidth < 768) setSidebarOpen(false)
}}
onNewChat={createConversation}
onDeleteConversation={deleteConversation}
onRenameConversation={renameConversation}
onTogglePin={togglePin}
onToggleArchive={toggleArchive}
onSearchClick={() => setShowSearch(true)}
onSettingsClick={(tab = 'general') => {
setSettingsTab(tab)
setShowSettings(true)
}}
darkMode={darkMode}
toggleTheme={() => setDarkMode(!darkMode)}
userProfile={userProfile}
/>
<main className="main-content">
<Header
onOpenSidebar={() => setSidebarOpen(true)}
isMobile={isMobile}
currentConversationId={currentConversation}
onDeleteConversation={deleteConversation}
onRenameConversation={renameConversation}
onTogglePin={togglePin}
onToggleArchive={toggleArchive}
currentChat={conversations.find(c => c.id === currentConversation)}
onRenameClick={() => {
const currentConv = conversations.find(c => c.id === currentConversation)
setModalTempTitle(currentConv?.title || '')
setShowRenameModal(true)
sessionStorage.setItem('isNewRenameModal', 'true')
}}
title={conversations.find(c => c.id === currentConversation)?.title}
onSettingsClick={(tab = 'general') => {
setSettingsTab(tab)
setShowSettings(true)
}}
onToggleTheme={() => setDarkMode(!darkMode)}
darkMode={darkMode}
userProfile={userProfile}
onHelpClick={() => {
setTourVersion(prev => prev + 1)
setShowTour(true)
}}
/>
{showRenameModal && (
<div className="rename-modal-overlay" onClick={() => setShowRenameModal(false)}>
<div className="rename-modal-content" onClick={e => e.stopPropagation()}>
<h3 className="rename-modal-title">Đổi tên đoạn chat</h3>
<input
className="premium-rename-input"
value={modalTempTitle}
onChange={(e) => setModalTempTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveRename()
if (e.key === 'Escape') setShowRenameModal(false)
}}
autoFocus
onFocus={e => {
const isRestored = !sessionStorage.getItem('isNewRenameModal');
if (!isRestored) {
e.target.select();
sessionStorage.removeItem('isNewRenameModal');
}
}}
/>
<div className="rename-modal-actions">
<button className="modal-btn modal-btn-cancel" onClick={() => setShowRenameModal(false)}>Hủy</button>
<button className="modal-btn modal-btn-save" onClick={handleSaveRename}>Lưu</button>
</div>
</div>
</div>
)}
{isLoading && (
<div className="loading-bar">
<div className="loading-progress"></div>
</div>
)}
<MessageList
messages={messages}
isLoading={isLoading}
conversationId={currentConversation}
onExampleClick={(text) => sendMessage(text, [])}
onImageClick={(images, index) => setViewerData({ images, index })}
userAvatar={userProfile.avatar}
userName={userProfile.name}
/>
<ChatInput
onSendMessage={sendMessage}
isLoading={isLoading}
onImageClick={(images, index) => setViewerData({ images, index })}
/>
</main>
{showSearch && (
<SearchModal
conversations={conversations}
onSelect={(id) => setCurrentConversation(id)}
onClose={() => setShowSearch(false)}
isRestored={isInitialMountRef.current}
/>
)}
{showSettings && (
<SettingsModal
onClose={() => setShowSettings(false)}
darkMode={darkMode}
onToggleTheme={() => setDarkMode(!darkMode)}
archivedSessions={conversations.filter(c => c.isArchived)}
onRestoreSession={(id) => toggleArchive(id)}
initialTab={settingsTab}
userProfile={userProfile}
onUpdateProfile={handleUpdateProfile}
isRestored={isInitialMountRef.current}
onTabChange={setSettingsTab}
/>
)}
{showPinLimitToast && (
<div className="premium-toast warning">
<div className="toast-icon">⚠️</div>
<div className="toast-content">
<strong>Giới hạn ghim</strong>
<p>Bạn chỉ có thể ghim tối đa 5 đoạn chat.</p>
</div>
</div>
)}
</>
)}
{/* Always render at fixed position to prevent DOM unmount/remount on isInitializing change */}
{viewerData && (
<ImageViewer
images={viewerData.images}
startIndex={viewerData.index}
onClose={() => setViewerData(null)}
onIndexChange={(newIndex) => {
setViewerData(prev => prev ? { ...prev, index: newIndex } : null)
}}
isRestored={isInitialMountRef.current || isInitializing}
/>
)}
</div>
)
}
export default App