mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-08 09:46:15 +02:00
Users reported slow download speeds from the default Kiwix CDN. This adds the ability to browse and download ZIM files from alternative Kiwix mirrors or self-hosted repositories, all through the GUI. - Add "Custom Libraries" button next to "Browse the Kiwix Library" - Source dropdown to switch between Default (Kiwix) and custom libraries - Browsable directory structure with breadcrumb navigation - 5 pre-seeded official Kiwix mirrors (US, DE, DK, UK, Global CDN) - Built-in mirrors protected from deletion - Downloads use existing pipeline (progress, cancel, Kiwix restart) - Source selection persists across page loads via localStorage - Scrollable directory browser (600px max) with sticky header - SSRF protection on all custom library URLs Closes #576 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
839 lines
31 KiB
TypeScript
839 lines
31 KiB
TypeScript
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<HTMLDivElement>(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<CategoryWithStatus | null>(null)
|
|
|
|
// Wikipedia selection state
|
|
const [selectedWikipedia, setSelectedWikipedia] = useState<string | null>(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<string | null>(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<BrowseResult>({
|
|
queryKey: ['browse-library', browseUrl],
|
|
queryFn: () => api.browseLibrary(browseUrl!) as Promise<BrowseResult>,
|
|
enabled: !!browseUrl && selectedSource !== 'default',
|
|
refetchOnWindowFocus: false,
|
|
retry: false,
|
|
})
|
|
|
|
const { data, fetchNextPage, isFetching, isLoading } =
|
|
useInfiniteQuery<ListRemoteZimFilesResponse>({
|
|
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(
|
|
<StyledModal
|
|
title="Confirm Download?"
|
|
onConfirm={() => {
|
|
downloadFile(record)
|
|
closeAllModals()
|
|
}}
|
|
onCancel={closeAllModals}
|
|
open={true}
|
|
confirmText="Download"
|
|
cancelText="Cancel"
|
|
confirmVariant="primary"
|
|
>
|
|
<p className="text-text-primary">
|
|
Are you sure you want to download{' '}
|
|
<strong>{record.title}</strong>? 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.
|
|
</p>
|
|
</StyledModal>,
|
|
'confirm-download-file-modal'
|
|
)
|
|
}
|
|
|
|
async function confirmCustomDownload(file: { name: string; url: string; size_bytes: number | null }) {
|
|
openModal(
|
|
<StyledModal
|
|
title="Confirm Download?"
|
|
onConfirm={() => {
|
|
downloadCustomFile(file)
|
|
closeAllModals()
|
|
}}
|
|
onCancel={closeAllModals}
|
|
open={true}
|
|
confirmText="Download"
|
|
cancelText="Cancel"
|
|
confirmVariant="primary"
|
|
>
|
|
<p className="text-text-primary">
|
|
Are you sure you want to download{' '}
|
|
<strong>{file.name}</strong>
|
|
{file.size_bytes ? ` (${formatBytes(file.size_bytes)})` : ''}? The Kiwix
|
|
application will be restarted after the download is complete.
|
|
</p>
|
|
</StyledModal>,
|
|
'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 (
|
|
<SettingsLayout>
|
|
<Head title="Content Explorer | Project N.O.M.A.D." />
|
|
<div className="xl:pl-72 w-full">
|
|
<main className="px-12 py-6">
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex flex-col">
|
|
<h1 className="text-4xl font-semibold mb-2">Content Explorer</h1>
|
|
<p className="text-text-muted">Browse and download content for offline reading!</p>
|
|
</div>
|
|
</div>
|
|
{!isOnline && (
|
|
<Alert
|
|
title="No internet connection. You may not be able to download files."
|
|
message=""
|
|
type="warning"
|
|
variant="solid"
|
|
className="!mt-6"
|
|
/>
|
|
)}
|
|
{!isInstalled && (
|
|
<Alert
|
|
title="The Kiwix application is not installed. Please install it to view downloaded content files."
|
|
type="warning"
|
|
variant="solid"
|
|
className="!mt-6"
|
|
/>
|
|
)}
|
|
<div className="mt-8 mb-6 flex items-center justify-between">
|
|
<StyledSectionHeader title="Curated Content" className="!mb-0" />
|
|
<StyledButton
|
|
onClick={() => refreshManifests.mutate()}
|
|
disabled={refreshManifests.isPending || !isOnline}
|
|
icon="IconRefresh"
|
|
>
|
|
Force Refresh Collections
|
|
</StyledButton>
|
|
</div>
|
|
|
|
{/* Wikipedia Selector */}
|
|
{isLoadingWikipedia ? (
|
|
<div className="mt-8 bg-surface-primary rounded-lg border border-border-subtle p-6">
|
|
<div className="flex justify-center py-6">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-desert-green"></div>
|
|
</div>
|
|
</div>
|
|
) : wikipediaState && wikipediaState.options.length > 0 ? (
|
|
<div className="mt-8 bg-surface-primary rounded-lg border border-border-subtle p-6">
|
|
<WikipediaSelector
|
|
options={wikipediaState.options}
|
|
currentSelection={wikipediaState.currentSelection}
|
|
selectedOptionId={selectedWikipedia}
|
|
onSelect={handleWikipediaSelect}
|
|
disabled={!isOnline}
|
|
showSubmitButton
|
|
onSubmit={handleWikipediaSubmit}
|
|
isSubmitting={isSubmittingWikipedia}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Tiered Category Collections */}
|
|
<div className="flex items-center gap-3 mt-8 mb-4">
|
|
<div className="w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm">
|
|
<IconBooks className="w-6 h-6 text-text-primary" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-xl font-semibold text-text-primary">Additional Content</h3>
|
|
<p className="text-sm text-text-muted">Curated collections for offline reference</p>
|
|
</div>
|
|
</div>
|
|
{categories && categories.length > 0 ? (
|
|
<>
|
|
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{categories.map((category) => (
|
|
<CategoryCard
|
|
key={category.slug}
|
|
category={category}
|
|
selectedTier={null}
|
|
onClick={handleCategoryClick}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tier Selection Modal */}
|
|
<TierSelectionModal
|
|
isOpen={tierModalOpen}
|
|
onClose={closeTierModal}
|
|
category={activeCategory}
|
|
selectedTierSlug={activeCategory?.installedTierSlug}
|
|
onSelectTier={handleTierSelect}
|
|
/>
|
|
</>
|
|
) : (
|
|
<p className="text-text-muted mt-4">No curated content categories available.</p>
|
|
)}
|
|
|
|
{/* Kiwix Library / Custom Library Browser */}
|
|
<div className="mt-12 mb-4 flex items-center justify-between">
|
|
<StyledSectionHeader title="Browse the Kiwix Library" className="!mb-0" />
|
|
<StyledButton
|
|
onClick={() => setManageModalOpen(true)}
|
|
disabled={!isOnline}
|
|
icon="IconLibrary"
|
|
>
|
|
{hasCustomLibraries ? 'Manage Custom Libraries' : 'Add Custom Library'}
|
|
</StyledButton>
|
|
</div>
|
|
|
|
{/* Source selector dropdown */}
|
|
{hasCustomLibraries && (
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<label className="text-sm font-medium text-text-secondary">Source:</label>
|
|
<select
|
|
value={selectedSource === 'default' ? 'default' : String(selectedSource)}
|
|
onChange={(e) => handleSourceChange(e.target.value)}
|
|
className="rounded-md border border-border-default bg-surface-primary text-text-primary px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-desert-green"
|
|
>
|
|
<option value="default">Default (Kiwix)</option>
|
|
{customLibraries.map((lib) => (
|
|
<option key={lib.id} value={String(lib.id)}>
|
|
{lib.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Default Kiwix library browser */}
|
|
{selectedSource === 'default' && (
|
|
<>
|
|
<div className="flex justify-start mt-4">
|
|
<Input
|
|
name="search"
|
|
label=""
|
|
placeholder="Search available ZIM files..."
|
|
value={queryUI}
|
|
onChange={(e) => {
|
|
setQueryUI(e.target.value)
|
|
debouncedSetQuery(e.target.value)
|
|
}}
|
|
className="w-1/3"
|
|
leftIcon={<IconSearch className="w-5 h-5 text-text-muted" />}
|
|
/>
|
|
</div>
|
|
<StyledTable<RemoteZimFileEntry & { actions?: any }>
|
|
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 (
|
|
<div className="flex space-x-2">
|
|
<StyledButton
|
|
icon={'IconDownload'}
|
|
onClick={() => {
|
|
confirmDownload(record)
|
|
}}
|
|
>
|
|
Download
|
|
</StyledButton>
|
|
</div>
|
|
)
|
|
},
|
|
},
|
|
]}
|
|
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' && (
|
|
<div className="mt-4">
|
|
{/* Breadcrumb navigation */}
|
|
<nav className="flex items-center gap-1 text-sm text-text-muted mb-4 flex-wrap">
|
|
{breadcrumbs.map((crumb, idx) => (
|
|
<span key={idx} className="flex items-center gap-1">
|
|
{idx > 0 && <IconChevronRight className="w-4 h-4" />}
|
|
{idx < breadcrumbs.length - 1 ? (
|
|
<button
|
|
onClick={() => navigateToBreadcrumb(idx)}
|
|
className="text-desert-green hover:underline"
|
|
>
|
|
{crumb.name}
|
|
</button>
|
|
) : (
|
|
<span className="text-text-primary font-medium">{crumb.name}</span>
|
|
)}
|
|
</span>
|
|
))}
|
|
</nav>
|
|
|
|
{isBrowsing && (
|
|
<div className="flex justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-desert-green"></div>
|
|
</div>
|
|
)}
|
|
|
|
{browseError && (
|
|
<Alert
|
|
title="Could not fetch directory listing from this URL."
|
|
message="The server may not support directory browsing, or the URL may be incorrect."
|
|
type="error"
|
|
variant="solid"
|
|
/>
|
|
)}
|
|
|
|
{!isBrowsing && !browseError && browseData && (
|
|
<div className="bg-surface-primary rounded-lg border border-border-subtle overflow-hidden relative" style={{ maxHeight: '600px', overflowY: 'auto' }}>
|
|
{browseData.directories.length === 0 && browseData.files.length === 0 ? (
|
|
<p className="text-text-muted p-6 text-center">
|
|
No directories or ZIM files found at this location.
|
|
</p>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border-subtle bg-surface-secondary sticky top-0 z-10">
|
|
<th className="text-left px-4 py-3 font-medium text-text-secondary">Name</th>
|
|
<th className="text-right px-4 py-3 font-medium text-text-secondary w-32">Size</th>
|
|
<th className="text-right px-4 py-3 font-medium text-text-secondary w-36"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{browseData.directories.map((dir) => (
|
|
<tr
|
|
key={dir.url}
|
|
className="border-b border-border-subtle hover:bg-surface-secondary cursor-pointer transition-colors"
|
|
onClick={() => navigateToDirectory(dir.name, dir.url)}
|
|
>
|
|
<td className="px-4 py-3">
|
|
<span className="flex items-center gap-2 text-text-primary">
|
|
<IconFolder className="w-5 h-5 text-desert-orange" />
|
|
{dir.name}
|
|
</span>
|
|
</td>
|
|
<td className="text-right px-4 py-3 text-text-muted">--</td>
|
|
<td className="text-right px-4 py-3">
|
|
<IconChevronRight className="w-4 h-4 text-text-muted ml-auto" />
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{browseData.files.map((file) => (
|
|
<tr
|
|
key={file.url}
|
|
className="border-b border-border-subtle hover:bg-surface-secondary transition-colors"
|
|
>
|
|
<td className="px-4 py-3">
|
|
<span className="flex items-center gap-2 text-text-primary">
|
|
<IconFileDownload className="w-5 h-5 text-desert-green" />
|
|
{file.name}
|
|
</span>
|
|
</td>
|
|
<td className="text-right px-4 py-3 text-text-muted">
|
|
{file.size_bytes ? formatBytes(file.size_bytes) : '--'}
|
|
</td>
|
|
<td className="text-right px-4 py-3">
|
|
<StyledButton
|
|
icon="IconDownload"
|
|
onClick={() => confirmCustomDownload(file)}
|
|
>
|
|
Download
|
|
</StyledButton>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<ActiveDownloads filetype="zim" withHeader />
|
|
|
|
{/* Manage Custom Libraries Modal */}
|
|
<StyledModal
|
|
title="Manage Custom Libraries"
|
|
open={manageModalOpen}
|
|
onCancel={() => setManageModalOpen(false)}
|
|
cancelText="Close"
|
|
>
|
|
<div className="space-y-6">
|
|
<div>
|
|
<p className="text-sm text-text-muted mb-4">
|
|
Add Kiwix mirrors or other ZIM file sources for faster downloads.
|
|
</p>
|
|
|
|
{/* Existing libraries */}
|
|
{customLibraries && customLibraries.length > 0 && (
|
|
<div className="space-y-2 mb-6">
|
|
{customLibraries.map((lib) => (
|
|
<div
|
|
key={lib.id}
|
|
className="flex items-center justify-between bg-surface-secondary rounded-lg px-4 py-3 border border-border-subtle"
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium text-text-primary truncate">
|
|
{lib.name}
|
|
{lib.is_default && (
|
|
<span className="ml-2 text-xs text-text-muted font-normal">(built-in)</span>
|
|
)}
|
|
</p>
|
|
<p className="text-xs text-text-muted truncate">{lib.base_url}</p>
|
|
</div>
|
|
{!lib.is_default && (
|
|
<button
|
|
onClick={() => removeLibraryMutation.mutate(lib.id)}
|
|
className="ml-3 p-1.5 text-text-muted hover:text-red-500 transition-colors rounded"
|
|
title="Remove library"
|
|
>
|
|
<IconTrash className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add new library form */}
|
|
<div className="space-y-3">
|
|
<Input
|
|
name="library-name"
|
|
label="Library Name"
|
|
placeholder="e.g., Debian Mirror"
|
|
value={newLibraryName}
|
|
onChange={(e) => setNewLibraryName(e.target.value)}
|
|
/>
|
|
<Input
|
|
name="library-url"
|
|
label="Base URL"
|
|
placeholder="e.g., https://cdimage.debian.org/mirror/kiwix.org/zim/"
|
|
value={newLibraryUrl}
|
|
onChange={(e) => setNewLibraryUrl(e.target.value)}
|
|
/>
|
|
<StyledButton
|
|
icon="IconPlus"
|
|
onClick={() => addLibraryMutation.mutate()}
|
|
disabled={
|
|
!newLibraryName.trim() ||
|
|
!newLibraryUrl.trim() ||
|
|
addLibraryMutation.isPending
|
|
}
|
|
>
|
|
Add Library
|
|
</StyledButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</StyledModal>
|
|
</main>
|
|
</div>
|
|
</SettingsLayout>
|
|
)
|
|
}
|