diff --git a/admin/app/controllers/zim_controller.ts b/admin/app/controllers/zim_controller.ts index 96adf63..006e59b 100644 --- a/admin/app/controllers/zim_controller.ts +++ b/admin/app/controllers/zim_controller.ts @@ -6,7 +6,7 @@ import { remoteDownloadWithMetadataValidator, selectWikipediaValidator, } from '#validators/common' -import { listRemoteZimValidator } from '#validators/zim' +import { addCustomLibraryValidator, browseLibraryValidator, idParamValidator, listRemoteZimValidator } from '#validators/zim' import { inject } from '@adonisjs/core' import type { HttpContext } from '@adonisjs/core/http' @@ -85,4 +85,51 @@ export default class ZimController { const payload = await request.validateUsing(selectWikipediaValidator) return this.zimService.selectWikipedia(payload.optionId) } + + // Custom library endpoints + + async listCustomLibraries({}: HttpContext) { + return this.zimService.listCustomLibraries() + } + + async addCustomLibrary({ request, response }: HttpContext) { + const payload = await request.validateUsing(addCustomLibraryValidator) + assertNotPrivateUrl(payload.base_url) + try { + const source = await this.zimService.addCustomLibrary(payload.name, payload.base_url) + return { message: 'Custom library added', library: source } + } catch (error) { + if (error.message === 'Maximum of 10 custom libraries allowed') { + return response.status(400).send({ message: error.message }) + } + throw error + } + } + + async removeCustomLibrary({ request, response }: HttpContext) { + const payload = await request.validateUsing(idParamValidator) + try { + await this.zimService.removeCustomLibrary(payload.params.id) + return { message: 'Custom library removed' } + } catch (error) { + if (error.message === 'Custom library not found') { + return response.status(404).send({ message: error.message }) + } + throw error + } + } + + async browseLibrary({ request, response }: HttpContext) { + const payload = await request.validateUsing(browseLibraryValidator) + try { + return await this.zimService.browseLibraryUrl(payload.url) + } catch (error) { + if (error.message?.includes('loopback or link-local')) { + return response.status(400).send({ message: error.message }) + } + return response.status(502).send({ + message: 'Could not fetch directory listing from the provided URL', + }) + } + } } diff --git a/admin/app/models/custom_library_source.ts b/admin/app/models/custom_library_source.ts new file mode 100644 index 0000000..478d9a0 --- /dev/null +++ b/admin/app/models/custom_library_source.ts @@ -0,0 +1,24 @@ +import { DateTime } from 'luxon' +import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm' + +export default class CustomLibrarySource extends BaseModel { + static namingStrategy = new SnakeCaseNamingStrategy() + + @column({ isPrimary: true }) + declare id: number + + @column() + declare name: string + + @column() + declare base_url: string + + @column() + declare is_default: boolean + + @column.dateTime({ autoCreate: true }) + declare created_at: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updated_at: DateTime +} diff --git a/admin/app/services/zim_service.ts b/admin/app/services/zim_service.ts index db6b5b7..1cc9e97 100644 --- a/admin/app/services/zim_service.ts +++ b/admin/app/services/zim_service.ts @@ -4,6 +4,7 @@ import { RemoteZimFileEntry, } from '../../types/zim.js' import axios from 'axios' +import * as cheerio from 'cheerio' import { XMLParser } from 'fast-xml-parser' import { isRawListRemoteZimFilesResponse, isRawRemoteZimFileEntry } from '../../util/zim.js' import logger from '@adonisjs/core/services/logger' @@ -27,6 +28,8 @@ import { SERVICE_NAMES } from '../../constants/service_names.js' import { CollectionManifestService } from './collection_manifest_service.js' import { KiwixLibraryService } from './kiwix_library_service.js' import type { CategoryWithStatus } from '../../types/collections.js' +import CustomLibrarySource from '#models/custom_library_source' +import { assertNotPrivateUrl } from '#validators/common' const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream'] const WIKIPEDIA_OPTIONS_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json' @@ -587,25 +590,47 @@ export class ZimService { } async onWikipediaDownloadComplete(url: string, success: boolean): Promise { + const filename = url.split('/').pop() || '' const selection = await this.getWikipediaSelection() - if (!selection || selection.url !== url) { - logger.warn(`[ZimService] Wikipedia download complete callback for unknown URL: ${url}`) - return + // Determine which Wikipedia option this file belongs to by matching filename + let matchedOptionId: string | null = null + try { + const options = await this.getWikipediaOptions() + for (const opt of options) { + if (opt.url && opt.url.split('/').pop() === filename) { + matchedOptionId = opt.id + break + } + } + } catch { + // If we can't fetch options, try to continue with existing selection } if (success) { - // Update status to installed - selection.status = 'installed' - await selection.save() + // Update or create the selection record + // Match by filename (not URL) so mirror downloads are recognized + if (selection) { + selection.option_id = matchedOptionId || selection.option_id + selection.url = url + selection.filename = filename + selection.status = 'installed' + await selection.save() + } else { + await WikipediaSelection.create({ + option_id: matchedOptionId || 'unknown', + url: url, + filename: filename, + status: 'installed', + }) + } - logger.info(`[ZimService] Wikipedia download completed successfully: ${selection.filename}`) + logger.info(`[ZimService] Wikipedia download completed successfully: ${filename}`) - // Delete the old Wikipedia file if it exists and is different - // We need to find what was previously installed + // Delete old Wikipedia files (keep only the newly installed one) const existingFiles = await this.list() const wikipediaFiles = existingFiles.files.filter((f) => - f.name.startsWith('wikipedia_en_') && f.name !== selection.filename + f.name.startsWith('wikipedia_en_') && f.name !== filename ) for (const oldFile of wikipediaFiles) { @@ -617,10 +642,137 @@ export class ZimService { } } } else { - // Download failed - keep the selection record but mark as failed - selection.status = 'failed' - await selection.save() - logger.error(`[ZimService] Wikipedia download failed for: ${selection.filename}`) + // Download failed - update selection if it matches this file + if (selection && (!selection.filename || selection.filename === filename)) { + selection.status = 'failed' + await selection.save() + logger.error(`[ZimService] Wikipedia download failed for: ${filename}`) + } else { + logger.error(`[ZimService] Wikipedia download failed for: ${filename} (no matching selection)`) + } } } + + // Custom library source management + + async listCustomLibraries(): Promise { + return CustomLibrarySource.all() + } + + async addCustomLibrary(name: string, baseUrl: string): Promise { + const count = await CustomLibrarySource.query().count('* as total') + const total = Number(count[0].$extras.total) + if (total >= 10) { + throw new Error('Maximum of 10 custom libraries allowed') + } + + // Ensure URL ends with / + const normalizedUrl = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/' + + return CustomLibrarySource.create({ + name, + base_url: normalizedUrl, + }) + } + + async removeCustomLibrary(id: number): Promise { + const source = await CustomLibrarySource.find(id) + if (!source) { + throw new Error('Custom library not found') + } + if (source.is_default) { + throw new Error('Cannot remove a built-in mirror') + } + await source.delete() + } + + async browseLibraryUrl(url: string): Promise<{ + directories: { name: string; url: string }[] + files: { name: string; url: string; size_bytes: number | null }[] + }> { + assertNotPrivateUrl(url) + + const normalizedUrl = url.endsWith('/') ? url : url + '/' + + const res = await axios.get(normalizedUrl, { + responseType: 'text', + timeout: 15000, + headers: { + 'Accept': 'text/html', + }, + }) + + const html: string = res.data + const directories: { name: string; url: string }[] = [] + const files: { name: string; url: string; size_bytes: number | null }[] = [] + + const $ = cheerio.load(html) + + $('a').each((_, el) => { + const href = el.attribs?.href + if (!href || href === '../' || href === './' || href === '/' || href.startsWith('?') || href.startsWith('#')) { + return + } + if (href.startsWith('/') || href.startsWith('http://') || href.startsWith('https://')) { + return + } + + if (href.endsWith('/')) { + const dirName = decodeURIComponent(href.replace(/\/$/, '')) + directories.push({ + name: dirName, + url: new URL(href, normalizedUrl).toString(), + }) + return + } + + if (href.endsWith('.zim')) { + const fileName = decodeURIComponent(href) + + // Apache/Nginx autoindex put the date + size in the text node directly + // following within a
. Walk forward across text siblings until
+        // we find a parseable size token.
+        let trailingText = ''
+        let sibling = el.next
+        while (sibling && sibling.type === 'text') {
+          trailingText += sibling.data
+          if (/\n/.test(sibling.data)) break
+          sibling = sibling.next
+        }
+
+        files.push({
+          name: fileName,
+          url: new URL(href, normalizedUrl).toString(),
+          size_bytes: this._parseListingSize(trailingText),
+        })
+      }
+    })
+
+    directories.sort((a, b) => a.name.localeCompare(b.name))
+    files.sort((a, b) => a.name.localeCompare(b.name))
+
+    return { directories, files }
+  }
+
+  /**
+   * Parse a directory-listing size token out of the text that follows an anchor.
+   * Apache renders e.g. `   2024-01-15 10:30  5.1G`; Nginx renders raw bytes.
+   * Returns bytes or null if no size token is found.
+   */
+  private _parseListingSize(text: string): number | null {
+    // Skip the date/time columns; grab the last numeric token (with optional suffix)
+    // before a newline. Matches `5.1G`, `5368709120`, `1.2T`, etc.
+    const sizeMatch = /([\d.]+\s*[KMGT]?B?|\d+)\s*$/i.exec(text.split('\n')[0].trim())
+    if (!sizeMatch) return null
+
+    const sizeStr = sizeMatch[1].replace(/\s|B$/gi, '')
+    const num = parseFloat(sizeStr)
+    if (isNaN(num)) return null
+
+    if (/^\d+$/.test(sizeStr)) return num
+
+    const suffix = sizeStr.slice(-1).toUpperCase()
+    const multipliers: Record = { K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 }
+    return multipliers[suffix] ? Math.round(num * multipliers[suffix]) : null
+  }
 }
diff --git a/admin/app/validators/zim.ts b/admin/app/validators/zim.ts
index 2c18271..5463e52 100644
--- a/admin/app/validators/zim.ts
+++ b/admin/app/validators/zim.ts
@@ -7,3 +7,30 @@ export const listRemoteZimValidator = vine.compile(
     query: vine.string().optional(),
   })
 )
+
+export const addCustomLibraryValidator = vine.compile(
+  vine.object({
+    name: vine.string().trim().minLength(1).maxLength(100),
+    base_url: vine
+      .string()
+      .url({ require_tld: false })
+      .trim(),
+  })
+)
+
+export const browseLibraryValidator = vine.compile(
+  vine.object({
+    url: vine
+      .string()
+      .url({ require_tld: false })
+      .trim(),
+  })
+)
+
+export const idParamValidator = vine.compile(
+  vine.object({
+    params: vine.object({
+      id: vine.number(),
+    }),
+  })
+)
diff --git a/admin/database/migrations/1775100000001_create_custom_library_sources_table.ts b/admin/database/migrations/1775100000001_create_custom_library_sources_table.ts
new file mode 100644
index 0000000..baa3c77
--- /dev/null
+++ b/admin/database/migrations/1775100000001_create_custom_library_sources_table.ts
@@ -0,0 +1,42 @@
+import { BaseSchema } from '@adonisjs/lucid/schema'
+
+export default class extends BaseSchema {
+  protected tableName = 'custom_library_sources'
+
+  async up() {
+    this.schema.createTable(this.tableName, (table) => {
+      table.increments('id').primary()
+      table.string('name', 100).notNullable()
+      table.string('base_url', 2048).notNullable()
+      table.boolean('is_default').notNullable().defaultTo(false)
+      table.timestamp('created_at').notNullable()
+      table.timestamp('updated_at').notNullable()
+    })
+
+    // Seed default Kiwix mirrors
+    const now = new Date().toISOString().slice(0, 19).replace('T', ' ')
+    const defaults = [
+      { name: 'Debian CDN (Global)', base_url: 'https://cdimage.debian.org/mirror/kiwix.org/zim/' },
+      { name: 'Your.org (US)', base_url: 'https://ftpmirror.your.org/pub/kiwix/zim/' },
+      { name: 'FAU Erlangen (DE)', base_url: 'https://ftp.fau.de/kiwix/zim/' },
+      { name: 'Dotsrc (DK)', base_url: 'https://mirrors.dotsrc.org/kiwix/zim/' },
+      { name: 'MirrorService (UK)', base_url: 'https://www.mirrorservice.org/sites/download.kiwix.org/zim/' },
+    ]
+
+    for (const d of defaults) {
+      await this.defer(async (db) => {
+        await db.table(this.tableName).insert({
+          name: d.name,
+          base_url: d.base_url,
+          is_default: true,
+          created_at: now,
+          updated_at: now,
+        })
+      })
+    }
+  }
+
+  async down() {
+    this.schema.dropTable(this.tableName)
+  }
+}
diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts
index 77e0515..3e015d8 100644
--- a/admin/inertia/lib/api.ts
+++ b/admin/inertia/lib/api.ts
@@ -681,6 +681,42 @@ class API {
     })()
   }
 
+  async listCustomLibraries() {
+    return catchInternal(async () => {
+      const response = await this.client.get<{ id: number; name: string; base_url: string; is_default: boolean }[]>(
+        '/zim/custom-libraries'
+      )
+      return response.data
+    })()
+  }
+
+  async addCustomLibrary(name: string, base_url: string) {
+    return catchInternal(async () => {
+      const response = await this.client.post<{
+        message: string
+        library: { id: number; name: string; base_url: string }
+      }>('/zim/custom-libraries', { name, base_url })
+      return response.data
+    })()
+  }
+
+  async removeCustomLibrary(id: number) {
+    return catchInternal(async () => {
+      const response = await this.client.delete<{ message: string }>(`/zim/custom-libraries/${id}`)
+      return response.data
+    })()
+  }
+
+  async browseLibrary(url: string) {
+    return catchInternal(async () => {
+      const response = await this.client.get<{
+        directories: { name: string; url: string }[]
+        files: { name: string; url: string; size_bytes: number | null }[]
+      }>('/zim/browse-library', { params: { url } })
+      return response.data
+    })()
+  }
+
   async deleteZimFile(filename: string) {
     return catchInternal(async () => {
       const response = await this.client.delete<{ message: string }>(`/zim/${filename}`)
diff --git a/admin/inertia/pages/settings/zim/remote-explorer.tsx b/admin/inertia/pages/settings/zim/remote-explorer.tsx
index 9d1ca3e..7fcfed1 100644
--- a/admin/inertia/pages/settings/zim/remote-explorer.tsx
+++ b/admin/inertia/pages/settings/zim/remote-explorer.tsx
@@ -21,7 +21,16 @@ 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 {
+  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'
@@ -34,6 +43,13 @@ 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()
@@ -56,6 +72,20 @@ export default function ZimRemoteExplorer() {
   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)
@@ -79,6 +109,26 @@ export default function ZimRemoteExplorer() {
     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],
@@ -97,6 +147,7 @@ export default function ZimRemoteExplorer() {
       getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.next_start : undefined),
       refetchOnWindowFocus: false,
       placeholderData: keepPreviousData,
+      enabled: selectedSource === 'default',
     })
 
   const flatData = useMemo(() => {
@@ -140,6 +191,50 @@ export default function ZimRemoteExplorer() {
     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(
        {
+          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, { @@ -179,6 +299,26 @@ export default function ZimRemoteExplorer() { } } + 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 @@ -264,6 +404,35 @@ export default function ZimRemoteExplorer() { }, }) + // 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 ( @@ -302,7 +471,7 @@ export default function ZimRemoteExplorer() { Force Refresh Collections - + {/* Wikipedia Selector */} {isLoadingWikipedia ? (
@@ -360,87 +529,303 @@ export default function ZimRemoteExplorer() { ) : (

No curated content categories available.

)} - -
- { - setQueryUI(e.target.value) - debouncedSetQuery(e.target.value) - }} - className="w-1/3" - leftIcon={} - /> + + {/* Kiwix Library / Custom Library Browser */} +
+ + setManageModalOpen(true)} + disabled={!isOnline} + icon="IconLibrary" + > + {hasCustomLibraries ? 'Manage Custom Libraries' : 'Add Custom Library'} +
- - 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) - }} + + {/* 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 */} +
- ) - }, - }, - ]} - 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 - /> + {crumb.name} + + ) : ( + {crumb.name} + )} + + ))} + + + {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) => ( + + + + + + ))} + +
NameSize
+ + + {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 + +
+
+
+
diff --git a/admin/start/routes.ts b/admin/start/routes.ts index 724734d..fb0d47d 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -183,6 +183,12 @@ router router.get('/wikipedia', [ZimController, 'getWikipediaState']) router.post('/wikipedia/select', [ZimController, 'selectWikipedia']) + + router.get('/custom-libraries', [ZimController, 'listCustomLibraries']) + router.post('/custom-libraries', [ZimController, 'addCustomLibrary']) + router.delete('/custom-libraries/:id', [ZimController, 'removeCustomLibrary']) + router.get('/browse-library', [ZimController, 'browseLibrary']) + router.delete('/:filename', [ZimController, 'delete']) }) .prefix('/api/zim')