8 changed files with 810 additions and 354 deletions
@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
|
||||
{ |
||||
"compilerOptions": { |
||||
"baseUrl": ".", |
||||
"paths": { |
||||
"@/*": ["./src/*"] |
||||
}, |
||||
"jsx": "react-jsx", |
||||
"module": "ESNext", |
||||
"moduleResolution": "bundler", |
||||
"target": "ES2020", |
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"], |
||||
"strict": true, |
||||
"noEmit": true |
||||
}, |
||||
"include": ["src"] |
||||
} |
||||
@ -0,0 +1,577 @@
@@ -0,0 +1,577 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import logger from '@/lib/logger' |
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Copy } from 'lucide-react' |
||||
import { Input } from '@/components/ui/input' |
||||
import { Skeleton } from '@/components/ui/skeleton' |
||||
import indexedDb, { StoreNames, type TCachedEventSearchHit } from '@/services/indexed-db.service' |
||||
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 { Event } from 'nostr-tools' |
||||
import { cn } from '@/lib/utils' |
||||
|
||||
const GLOBAL_CACHED_EVENTS_SEARCH_LIMIT = 400 |
||||
|
||||
export default function CacheBrowserDialog({ |
||||
open, |
||||
onOpenChange |
||||
}: { |
||||
open: boolean |
||||
onOpenChange: (open: boolean) => void |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { isSmallScreen } = useScreenSize() |
||||
const [cacheInfo, setCacheInfo] = useState<Record<string, number>>({}) |
||||
const [selectedStore, setSelectedStore] = useState<string | null>(null) |
||||
const [storeItems, setStoreItems] = useState<any[]>([]) |
||||
const [loadingItems, setLoadingItems] = useState(false) |
||||
const [wordWrapEnabled, setWordWrapEnabled] = useState(true) |
||||
const [searchQuery, setSearchQuery] = useState('') |
||||
const [cachedEventsSearch, setCachedEventsSearch] = useState('') |
||||
const [globalSearchHits, setGlobalSearchHits] = useState<TCachedEventSearchHit[]>([]) |
||||
const [globalSearchLoading, setGlobalSearchLoading] = useState(false) |
||||
const [globalSearchTruncated, setGlobalSearchTruncated] = useState(false) |
||||
const [publicationListFull, setPublicationListFull] = useState(false) |
||||
const globalSearchRequestId = useRef(0) |
||||
|
||||
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 }) |
||||
} |
||||
} |
||||
|
||||
useEffect(() => { |
||||
if (!open) return |
||||
setSelectedStore(null) |
||||
setStoreItems([]) |
||||
setSearchQuery('') |
||||
setCachedEventsSearch('') |
||||
setGlobalSearchHits([]) |
||||
setGlobalSearchTruncated(false) |
||||
setPublicationListFull(false) |
||||
void loadCacheInfo() |
||||
}, [open]) |
||||
|
||||
useEffect(() => { |
||||
if (!open || selectedStore) return |
||||
const q = cachedEventsSearch.trim() |
||||
if (!q) { |
||||
setGlobalSearchHits([]) |
||||
setGlobalSearchLoading(false) |
||||
setGlobalSearchTruncated(false) |
||||
return |
||||
} |
||||
const reqId = ++globalSearchRequestId.current |
||||
setGlobalSearchLoading(true) |
||||
const timer = window.setTimeout(() => { |
||||
void indexedDb |
||||
.searchAllCachedEventsFullText(q, { limit: GLOBAL_CACHED_EVENTS_SEARCH_LIMIT }) |
||||
.then((hits) => { |
||||
if (globalSearchRequestId.current !== reqId) return |
||||
setGlobalSearchHits(hits) |
||||
setGlobalSearchTruncated(hits.length >= GLOBAL_CACHED_EVENTS_SEARCH_LIMIT) |
||||
setGlobalSearchLoading(false) |
||||
}) |
||||
.catch((e) => { |
||||
logger.error('Cached events search failed', { e }) |
||||
if (globalSearchRequestId.current !== reqId) return |
||||
setGlobalSearchHits([]) |
||||
setGlobalSearchTruncated(false) |
||||
setGlobalSearchLoading(false) |
||||
}) |
||||
}, 350) |
||||
return () => window.clearTimeout(timer) |
||||
}, [cachedEventsSearch, open, selectedStore]) |
||||
|
||||
const handleStoreClick = async (storeName: string) => { |
||||
setSelectedStore(storeName) |
||||
setSearchQuery('') |
||||
setPublicationListFull(false) |
||||
setLoadingItems(true) |
||||
try { |
||||
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 handleOpenSearchHit = async (hit: TCachedEventSearchHit) => { |
||||
setSelectedStore(hit.storeName) |
||||
setSearchQuery(hit.value.id) |
||||
setPublicationListFull(hit.storeName === 'publicationEvents') |
||||
setLoadingItems(true) |
||||
try { |
||||
const items = await indexedDb.getStoreItems(hit.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) => { |
||||
if (item.key?.toLowerCase().includes(query)) return true |
||||
try { |
||||
if (JSON.stringify(item.value).toLowerCase().includes(query)) return true |
||||
} catch { |
||||
/* skip */ |
||||
} |
||||
if (new Date(item.addedAt).toLocaleString().toLowerCase().includes(query)) return true |
||||
return false |
||||
}) |
||||
}, [storeItems, searchQuery]) |
||||
|
||||
const handleDeleteItem = async (key: string) => { |
||||
if (!selectedStore) return |
||||
try { |
||||
if (selectedStore === 'publicationEvents') { |
||||
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')) |
||||
} |
||||
const items = |
||||
selectedStore === 'publicationEvents' && !publicationListFull |
||||
? await indexedDb.getPublicationStoreItems(selectedStore) |
||||
: await indexedDb.getStoreItems(selectedStore) |
||||
setStoreItems(items) |
||||
void 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([]) |
||||
void 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) |
||||
const items = await indexedDb.getStoreItems(selectedStore) |
||||
setStoreItems(items) |
||||
setSearchQuery('') |
||||
void loadCacheInfo() |
||||
const itemsAfterCleanup = await indexedDb.getStoreItems(selectedStore) |
||||
const actualCount = itemsAfterCleanup.length |
||||
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) |
||||
} |
||||
} |
||||
|
||||
const handleCopyItemJson = (item: { value: unknown }) => { |
||||
try { |
||||
const text = JSON.stringify(item.value, null, 2) |
||||
void navigator.clipboard.writeText(text) |
||||
toast.success(t('Copied to clipboard')) |
||||
} catch { |
||||
toast.error(t('Failed to copy')) |
||||
} |
||||
} |
||||
|
||||
const isInvalidEvent = useCallback( |
||||
(item: { key: string; value: any; addedAt: number }, storeName?: string | null): boolean => { |
||||
if (!item) return true |
||||
if (storeName === 'rssFeedItems') { |
||||
return !(item.value || (item as any).item) |
||||
} |
||||
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) |
||||
} |
||||
if (!item.value) return true |
||||
const event = item.value as Event |
||||
if (!event.pubkey || !event.kind || typeof event.created_at !== 'number') return true |
||||
if (!event.tags || !Array.isArray(event.tags)) return true |
||||
if (!event.id || !event.sig) return true |
||||
return false |
||||
}, |
||||
[] |
||||
) |
||||
|
||||
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 renderStoreListView = () => { |
||||
if (Object.keys(cacheInfo).length === 0) { |
||||
return <div className="text-sm text-muted-foreground">{t('No cached data found.')}</div> |
||||
} |
||||
const q = cachedEventsSearch.trim() |
||||
if (q) { |
||||
if (globalSearchLoading) { |
||||
return ( |
||||
<div className="space-y-2 py-2" role="status" aria-busy="true" aria-label={t('Searching…')}> |
||||
{Array.from({ length: 6 }).map((_, i) => ( |
||||
<Skeleton key={i} className="h-16 w-full rounded-md" /> |
||||
))} |
||||
</div> |
||||
) |
||||
} |
||||
if (globalSearchHits.length === 0) { |
||||
return ( |
||||
<div className="text-sm text-muted-foreground">{t('No cached events match your search.')}</div> |
||||
) |
||||
} |
||||
return ( |
||||
<div className="space-y-2"> |
||||
{globalSearchTruncated && ( |
||||
<p className="text-xs text-muted-foreground"> |
||||
{t('Showing first {{count}} cached event matches.', { count: GLOBAL_CACHED_EVENTS_SEARCH_LIMIT })} |
||||
</p> |
||||
)} |
||||
{globalSearchHits.map((hit) => ( |
||||
<div |
||||
key={`${hit.storeName}:${hit.key}:${hit.value.id}`} |
||||
className="space-y-2 rounded-lg border p-3 transition-colors hover:bg-muted/50" |
||||
> |
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> |
||||
<span className="break-all font-medium text-foreground">{hit.storeName}</span> |
||||
<span aria-hidden>·</span> |
||||
<span>{t('Event kind label', { kind: hit.value.kind })}</span> |
||||
</div> |
||||
<div className="line-clamp-2 break-words text-xs">{hit.value.content || '\u00a0'}</div> |
||||
<div className="flex flex-wrap gap-2"> |
||||
<Button |
||||
variant="outline" |
||||
size="sm" |
||||
className="h-7 text-xs" |
||||
type="button" |
||||
onClick={() => void handleOpenSearchHit(hit)} |
||||
> |
||||
{t('Open in store')} |
||||
</Button> |
||||
<Button |
||||
variant="ghost" |
||||
size="sm" |
||||
className="h-7 text-xs" |
||||
type="button" |
||||
onClick={() => handleCopyItemJson({ value: hit.value })} |
||||
title={t('Copy event JSON')} |
||||
> |
||||
<Copy className="mr-1 h-3 w-3" /> |
||||
{t('Copy event JSON')} |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
))} |
||||
</div> |
||||
) |
||||
} |
||||
return Object.entries(cacheInfo) |
||||
.sort(([a], [b]) => a.localeCompare(b)) |
||||
.map(([storeName, count]) => ( |
||||
<div |
||||
key={storeName} |
||||
className="cursor-pointer rounded-lg border p-3 transition-colors hover:bg-muted/50" |
||||
onClick={() => handleStoreClick(storeName)} |
||||
> |
||||
<div className="break-words text-sm font-semibold">{storeName}</div> |
||||
<div className="mt-1 text-xs text-muted-foreground"> |
||||
{count} {t('items')} |
||||
</div> |
||||
</div> |
||||
)) |
||||
} |
||||
|
||||
const storeItemsSearch = |
||||
selectedStore && !loadingItems ? ( |
||||
<div className="relative min-w-0 max-w-full shrink-0 px-1.5 py-1"> |
||||
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> |
||||
<Input |
||||
type="text" |
||||
placeholder={t('Search items...')} |
||||
value={searchQuery} |
||||
onChange={(e) => setSearchQuery(e.target.value)} |
||||
className="w-full min-w-0 pl-8" |
||||
/> |
||||
</div> |
||||
) : null |
||||
|
||||
const renderStoreItemsInner = () => |
||||
loadingItems ? ( |
||||
<div className="space-y-2 py-6" role="status" aria-busy="true"> |
||||
{Array.from({ length: 5 }).map((_, i) => ( |
||||
<Skeleton key={i} className="h-10 w-full rounded-md" /> |
||||
))} |
||||
</div> |
||||
) : ( |
||||
<> |
||||
{storeItems.length === 0 ? ( |
||||
<div className="text-sm text-muted-foreground">{t('No items in this store.')}</div> |
||||
) : ( |
||||
<div className="min-w-0 max-w-full space-y-2"> |
||||
<div className="mb-2 flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
||||
<div className="min-w-0 text-xs text-muted-foreground"> |
||||
{filteredStoreItems.length} {t('of')} {storeItems.length} {t('items')} |
||||
{searchQuery.trim() && ` ${t('matching')} "${searchQuery}"`} |
||||
</div> |
||||
<div className="flex min-w-0 flex-shrink-0 flex-wrap gap-2 sm:justify-end"> |
||||
<Button variant="outline" size="sm" onClick={handleCleanupDuplicates} className="h-7 text-xs"> |
||||
<RefreshCw className="h-3 w-3 mr-1" /> |
||||
{t('Cleanup Duplicates')} |
||||
</Button> |
||||
<Button variant="destructive" size="sm" onClick={handleDeleteAllItems} className="h-7 text-xs"> |
||||
<Trash2 className="h-3 w-3 mr-1" /> |
||||
{t('Delete All')} |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
{filteredStoreItems.length === 0 ? ( |
||||
<div className="text-sm text-muted-foreground">{t('No items match your search.')}</div> |
||||
) : ( |
||||
filteredStoreItems.map((item, index) => { |
||||
const nestedCount = (item as any).nestedCount |
||||
const invalid = isInvalidEvent(item, selectedStore) |
||||
const invalidExplanation = invalid ? getInvalidEventExplanation(item) : '' |
||||
return ( |
||||
<div |
||||
key={item.key || index} |
||||
className="relative min-w-0 max-w-full break-words rounded-lg border p-3" |
||||
> |
||||
<div className="absolute right-2 top-2 z-10 flex items-center gap-0.5 rounded-md bg-background/90 pl-1 backdrop-blur-sm"> |
||||
{invalid && ( |
||||
<HoverCard> |
||||
<HoverCardTrigger asChild> |
||||
<Button |
||||
variant="ghost" |
||||
size="sm" |
||||
className="h-6 w-6 p-0 text-amber-600 dark:text-amber-500 hover:text-amber-700 dark:hover:text-amber-400" |
||||
title={invalidExplanation} |
||||
> |
||||
<TriangleAlert className="h-3 w-3" /> |
||||
</Button> |
||||
</HoverCardTrigger> |
||||
<HoverCardContent className="w-80"> |
||||
<div className="space-y-2"> |
||||
<div className="font-semibold text-sm flex items-center gap-2"> |
||||
<TriangleAlert className="h-4 w-4 text-amber-600 dark:text-amber-500" /> |
||||
{t('Invalid Event')} |
||||
</div> |
||||
<div className="text-sm text-muted-foreground">{invalidExplanation}</div> |
||||
</div> |
||||
</HoverCardContent> |
||||
</HoverCard> |
||||
)} |
||||
<Button |
||||
variant="ghost" |
||||
size="sm" |
||||
type="button" |
||||
onClick={() => handleCopyItemJson(item)} |
||||
className="h-6 w-6 p-0" |
||||
title={t('Copy event JSON')} |
||||
> |
||||
<Copy className="h-3 w-3" /> |
||||
</Button> |
||||
<Button |
||||
variant="ghost" |
||||
size="sm" |
||||
onClick={() => handleDeleteItem(item.key)} |
||||
className="h-6 w-6 p-0" |
||||
title={t('Delete item')} |
||||
> |
||||
<X className="h-3 w-3" /> |
||||
</Button> |
||||
</div> |
||||
<div className={`font-semibold text-xs mb-2 break-all ${invalid ? 'pr-24' : 'pr-20'}`}> |
||||
{item.key} |
||||
{typeof nestedCount === 'number' && nestedCount > 0 && ( |
||||
<span className="ml-2 text-muted-foreground"> |
||||
({nestedCount} {t('nested events')}) |
||||
</span> |
||||
)} |
||||
</div> |
||||
<div className="text-xs text-muted-foreground mb-2"> |
||||
{t('Added at')}: {new Date(item.addedAt).toLocaleString()} |
||||
</div> |
||||
<pre |
||||
className={cn( |
||||
'max-h-96 min-w-0 max-w-full select-text overflow-auto rounded bg-muted p-2 text-xs', |
||||
wordWrapEnabled |
||||
? 'break-words whitespace-pre-wrap [overflow-wrap:anywhere]' |
||||
: 'overflow-x-auto whitespace-pre' |
||||
)} |
||||
> |
||||
{JSON.stringify(item.value, null, 2)} |
||||
</pre> |
||||
</div> |
||||
) |
||||
}) |
||||
)} |
||||
</div> |
||||
)} |
||||
</> |
||||
) |
||||
|
||||
const browseCacheHeader = ( |
||||
<div className="flex min-w-0 items-center justify-between gap-2"> |
||||
<div className="flex min-w-0 flex-1 items-center gap-2"> |
||||
{selectedStore ? ( |
||||
<> |
||||
<Button |
||||
variant="ghost" |
||||
size="sm" |
||||
onClick={() => { |
||||
setSelectedStore(null) |
||||
setStoreItems([]) |
||||
}} |
||||
> |
||||
← {t('Back')} |
||||
</Button> |
||||
<span className="truncate text-sm font-medium">{selectedStore}</span> |
||||
</> |
||||
) : ( |
||||
<span className="flex items-center gap-2 text-sm font-medium"> |
||||
<Database className="h-4 w-4 shrink-0" /> |
||||
{t('Browse Cache')} |
||||
</span> |
||||
)} |
||||
</div> |
||||
<Button |
||||
variant="ghost" |
||||
size="sm" |
||||
onClick={() => setWordWrapEnabled(!wordWrapEnabled)} |
||||
title={wordWrapEnabled ? t('Disable word wrap') : t('Enable word wrap')} |
||||
> |
||||
<WrapText className={`h-4 w-4 ${wordWrapEnabled ? '' : 'opacity-50'}`} /> |
||||
</Button> |
||||
</div> |
||||
) |
||||
|
||||
const browseCacheDescription = selectedStore |
||||
? t('View cached items in this store.') |
||||
: t('Browse cache root description') |
||||
|
||||
/** Keep outside overflow-x-hidden ancestors so focus rings are not clipped (Firefox). */ |
||||
const storeListSearch = !selectedStore && Object.keys(cacheInfo).length > 0 && ( |
||||
<div className="relative min-w-0 max-w-full px-1.5 py-1"> |
||||
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> |
||||
<Input |
||||
type="search" |
||||
placeholder={t('Search cached events...')} |
||||
value={cachedEventsSearch} |
||||
onChange={(e) => setCachedEventsSearch(e.target.value)} |
||||
className="w-full min-w-0 pl-8" |
||||
autoComplete="off" |
||||
/> |
||||
</div> |
||||
) |
||||
|
||||
/** Scrollable region only — search fields are siblings above this so focus rings are not clipped. */ |
||||
const scrollableListBody = ( |
||||
<div |
||||
className={cn( |
||||
'min-w-0 max-w-full space-y-4 pr-3 sm:pr-5', |
||||
wordWrapEnabled && 'break-words' |
||||
)} |
||||
> |
||||
{!selectedStore ? renderStoreListView() : renderStoreItemsInner()} |
||||
</div> |
||||
) |
||||
|
||||
return isSmallScreen ? ( |
||||
<Drawer open={open} onOpenChange={onOpenChange}> |
||||
<DrawerContent className="flex max-h-[90vh] flex-col"> |
||||
<DrawerHeader className="min-w-0 shrink-0"> |
||||
{browseCacheHeader} |
||||
<DrawerTitle className="sr-only">{t('Browse Cache')}</DrawerTitle> |
||||
<DrawerDescription>{browseCacheDescription}</DrawerDescription> |
||||
</DrawerHeader> |
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col space-y-4 px-4 pb-4"> |
||||
{storeListSearch} |
||||
{storeItemsSearch} |
||||
<div className="max-h-[min(70vh,32rem)] min-h-0 min-w-0 w-full flex-1 overflow-x-hidden overflow-y-auto"> |
||||
{scrollableListBody} |
||||
</div> |
||||
</div> |
||||
</DrawerContent> |
||||
</Drawer> |
||||
) : ( |
||||
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
<DialogContent className="flex max-h-[min(1000px,90vh)] w-[calc(100vw-1.5rem)] max-w-[min(1000px,calc(100vw-1.5rem))] min-w-0 flex-col gap-4 overflow-x-visible overflow-y-hidden sm:w-full sm:max-w-[1000px]"> |
||||
<DialogHeader className="min-w-0 shrink-0"> |
||||
{browseCacheHeader} |
||||
<DialogTitle className="sr-only">{t('Browse Cache')}</DialogTitle> |
||||
<DialogDescription>{browseCacheDescription}</DialogDescription> |
||||
</DialogHeader> |
||||
{storeListSearch} |
||||
{storeItemsSearch} |
||||
<div className="min-h-0 min-w-0 w-full flex-1 overflow-x-hidden overflow-y-auto"> |
||||
{scrollableListBody} |
||||
</div> |
||||
</DialogContent> |
||||
</Dialog> |
||||
) |
||||
} |
||||
@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
|
||||
import CacheBrowserDialog from '../components/CacheBrowser/CacheBrowserDialog' |
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react' |
||||
|
||||
type CacheBrowserContextValue = { |
||||
openBrowseCache: () => void |
||||
} |
||||
|
||||
const CacheBrowserContext = createContext<CacheBrowserContextValue | undefined>(undefined) |
||||
|
||||
export function CacheBrowserProvider({ children }: { children: ReactNode }) { |
||||
const [open, setOpen] = useState(false) |
||||
const openBrowseCache = useCallback(() => setOpen(true), []) |
||||
const value = useMemo(() => ({ openBrowseCache }), [openBrowseCache]) |
||||
|
||||
return ( |
||||
<CacheBrowserContext.Provider value={value}> |
||||
{children} |
||||
<CacheBrowserDialog open={open} onOpenChange={setOpen} /> |
||||
</CacheBrowserContext.Provider> |
||||
) |
||||
} |
||||
|
||||
export function useCacheBrowser(): CacheBrowserContextValue { |
||||
const ctx = useContext(CacheBrowserContext) |
||||
if (!ctx) { |
||||
throw new Error('useCacheBrowser must be used within CacheBrowserProvider') |
||||
} |
||||
return ctx |
||||
} |
||||
Loading…
Reference in new issue