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, IconFolder, IconFileDownload, IconChevronRight, IconPlus, IconTrash, IconLibrary, } 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' const CUSTOM_LIBRARIES_KEY = 'custom-libraries' type CustomLibrary = { id: number; name: string; base_url: string; is_default: boolean } type BrowseResult = { directories: { name: string; url: string }[] files: { name: string; url: string; size_bytes: number | null }[] } 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) // Custom library state - persist selection to localStorage const [selectedSource, setSelectedSource] = useState<'default' | number>(() => { try { const saved = localStorage.getItem('nomad:zim-library-source') if (saved && saved !== 'default') return parseInt(saved, 10) } catch {} return 'default' }) const [browseUrl, setBrowseUrl] = useState(null) const [breadcrumbs, setBreadcrumbs] = useState<{ name: string; url: string }[]>([]) const [manageModalOpen, setManageModalOpen] = useState(false) const [newLibraryName, setNewLibraryName] = useState('') const [newLibraryUrl, setNewLibraryUrl] = useState('') 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, }) // Fetch custom libraries const { data: customLibraries } = useQuery({ queryKey: [CUSTOM_LIBRARIES_KEY], queryFn: () => api.listCustomLibraries(), refetchOnWindowFocus: false, }) // Browse custom library directory const { data: browseData, isLoading: isBrowsing, error: browseError, } = useQuery({ queryKey: ['browse-library', browseUrl], queryFn: () => api.browseLibrary(browseUrl!) as Promise, enabled: !!browseUrl && selectedSource !== 'default', refetchOnWindowFocus: false, retry: false, }) 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, enabled: selectedSource === 'default', }) 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]) // Restore custom library selection on mount when data loads useEffect(() => { if (selectedSource !== 'default' && customLibraries) { const lib = customLibraries.find((l) => l.id === selectedSource) if (lib && !browseUrl) { setBrowseUrl(lib.base_url) setBreadcrumbs([{ name: lib.name, url: lib.base_url }]) } else if (!lib) { // Saved library was deleted setSelectedSource('default') localStorage.setItem('nomad:zim-library-source', 'default') } } }, [customLibraries, selectedSource]) // When selecting a custom library, navigate to its root const handleSourceChange = (value: string) => { localStorage.setItem('nomad:zim-library-source', value) if (value === 'default') { setSelectedSource('default') setBrowseUrl(null) setBreadcrumbs([]) } else { const id = parseInt(value, 10) const lib = customLibraries?.find((l) => l.id === id) if (lib) { setSelectedSource(id) setBrowseUrl(lib.base_url) setBreadcrumbs([{ name: lib.name, url: lib.base_url }]) } } } const navigateToDirectory = (name: string, url: string) => { setBrowseUrl(url) setBreadcrumbs((prev) => [...prev, { name, url }]) } const navigateToBreadcrumb = (index: number) => { const crumb = breadcrumbs[index] setBrowseUrl(crumb.url) setBreadcrumbs((prev) => prev.slice(0, index + 1)) } 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 confirmCustomDownload(file: { name: string; url: string; size_bytes: number | null }) { openModal( { downloadCustomFile(file) closeAllModals() }} onCancel={closeAllModals} open={true} confirmText="Download" cancelText="Cancel" confirmVariant="primary" >

Are you sure you want to download{' '} {file.name} {file.size_bytes ? ` (${formatBytes(file.size_bytes)})` : ''}? The Kiwix application will be restarted after the download is complete.

, 'confirm-download-custom-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) } } async function downloadCustomFile(file: { name: string; url: string; size_bytes: number | null }) { try { await api.downloadRemoteZimFile(file.url, { title: file.name.replace(/\.zim$/, ''), size_bytes: file.size_bytes ?? undefined, }) addNotification({ message: `Started downloading "${file.name}"`, type: 'success', }) invalidateDownloads() } catch (error) { console.error('Error downloading file:', error) addNotification({ message: 'Failed to start download.', type: '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] }) queryClient.invalidateQueries({ queryKey: [WIKIPEDIA_STATE_KEY] }) }, }) // Custom library management const addLibraryMutation = useMutation({ mutationFn: () => api.addCustomLibrary(newLibraryName.trim(), newLibraryUrl.trim()), onSuccess: () => { addNotification({ message: 'Custom library added.', type: 'success' }) queryClient.invalidateQueries({ queryKey: [CUSTOM_LIBRARIES_KEY] }) setNewLibraryName('') setNewLibraryUrl('') }, onError: () => { addNotification({ message: 'Failed to add custom library.', type: 'error' }) }, }) const removeLibraryMutation = useMutation({ mutationFn: (id: number) => api.removeCustomLibrary(id), onSuccess: (_data, id) => { addNotification({ message: 'Custom library removed.', type: 'success' }) queryClient.invalidateQueries({ queryKey: [CUSTOM_LIBRARIES_KEY] }) if (selectedSource === id) { setSelectedSource('default') setBrowseUrl(null) setBreadcrumbs([]) } }, }) const hasCustomLibraries = customLibraries && customLibraries.length > 0 return (

Content Explorer

Browse and download content for offline reading!

{!isOnline && ( )} {!isInstalled && ( )}
refreshManifests.mutate()} disabled={refreshManifests.isPending || !isOnline} icon="IconRefresh" > Force 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.

)} {/* Kiwix Library / Custom Library Browser */}
setManageModalOpen(true)} disabled={!isOnline} icon="IconLibrary" > {hasCustomLibraries ? 'Manage Custom Libraries' : 'Add Custom Library'}
{/* Source selector dropdown */} {hasCustomLibraries && (
)} {/* Default Kiwix library browser */} {selectedSource === 'default' && ( <>
{ 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`, 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 /> )} {/* Custom library directory browser */} {selectedSource !== 'default' && (
{/* Breadcrumb navigation */} {isBrowsing && (
)} {browseError && ( )} {!isBrowsing && !browseError && browseData && (
{browseData.directories.length === 0 && browseData.files.length === 0 ? (

No directories or ZIM files found at this location.

) : ( {browseData.directories.map((dir) => ( navigateToDirectory(dir.name, dir.url)} > ))} {browseData.files.map((file) => ( ))}
Name Size
{dir.name} --
{file.name} {file.size_bytes ? formatBytes(file.size_bytes) : '--'} confirmCustomDownload(file)} > Download
)}
)}
)} {/* Manage Custom Libraries Modal */} setManageModalOpen(false)} cancelText="Close" >

Add Kiwix mirrors or other ZIM file sources for faster downloads.

{/* Existing libraries */} {customLibraries && customLibraries.length > 0 && (
{customLibraries.map((lib) => (

{lib.name} {lib.is_default && ( (built-in) )}

{lib.base_url}

{!lib.is_default && ( )}
))}
)} {/* Add new library form */}
setNewLibraryName(e.target.value)} /> setNewLibraryUrl(e.target.value)} /> addLibraryMutation.mutate()} disabled={ !newLibraryName.trim() || !newLibraryUrl.trim() || addLibraryMutation.isPending } > Add Library
) }