import { Button } from '@/components/ui/button' import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url' import logger from '@/lib/logger' import { useNostr } from '@/providers/NostrProvider' import { TMailboxRelay, TMailboxRelayScope } from '@/types' import { useEffect, useState, useMemo, useRef, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { DndContext, closestCenter, KeyboardSensor, PointerSensor, TouchSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core' import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable' import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers' import MailboxRelay from '../MailboxSetting/MailboxRelay' import NewMailboxRelayInput from '../MailboxSetting/NewMailboxRelayInput' import RelayCountWarning from '../MailboxSetting/RelayCountWarning' import DiscoveredRelays from '../MailboxSetting/DiscoveredRelays' import { createCacheRelaysDraftEvent } from '@/lib/draft-event' import { getRelayListFromEvent } from '@/lib/event-metadata' import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' import { CloudUpload, Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Terminal, XCircle } from 'lucide-react' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import client from '@/services/client.service' import indexedDb, { StoreNames } from '@/services/indexed-db.service' import postEditorCache from '@/services/post-editor-cache.service' import { StorageKey } from '@/constants' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { toast } from 'sonner' import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { Event } from 'nostr-tools' export default function CacheRelaysSetting() { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() const { pubkey, cacheRelayListEvent, checkLogin, publish, updateCacheRelayListEvent, relayList, requestAccountNetworkHydrate } = useNostr() const [relays, setRelays] = useState([]) const [hasChange, setHasChange] = useState(false) const [pushing, setPushing] = useState(false) const justSavedRef = useRef(false) const [cacheInfo, setCacheInfo] = useState>({}) const [browsingCache, setBrowsingCache] = useState(false) const [selectedStore, setSelectedStore] = useState(null) const [storeItems, setStoreItems] = useState([]) const [loadingItems, setLoadingItems] = useState(false) const [wordWrapEnabled, setWordWrapEnabled] = useState(true) const [searchQuery, setSearchQuery] = useState('') const [consoleLogs, setConsoleLogs] = useState; timestamp: number }>>([]) const [showConsoleLogs, setShowConsoleLogs] = useState(false) const [consoleLogSearch, setConsoleLogSearch] = useState('') const [consoleLogLevel, setConsoleLogLevel] = useState<'errors-warnings' | 'all'>('all') const [cacheRefreshBusy, setCacheRefreshBusy] = useState(false) const consoleLogRef = useRef; timestamp: number }>>([]) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) ) function handleDragEnd(event: DragEndEvent) { const { active, over } = event if (active.id !== over?.id) { const oldIndex = relays.findIndex((relay) => relay.url === active.id) const newIndex = relays.findIndex((relay) => relay.url === over?.id) if (oldIndex !== -1 && newIndex !== -1) { setRelays((relays) => arrayMove(relays, oldIndex, newIndex)) setHasChange(true) } } } useEffect(() => { if (!cacheRelayListEvent) { setRelays([]) setHasChange(false) return } const cacheRelayList = getRelayListFromEvent(cacheRelayListEvent) const newRelays = cacheRelayList.originalRelays // Use functional update to compare with current state setRelays((currentRelays) => { // Check if relays are actually different (deep comparison) const areRelaysEqual = newRelays.length === currentRelays.length && newRelays.every((relay, index) => relay.url === currentRelays[index]?.url && relay.scope === currentRelays[index]?.scope ) // Only update and reset hasChange if relays actually changed AND we just saved // This prevents resetting hasChange when user is actively making changes if (!areRelaysEqual) { if (justSavedRef.current) { // We just saved, so this update is expected - reset hasChange justSavedRef.current = false setHasChange(false) } return newRelays } // If relays are equal, don't update state (prevents unnecessary re-render) return currentRelays }) }, [cacheRelayListEvent]) if (!pubkey) { return (
) } if (cacheRelayListEvent === undefined) { return
{t('loading...')}
} const changeCacheRelayScope = (url: string, scope: TMailboxRelayScope) => { setRelays((prev) => prev.map((r) => (r.url === url ? { ...r, scope } : r))) setHasChange(true) } const removeCacheRelay = (url: string) => { setRelays((prev) => prev.filter((r) => r.url !== url)) setHasChange(true) } const saveNewCacheRelay = (url: string) => { if (url === '') return null const normalizedUrl = normalizeUrl(url) if (!normalizedUrl) { return t('Invalid relay URL') } // Cache relays must be local network URLs only if (!isLocalNetworkUrl(normalizedUrl)) { return t('Cache relays must be local network URLs only (e.g., ws://localhost:4869 or ws://127.0.0.1:4869)') } if (relays.some((r) => r.url === normalizedUrl)) { return t('Relay already exists') } setRelays([...relays, { url: normalizedUrl, scope: 'both' }]) setHasChange(true) return null } const handleAddDiscoveredRelays = (newRelays: TMailboxRelay[]) => { // Filter to only local network URLs for cache relays const localRelays = newRelays.filter(newRelay => isLocalNetworkUrl(newRelay.url)) const relaysToAdd = localRelays.filter( newRelay => !relays.some(r => r.url === newRelay.url) ) if (relaysToAdd.length > 0) { setRelays([...relays, ...relaysToAdd]) setHasChange(true) } } useEffect(() => { // Load cache info on mount loadCacheInfo() }, []) const loadCacheInfo = async () => { try { const info = await indexedDb.getStoreInfo() setCacheInfo(info) } catch (error) { const message = error instanceof Error ? error.message : String(error) logger.error('Failed to load cache info', { error: message }) } } const handleClearCache = async () => { if (!confirm(t('Are you sure you want to clear all cached data? This will delete all stored events and settings from your browser.'))) { return } try { // Clear IndexedDB (all stores, including Piper read-aloud blobs) await indexedDb.clearAllCache() await indexedDb.clearPiperTtsCache() // Clear localStorage (but keep essential settings like theme, accounts, etc.) // We'll only clear Jumble-specific cache keys, not all localStorage const cacheKeys = Object.values(StorageKey).filter(key => key.includes('CACHE') || key.includes('EVENT') || key.includes('FEED') || key.includes('NOTIFICATION') ) cacheKeys.forEach(key => { try { window.localStorage.removeItem(key) } catch (e) { logger.warn(`Failed to remove ${key} from localStorage`, e as Error) } }) // Clear only this app's service worker caches if ('caches' in window) { try { const cacheNames = await caches.keys() const currentOrigin = window.location.origin // App-specific cache names (from vite.config.ts) const appCacheNames = [ 'nostr-images', 'satellite-images', 'external-images' ] // Workbox precache caches (typically start with 'workbox-' or 'precache-') // and any cache that might be from this app const appCaches = cacheNames.filter(name => { // Check if it's one of our named caches if (appCacheNames.includes(name)) { return true } // Check if it's a workbox precache cache if (name.startsWith('workbox-') || name.startsWith('precache-')) { return true } // Check if it's a workbox runtime cache (might have our origin in the name) if (name.includes(currentOrigin.replace(/https?:\/\//, '').split('/')[0])) { return true } return false }) await Promise.all(appCaches.map(name => caches.delete(name).catch(error => { logger.warn(`Failed to delete cache: ${name}`, { error }) }))) } catch (error) { logger.warn('Failed to clear some service worker caches', { error }) } } // Clear post editor cache postEditorCache.clearAllPostCaches() // Clear in-memory caches so profile pics and reactions work after clear client.clearInMemoryCaches() // Reload cache info await loadCacheInfo() toast.success(t('Cache cleared successfully')) // Reload the app so it re-fetches profiles and relay lists from the network. // Without this, missing IndexedDB + stale in-memory state can break reactions and avatars. setTimeout(() => window.location.reload(), 1500) } catch (error) { logger.error('Failed to clear cache', { error }) toast.error(t('Failed to clear cache')) } } const handleRefreshCache = async () => { try { setCacheRefreshBusy(true) await indexedDb.forceDatabaseUpgrade() await loadCacheInfo() if (pubkey) { await requestAccountNetworkHydrate() await syncUserDeletionTombstones(pubkey, relayList) } toast.success(t('Cache refreshed successfully')) } catch (error) { logger.error('Failed to refresh cache', { error }) toast.error(t('Failed to refresh cache')) } finally { setCacheRefreshBusy(false) } } const handleBrowseCache = () => { setBrowsingCache(true) setSelectedStore(null) setStoreItems([]) setSearchQuery('') loadCacheInfo() } const handleClearServiceWorker = async () => { if (!confirm(t('Are you sure you want to unregister the service worker? This will clear this app\'s service worker caches and you will need to reload the page.'))) { return } try { const currentOrigin = window.location.origin let unregisteredCount = 0 let cacheClearedCount = 0 // Check for service worker support and secure context (SW API throws in insecure contexts) if (window.isSecureContext && 'serviceWorker' in navigator) { // Get all service worker registrations let registrations: readonly ServiceWorkerRegistration[] = [] try { registrations = await navigator.serviceWorker.getRegistrations() } catch (error) { logger.warn('Failed to get service worker registrations', { error }) } // Only unregister service workers for this origin/app if (registrations.length > 0) { const unregisterPromises = registrations.map(async (registration) => { try { // Check if this service worker is for this origin const scope = registration.scope if (scope.startsWith(currentOrigin)) { const result = await registration.unregister() if (result) { unregisteredCount++ } return result } return false } catch (error) { logger.warn('Failed to unregister a service worker', { error }) return false } }) await Promise.all(unregisterPromises) } } // Clear only this app's caches if ('caches' in window) { try { const cacheNames = await caches.keys() // App-specific cache names (from vite.config.ts) const appCacheNames = [ 'nostr-images', 'satellite-images', 'external-images' ] // Workbox precache caches (typically start with 'workbox-' or 'precache-') // and any cache that might be from this app const appCaches = cacheNames.filter(name => { // Check if it's one of our named caches if (appCacheNames.includes(name)) { return true } // Check if it's a workbox precache cache if (name.startsWith('workbox-') || name.startsWith('precache-')) { return true } // Check if it's a workbox runtime cache (might have our origin in the name) if (name.includes(currentOrigin.replace(/https?:\/\//, '').split('/')[0])) { return true } return false }) await Promise.all(appCaches.map(name => { cacheClearedCount++ return caches.delete(name).catch(error => { logger.warn(`Failed to delete cache: ${name}`, { error }) cacheClearedCount-- }) })) } catch (error) { logger.warn('Failed to clear some caches', { error }) } } if (unregisteredCount > 0 || cacheClearedCount > 0) { const message = unregisteredCount > 0 && cacheClearedCount > 0 ? t('Service worker unregistered and caches cleared. Please reload the page.') : unregisteredCount > 0 ? t('Service worker unregistered. Please reload the page.') : t('Service worker caches cleared. Please reload the page.') toast.success(message) // Reload after a short delay setTimeout(() => { window.location.reload() }, 1000) } else { toast.info(t('No service workers or caches found for this app')) } } catch (error) { logger.error('Failed to unregister service worker', { error }) toast.error(t('Failed to unregister service worker: ') + (error instanceof Error ? error.message : String(error))) } } // Capture console logs and logger output - start capturing immediately when component mounts // Note: The logger uses console.log/error/warn/info internally, so intercepting console methods // will automatically capture all logger output (debug, info, warn, error, perf, component, etc.) useEffect(() => { const originalLog = console.log const originalError = console.error const originalWarn = console.warn const originalInfo = console.info const captureLog = (type: string, ...args: any[]) => { // Handle console formatting with %c placeholders for CSS styling // Console.log supports %c for CSS styling: console.log('%cText', 'color: red') let message = '' let formattedParts: Array<{ text: string; style?: string }> = [] if (args.length > 0 && typeof args[0] === 'string' && args[0].includes('%c')) { // Handle %c formatting const formatString = args[0] const parts = formatString.split(/%c/g) formattedParts = [] for (let i = 0; i < parts.length; i++) { const text = parts[i] const style = i < args.length - 1 && typeof args[i + 1] === 'string' ? args[i + 1] : undefined formattedParts.push({ text, style }) } // Also include remaining args const remainingArgs = args.slice(parts.length) if (remainingArgs.length > 0) { const remainingText = remainingArgs.map(arg => { if (typeof arg === 'object') { try { return JSON.stringify(arg, null, 2) } catch { return String(arg) } } return String(arg) }).join(' ') if (formattedParts.length > 0) { formattedParts[formattedParts.length - 1].text += ' ' + remainingText } else { formattedParts.push({ text: remainingText }) } } // Create a plain text version for search/filtering message = formattedParts.map(p => p.text).join('') } else { // Normal formatting - convert all args to strings message = args.map(arg => { if (typeof arg === 'object') { try { return JSON.stringify(arg, null, 2) } catch { return String(arg) } } return String(arg) }).join(' ') formattedParts = [{ text: message }] } const logEntry = { type, message, formattedParts, timestamp: Date.now() } consoleLogRef.current.push(logEntry) // Keep only last 1000 logs if (consoleLogRef.current.length > 1000) { consoleLogRef.current = consoleLogRef.current.slice(-1000) } // Update state if dialog is open if (showConsoleLogs) { setConsoleLogs([...consoleLogRef.current]) } } // Intercept console methods - this will capture all logger output since logger uses console internally console.log = (...args: any[]) => { captureLog('log', ...args) originalLog.apply(console, args) } console.error = (...args: any[]) => { captureLog('error', ...args) originalError.apply(console, args) } console.warn = (...args: any[]) => { captureLog('warn', ...args) originalWarn.apply(console, args) } console.info = (...args: any[]) => { captureLog('info', ...args) originalInfo.apply(console, args) } return () => { console.log = originalLog console.error = originalError console.warn = originalWarn console.info = originalInfo } }, [showConsoleLogs]) const handleShowConsoleLogs = () => { setConsoleLogs([...consoleLogRef.current]) setShowConsoleLogs(true) // Reset filters when opening – default to 'all' so user sees every entry (errors + warnings + info) setConsoleLogSearch('') setConsoleLogLevel('all') } const handleClearConsoleLogs = () => { consoleLogRef.current = [] setConsoleLogs([]) toast.success(t('Console logs cleared')) } // Filter console logs based on search query and log level const filteredConsoleLogs = useMemo(() => { let filtered = [...consoleLogs] // Filter by log level: errors-warnings = error + warn only, all = everything if (consoleLogLevel === 'errors-warnings') { filtered = filtered.filter(log => log.type === 'error' || log.type === 'warn') } // Filter by search query if (consoleLogSearch.trim()) { const query = consoleLogSearch.toLowerCase().trim() filtered = filtered.filter(log => log.message.toLowerCase().includes(query) || log.type.toLowerCase().includes(query) ) } return filtered }, [consoleLogs, consoleLogSearch, consoleLogLevel]) const handleStoreClick = async (storeName: string) => { setSelectedStore(storeName) setSearchQuery('') setLoadingItems(true) try { // For publication stores, use special method that only shows masters const items = storeName === 'publicationEvents' ? await indexedDb.getPublicationStoreItems(storeName) : await indexedDb.getStoreItems(storeName) setStoreItems(items) } catch (error) { logger.error('Failed to load store items', { error }) toast.error(t('Failed to load store items')) setStoreItems([]) } finally { setLoadingItems(false) } } const filteredStoreItems = useMemo(() => { if (!searchQuery.trim()) { return storeItems } const query = searchQuery.toLowerCase().trim() return storeItems.filter(item => { // Search in key if (item.key?.toLowerCase().includes(query)) { return true } // Search in JSON content try { const jsonString = JSON.stringify(item.value) if (jsonString.toLowerCase().includes(query)) { return true } } catch (e) { // If JSON.stringify fails, skip } // Search in addedAt timestamp const dateString = new Date(item.addedAt).toLocaleString().toLowerCase() if (dateString.includes(query)) { return true } return false }) }, [storeItems, searchQuery]) const handleDeleteItem = async (key: string) => { if (!selectedStore) return try { // For publication stores, parse the key to get pubkey and d-tag if (selectedStore === 'publicationEvents') { // Key format is "pubkey" or "pubkey:d-tag" const parts = key.split(':') const pubkey = parts[0] const d = parts[1] || undefined const result = await indexedDb.deletePublicationAndNestedEvents(pubkey, d) toast.success(t('Deleted {{count}} event(s)', { count: result.deleted })) } else { await indexedDb.deleteStoreItem(selectedStore, key) toast.success(t('Item deleted successfully')) } // Reload items const items = selectedStore === 'publicationEvents' ? await indexedDb.getPublicationStoreItems(selectedStore) : await indexedDb.getStoreItems(selectedStore) setStoreItems(items) // Update cache info loadCacheInfo() } catch (error) { logger.error('Failed to delete item', { error }) toast.error(t('Failed to delete item')) } } const handleDeleteAllItems = async () => { if (!selectedStore) return if (!confirm(t('Are you sure you want to delete all items from this store?'))) { return } try { await indexedDb.clearStore(selectedStore) setStoreItems([]) // Update cache info loadCacheInfo() toast.success(t('All items deleted successfully')) } catch (error) { logger.error('Failed to delete all items', { error }) toast.error(t('Failed to delete all items')) } } const handleCleanupDuplicates = async () => { if (!selectedStore) return if (!confirm(t('Clean up duplicate replaceable events? This will keep only the newest version of each event.'))) { return } setLoadingItems(true) try { const result = await indexedDb.cleanupDuplicateReplaceableEvents(selectedStore) // Reload items const items = await indexedDb.getStoreItems(selectedStore) setStoreItems(items) // Reset search query to show all items setSearchQuery('') // Update cache info loadCacheInfo() // Reload items to get accurate count after cleanup const itemsAfterCleanup = await indexedDb.getStoreItems(selectedStore) const actualCount = itemsAfterCleanup.length // Show message with actual count if (actualCount !== result.kept) { toast.success(t('Cleaned up {{deleted}} duplicate entries, kept {{kept}} (total items after cleanup: {{total}})', { deleted: result.deleted, kept: result.kept, total: actualCount })) } else { toast.success(t('Cleaned up {{deleted}} duplicate entries, kept {{kept}}', { deleted: result.deleted, kept: result.kept })) } } catch (error) { logger.error('Failed to cleanup duplicates', { error }) if (error instanceof Error && error.message === 'Not a replaceable event store') { toast.error(t('This store does not contain replaceable events')) } else { toast.error(t('Failed to cleanup duplicates')) } } finally { setLoadingItems(false) } } // Check if an event is invalid const isInvalidEvent = useCallback((item: { key: string; value: any; addedAt: number }, storeName?: string | null): boolean => { if (!item) return true // RSS feed items are not Nostr events, so skip validation for that store // Handle both old format (with item property) and new format (with value property) if (storeName === 'rssFeedItems') { // Old format has item property, new format has value property - both are valid for RSS items if (item.value || (item as any).item) { return false } // If neither exists, it's invalid return true } if (storeName === StoreNames.PIPER_TTS_CACHE) { const v = item.value as { blob?: unknown; mimeType?: string } | null return !(v && typeof v.mimeType === 'string' && v.blob instanceof Blob) } // For other stores, check if value exists if (!item.value) return true const event = item.value as Event // Check for required Nostr event fields if (!event.pubkey || !event.kind || typeof event.created_at !== 'number') { return true } // Check for tags array (required for Nostr events) if (!event.tags || !Array.isArray(event.tags)) { return true } // Check for id and sig (these should be present in valid events) if (!event.id || !event.sig) { return true } return false }, []) // Get explanation for why an event is invalid const getInvalidEventExplanation = useCallback((item: { key: string; value: any; addedAt: number }): string => { if (!item || !item.value) { return t('Event has no value data') } const event = item.value as Event const missing: string[] = [] if (!event.pubkey) missing.push(t('pubkey')) if (!event.kind) missing.push(t('kind')) if (typeof event.created_at !== 'number') missing.push(t('created_at')) if (!event.tags || !Array.isArray(event.tags)) missing.push(t('tags')) if (!event.id) missing.push(t('id')) if (!event.sig) missing.push(t('sig')) if (missing.length > 0) { return t('Event is missing required fields: {{fields}}', { fields: missing.join(', ') }) } return t('Event appears to be invalid or corrupted') }, [t]) const save = async () => { if (!pubkey) return setPushing(true) try { const event = createCacheRelaysDraftEvent(relays) const result = await publish(event) // Set flag before updating so useEffect knows to reset hasChange justSavedRef.current = true await updateCacheRelayListEvent(result) // Show publishing feedback if ((result as any).relayStatuses) { showPublishingFeedback({ success: true, relayStatuses: (result as any).relayStatuses, successCount: (result as any).relayStatuses.filter((s: any) => s.success).length, totalCount: (result as any).relayStatuses.length }, { message: t('Cache relays saved'), duration: 6000 }) } else { showSimplePublishSuccess(t('Cache relays saved')) } } catch (error) { // Reset flag on error justSavedRef.current = false logger.error('Failed to save cache relays', { error }) // Show error feedback if (error instanceof Error && (error as any).relayStatuses) { showPublishingFeedback({ success: false, relayStatuses: (error as any).relayStatuses, successCount: (error as any).relayStatuses.filter((s: any) => s.success).length, totalCount: (error as any).relayStatuses.length }, { message: error.message || t('Failed to save cache relays'), duration: 6000 }) } else { showPublishingError(error instanceof Error ? error : new Error(t('Failed to save cache relays'))) } } finally { setPushing(false) } } return (
{/* Cache Relays Section */}

{t('Cache Relays')}

{t('Cache relays are used to store and retrieve events locally. These relays are merged with your inbox and outbox relays.')}
r.url)} strategy={verticalListSortingStrategy}>
{relays.map((relay) => ( ))}
{/* In-Browser Cache Section */}

{t('In-Browser Cache')}

{t('Clear cached data stored in your browser, including IndexedDB events, localStorage settings, and service worker caches.')}
{t('refreshCacheButtonExplainer', { defaultValue: 'Refresh Cache runs an IndexedDB upgrade check, re-fetches your relay lists and profile-related events from the network (same work as the automatic startup sync), syncs kind-5 deletions into tombstones and removes deleted items from the local cache, then refreshes the store counts below.' })}
{isSmallScreen ? (
{selectedStore ? (
{selectedStore}
) : ( t('Browse Cache') )}
{selectedStore ? t('View cached items in this store.') : t('View details about cached data in IndexedDB stores. Click on a store to view its items.')}
{!selectedStore ? ( // Store list view Object.keys(cacheInfo).length === 0 ? (
{t('No cached data found.')}
) : ( Object.entries(cacheInfo).map(([storeName, count]) => (
handleStoreClick(storeName)} >
{storeName}
{count} {t('items')}
)) ) ) : ( // Store items view loadingItems ? (
{Array.from({ length: 5 }).map((_, i) => ( ))}
) : ( <>
setSearchQuery(e.target.value)} className="pl-8" />
{storeItems.length === 0 ? (
{t('No items in this store.')}
) : (
{filteredStoreItems.length} {t('of')} {storeItems.length} {t('items')} {searchQuery.trim() && ` ${t('matching')} "${searchQuery}"`}
{filteredStoreItems.length === 0 ? (
{t('No items match your search.')}
) : ( filteredStoreItems.map((item, index) => { const nestedCount = (item as any).nestedCount const invalid = isInvalidEvent(item, selectedStore) const invalidExplanation = invalid ? getInvalidEventExplanation(item) : '' return (
{invalid && (
{t('Invalid Event')}
{invalidExplanation}
)}
{item.key} {typeof nestedCount === 'number' && nestedCount > 0 && ( ({nestedCount} {t('nested events')}) )}
{t('Added at')}: {new Date(item.addedAt).toLocaleString()}
                                  {JSON.stringify(item.value, null, 2)}
                                
) }) )}
)} ) )}
) : (
{selectedStore ? (
{selectedStore}
) : ( t('Browse Cache') )}
{selectedStore ? t('View cached items in this store.') : t('View details about cached data in IndexedDB stores. Click on a store to view its items.')}
{!selectedStore ? ( // Store list view Object.keys(cacheInfo).length === 0 ? (
{t('No cached data found.')}
) : ( Object.entries(cacheInfo).map(([storeName, count]) => (
handleStoreClick(storeName)} >
{storeName}
{count} {t('items')}
)) ) ) : ( // Store items view <> {loadingItems ? (
{Array.from({ length: 5 }).map((_, i) => ( ))}
) : ( <>
setSearchQuery(e.target.value)} className="pl-8" />
{storeItems.length === 0 ? (
{t('No items in this store.')}
) : (
{filteredStoreItems.length} {t('of')} {storeItems.length} {t('items')} {searchQuery.trim() && ` ${t('matching')} "${searchQuery}"`}
{filteredStoreItems.length === 0 ? (
{t('No items match your search.')}
) : ( filteredStoreItems.map((item, index) => { const nestedCount = (item as any).nestedCount const invalid = isInvalidEvent(item, selectedStore) const invalidExplanation = invalid ? getInvalidEventExplanation(item) : '' return (
{invalid && (
{t('Invalid Event')}
{invalidExplanation}
)}
{item.key} {typeof nestedCount === 'number' && nestedCount > 0 && ( ({nestedCount} {t('nested events')}) )}
{t('Added at')}: {new Date(item.addedAt).toLocaleString()}
                                    {JSON.stringify(item.value, null, 2)}
                                  
) }) )}
)} )} )}
)} {/* Console Logs Dialog */} {isSmallScreen ? (
{t('Console Logs')} {t('View recent console logs for debugging')} ({filteredConsoleLogs.length} / {consoleLogs.length} {t('entries')})
setConsoleLogSearch(e.target.value)} className="min-w-0 flex-1 basis-[min(100%,12rem)]" />
{filteredConsoleLogs.length === 0 ? (
{consoleLogs.length === 0 ? t('No console logs captured yet') : t('No logs match the current filters') }
) : ( filteredConsoleLogs.map((log, index) => (
{new Date(log.timestamp).toLocaleTimeString()} [{log.type}]
                          {log.formattedParts ? (
                            log.formattedParts.map((part, i) => {
                              if (part.style) {
                                // Parse CSS string like "color:#f1b912" or "color: #f1b912; font-weight: bold"
                                const styleObj: Record = {}
                                part.style.split(';').forEach(rule => {
                                  const trimmed = rule.trim()
                                  if (trimmed) {
                                    const colonIndex = trimmed.indexOf(':')
                                    if (colonIndex > 0) {
                                      const key = trimmed.substring(0, colonIndex).trim()
                                      const value = trimmed.substring(colonIndex + 1).trim()
                                      // Convert kebab-case to camelCase
                                      const camelKey = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
                                      styleObj[camelKey] = value
                                    }
                                  }
                                })
                                return (
                                  
                                    {part.text}
                                  
                                )
                              }
                              return {part.text}
                            })
                          ) : (
                            log.message
                          )}
                        
)) )}
) : (
{t('Console Logs')} {t('View recent console logs for debugging')} ({filteredConsoleLogs.length} / {consoleLogs.length} {t('entries')})
setConsoleLogSearch(e.target.value)} className="min-w-0 flex-1 basis-[min(100%,12rem)]" />
{filteredConsoleLogs.length === 0 ? (
{consoleLogs.length === 0 ? t('No console logs captured yet') : t('No logs match the current filters') }
) : ( filteredConsoleLogs.map((log, index) => (
{new Date(log.timestamp).toLocaleTimeString()} [{log.type}]
                          {log.formattedParts ? (
                            log.formattedParts.map((part, i) => {
                              if (part.style) {
                                // Parse CSS string like "color:#f1b912" or "color: #f1b912; font-weight: bold"
                                const styleObj: Record = {}
                                part.style.split(';').forEach(rule => {
                                  const trimmed = rule.trim()
                                  if (trimmed) {
                                    const colonIndex = trimmed.indexOf(':')
                                    if (colonIndex > 0) {
                                      const key = trimmed.substring(0, colonIndex).trim()
                                      const value = trimmed.substring(colonIndex + 1).trim()
                                      // Convert kebab-case to camelCase
                                      const camelKey = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
                                      styleObj[camelKey] = value
                                    }
                                  }
                                })
                                return (
                                  
                                    {part.text}
                                  
                                )
                              }
                              return {part.text}
                            })
                          ) : (
                            log.message
                          )}
                        
)) )}
)}
) }