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, IconBooks } from '@tabler/icons-react' import useDebounce from '~/hooks/useDebounce' import CategoryCard from '~/components/CategoryCard' import TierSelectionModal from '~/components/TierSelectionModal' import WikipediaSelector from '~/components/WikipediaSelector' import StyledSectionHeader from '~/components/StyledSectionHeader' import type { CategoryWithStatus, SpecTier } from '../../../../types/collections' import useDownloads from '~/hooks/useDownloads' import ActiveDownloads from '~/components/ActiveDownloads' import { SERVICE_NAMES } from '../../../../constants/service_names' const CURATED_CATEGORIES_KEY = 'curated-categories' const WIKIPEDIA_STATE_KEY = 'wikipedia-state' export default function ZimRemoteExplorer() { const queryClient = useQueryClient() const tableParentRef = useRef(null) const { openModal, closeAllModals } = useModals() const { addNotification } = useNotifications() const { isOnline } = useInternetStatus() const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.KIWIX) 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) // Wikipedia selection state const [selectedWikipedia, setSelectedWikipedia] = useState(null) const [isSubmittingWikipedia, setIsSubmittingWikipedia] = useState(false) const debouncedSetQuery = debounce((val: string) => { setQuery(val) }, 400) // Fetch curated categories with tiers const { data: categories } = useQuery({ queryKey: [CURATED_CATEGORIES_KEY], queryFn: () => api.listCuratedCategories(), refetchOnWindowFocus: false, }) // Fetch Wikipedia options and state const { data: wikipediaState, isLoading: isLoadingWikipedia } = useQuery({ queryKey: [WIKIPEDIA_STATE_KEY], queryFn: () => api.getWikipediaState(), 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) { openModal( { downloadFile(record) closeAllModals() }} onCancel={closeAllModals} open={true} confirmText="Download" cancelText="Cancel" confirmVariant="primary" >

Are you sure you want to download{' '} {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, { title: record.title, summary: record.summary, author: record.author, size_bytes: record.size_bytes, }) invalidateDownloads() } catch (error) { console.error('Error downloading file:', error) } } // Category/tier handlers const handleCategoryClick = (category: CategoryWithStatus) => { if (!isOnline) return setActiveCategory(category) setTierModalOpen(true) } const handleTierSelect = async (category: CategoryWithStatus, tier: SpecTier) => { try { await api.downloadCategoryTier(category.slug, tier.slug) addNotification({ message: `Started downloading "${category.name} - ${tier.name}"`, type: 'success', }) invalidateDownloads() // Refresh categories to update the installed tier display queryClient.invalidateQueries({ queryKey: [CURATED_CATEGORIES_KEY] }) } catch (error) { console.error('Error downloading tier resources:', error) addNotification({ message: 'An error occurred while starting downloads.', type: 'error', }) } } const closeTierModal = () => { setTierModalOpen(false) setActiveCategory(null) } // Wikipedia selection handlers const handleWikipediaSelect = (optionId: string) => { if (!isOnline) return setSelectedWikipedia(optionId) } const handleWikipediaSubmit = async () => { if (!selectedWikipedia) return setIsSubmittingWikipedia(true) try { const result = await api.selectWikipedia(selectedWikipedia) if (result?.success) { addNotification({ message: selectedWikipedia === 'none' ? 'Wikipedia removed successfully' : 'Wikipedia download started', type: 'success', }) invalidateDownloads() queryClient.invalidateQueries({ queryKey: [WIKIPEDIA_STATE_KEY] }) setSelectedWikipedia(null) } else { addNotification({ message: result?.message || 'Failed to change Wikipedia selection', type: 'error', }) } } catch (error) { console.error('Error selecting Wikipedia:', error) addNotification({ message: 'An error occurred while changing Wikipedia selection', type: 'error', }) } finally { setIsSubmittingWikipedia(false) } } const refreshManifests = useMutation({ mutationFn: () => api.refreshManifests(), onSuccess: () => { addNotification({ message: 'Successfully refreshed content collections.', type: 'success', }) queryClient.invalidateQueries({ queryKey: [CURATED_CATEGORIES_KEY] }) }, }) return (

Content Explorer

Browse and download content for offline reading!

{!isOnline && ( )} {!isInstalled && ( )} refreshManifests.mutate()} disabled={refreshManifests.isPending} icon="IconCloudDownload" > Refresh Collections {/* Wikipedia Selector */} {isLoadingWikipedia ? (
) : wikipediaState && wikipediaState.options.length > 0 ? (
) : null} {/* Tiered Category Collections */}

Additional Content

Curated collections for offline reference

{categories && categories.length > 0 ? ( <>
{categories.map((category) => ( ))}
{/* Tier Selection Modal */} ) : (

No curated content categories 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 />
) }