import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient, } from '@tanstack/react-query' import api from '~/lib/api' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import StyledTable from '~/components/StyledTable' import SettingsLayout from '~/layouts/SettingsLayout' import { Head } from '@inertiajs/react' import { ListRemoteZimFilesResponse, RemoteZimFileEntry } from '../../../../types/zim' import { formatBytes } from '~/lib/util' import StyledButton from '~/components/StyledButton' import { useModals } from '~/context/ModalContext' import StyledModal from '~/components/StyledModal' import { useNotifications } from '~/context/NotificationContext' import useInternetStatus from '~/hooks/useInternetStatus' import Alert from '~/components/Alert' import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus' import Input from '~/components/inputs/Input' import { IconSearch } from '@tabler/icons-react' import useDebounce from '~/hooks/useDebounce' import CuratedCollectionCard from '~/components/CuratedCollectionCard' import CategoryCard from '~/components/CategoryCard' import TierSelectionModal from '~/components/TierSelectionModal' import StyledSectionHeader from '~/components/StyledSectionHeader' import { CuratedCollectionWithStatus, CuratedCategory, CategoryTier, CategoryResource, } from '../../../../types/downloads' import useDownloads from '~/hooks/useDownloads' import ActiveDownloads from '~/components/ActiveDownloads' const CURATED_COLLECTIONS_KEY = 'curated-zim-collections' const CURATED_CATEGORIES_KEY = 'curated-categories' // Helper to get all resources for a tier (including inherited resources) const getAllResourcesForTier = (tier: CategoryTier, allTiers: CategoryTier[]): CategoryResource[] => { const resources = [...tier.resources] if (tier.includesTier) { const includedTier = allTiers.find((t) => t.slug === tier.includesTier) if (includedTier) { resources.unshift(...getAllResourcesForTier(includedTier, allTiers)) } } return resources } export default function ZimRemoteExplorer() { const queryClient = useQueryClient() const tableParentRef = useRef(null) const { openModal, closeAllModals } = useModals() const { addNotification } = useNotifications() const { isOnline } = useInternetStatus() const { isInstalled } = useServiceInstalledStatus('nomad_kiwix_serve') const { debounce } = useDebounce() const [query, setQuery] = useState('') const [queryUI, setQueryUI] = useState('') // Category/tier selection state const [tierModalOpen, setTierModalOpen] = useState(false) const [activeCategory, setActiveCategory] = useState(null) const debouncedSetQuery = debounce((val: string) => { setQuery(val) }, 400) const { data: curatedCollections } = useQuery({ queryKey: [CURATED_COLLECTIONS_KEY], queryFn: () => api.listCuratedZimCollections(), refetchOnWindowFocus: false, }) // Fetch curated categories with tiers const { data: categories } = useQuery({ queryKey: [CURATED_CATEGORIES_KEY], queryFn: () => api.listCuratedCategories(), refetchOnWindowFocus: false, }) const { data: downloads, invalidate: invalidateDownloads } = useDownloads({ filetype: 'zim', enabled: true, }) const { data, fetchNextPage, isFetching, isLoading } = useInfiniteQuery({ queryKey: ['remote-zim-files', query], queryFn: async ({ pageParam = 0 }) => { const pageParsed = parseInt((pageParam as number).toString(), 10) const start = isNaN(pageParsed) ? 0 : pageParsed * 12 const res = await api.listRemoteZimFiles({ start, count: 12, query: query || undefined }) if (!res) { throw new Error('Failed to fetch remote ZIM files.') } return res.data }, initialPageParam: 0, getNextPageParam: (_lastPage, pages) => { if (!_lastPage.has_more) { return undefined // No more pages to fetch } return pages.length }, refetchOnWindowFocus: false, placeholderData: keepPreviousData, }) const flatData = useMemo(() => { const mapped = data?.pages.flatMap((page) => page.items) || [] // remove items that are currently downloading return mapped.filter((item) => { const isDownloading = downloads?.some((download) => { const filename = item.download_url.split('/').pop() return filename && download.filepath.endsWith(filename) }) return !isDownloading }) }, [data, downloads]) const hasMore = useMemo(() => data?.pages[data.pages.length - 1]?.has_more || false, [data]) const fetchOnBottomReached = useCallback( (parentRef?: HTMLDivElement | null) => { if (parentRef) { const { scrollHeight, scrollTop, clientHeight } = parentRef //once the user has scrolled within 200px of the bottom of the table, fetch more data if we can if ( scrollHeight - scrollTop - clientHeight < 200 && !isFetching && hasMore && flatData.length > 0 ) { fetchNextPage() } } }, [fetchNextPage, isFetching, hasMore, flatData.length] ) const virtualizer = useVirtualizer({ count: flatData.length, estimateSize: () => 48, // Estimate row height getScrollElement: () => tableParentRef.current, overscan: 5, // Number of items to render outside the visible area }) //a check on mount and after a fetch to see if the table is already scrolled to the bottom and immediately needs to fetch more data useEffect(() => { fetchOnBottomReached(tableParentRef.current) }, [fetchOnBottomReached]) async function confirmDownload(record: RemoteZimFileEntry | CuratedCollectionWithStatus) { const isCollection = 'resources' in record openModal( { if (isCollection) { if (record.all_downloaded) { addNotification({ message: `All resources in the collection "${record.name}" have already been downloaded.`, type: 'info', }) return } downloadCollection(record) } else { downloadFile(record) } closeAllModals() }} onCancel={closeAllModals} open={true} confirmText="Download" cancelText="Cancel" confirmVariant="primary" >

Are you sure you want to download{' '} {isCollection ? record.name : record.title}? It may take some time for it to be available depending on the file size and your internet connection. The Kiwix application will be restarted after the download is complete.

, 'confirm-download-file-modal' ) } async function downloadFile(record: RemoteZimFileEntry) { try { await api.downloadRemoteZimFile(record.download_url) invalidateDownloads() } catch (error) { console.error('Error downloading file:', error) } } async function downloadCollection(record: CuratedCollectionWithStatus) { try { await api.downloadZimCollection(record.slug) invalidateDownloads() } catch (error) { console.error('Error downloading collection:', error) } } // Category/tier handlers const handleCategoryClick = (category: CuratedCategory) => { if (!isOnline) return setActiveCategory(category) setTierModalOpen(true) } const handleTierSelect = async (category: CuratedCategory, tier: CategoryTier) => { // Get all resources for this tier (including inherited ones) const resources = getAllResourcesForTier(tier, category.tiers) // Download each resource try { for (const resource of resources) { await api.downloadRemoteZimFile(resource.url) } addNotification({ message: `Started downloading ${resources.length} files from "${category.name} - ${tier.name}"`, type: 'success', }) invalidateDownloads() } catch (error) { console.error('Error downloading tier resources:', error) addNotification({ message: 'An error occurred while starting downloads.', type: 'error', }) } closeTierModal() } const closeTierModal = () => { setTierModalOpen(false) setActiveCategory(null) } const fetchLatestCollections = useMutation({ mutationFn: () => api.fetchLatestZimCollections(), onSuccess: () => { addNotification({ message: 'Successfully fetched the latest ZIM collections.', type: 'success', }) queryClient.invalidateQueries({ queryKey: [CURATED_COLLECTIONS_KEY] }) }, }) // Auto-fetch latest collections if the list is empty useEffect(() => { if (curatedCollections && curatedCollections.length === 0 && !fetchLatestCollections.isPending) { fetchLatestCollections.mutate() } }, [curatedCollections, fetchLatestCollections]) return (

Content Explorer

Browse and download content for offline reading!

{!isOnline && ( )} {!isInstalled && ( )} fetchLatestCollections.mutate()} disabled={fetchLatestCollections.isPending} icon="CloudArrowDownIcon" > Fetch Latest Collections {/* Tiered Category Collections - matches Easy Setup Wizard */} {categories && categories.length > 0 ? ( <>
{categories.map((category) => ( ))}
{/* Tier Selection Modal */} ) : ( /* Legacy flat collections - fallback if no categories available */
{curatedCollections?.map((collection) => ( confirmDownload(collection)} size="large" /> ))} {curatedCollections && curatedCollections.length === 0 && (

No curated collections available.

)}
)}
{ setQueryUI(e.target.value) debouncedSetQuery(e.target.value) }} className="w-1/3" leftIcon={} />
data={flatData.map((i, idx) => { const row = virtualizer.getVirtualItems().find((v) => v.index === idx) return { ...i, height: `${row?.size || 48}px`, // Use the size from the virtualizer translateY: row?.start || 0, } })} ref={tableParentRef} loading={isLoading} columns={[ { accessor: 'title', }, { accessor: 'author', }, { accessor: 'summary', }, { accessor: 'updated', render(record) { return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', }).format(new Date(record.updated)) }, }, { accessor: 'size_bytes', title: 'Size', render(record) { return formatBytes(record.size_bytes) }, }, { accessor: 'actions', render(record) { return (
{ confirmDownload(record) }} > Download
) }, }, ]} className="relative overflow-x-auto overflow-y-auto h-[600px] w-full mt-4" tableBodyStyle={{ position: 'relative', height: `${virtualizer.getTotalSize()}px`, }} containerProps={{ onScroll: (e) => fetchOnBottomReached(e.currentTarget as HTMLDivElement), }} compact rowLines />
) }