feat: expandable rows in Kiwix Library browser (rebased onto dev)

Click a row in the Content Explorer's 'Browse the Kiwix Library' table
to expand it and reveal the full description (no longer truncated) along
with additional metadata: author, publisher, language, category,
article count, media count, issue date, file size, tags, and file name.

Changes:
- Extend RemoteZimFileEntry type with optional metadata fields
  (language, publisher, category, tags, article_count, media_count, issued)
- Update zim_service.ts listRemote() to map these fields from the raw
  Kiwix API response in the paginated accumulator loop
- Remove @tanstack/react-virtual virtualization from remote-explorer.tsx
  (12 items per page — virtualization not needed and incompatible with
  variable-height expanded rows)
- Use StyledTable's built-in expandable prop with expandedRowRender
  to show full details when a row is clicked
- Preserve upstream's custom libraries source selector and directory browser
This commit is contained in:
eizus 2026-07-01 15:57:51 -04:00
parent 6a4f02dd46
commit f9f9969d33
3 changed files with 113 additions and 24 deletions

View File

@ -164,6 +164,13 @@ export class ZimService {
download_url,
author: entry.author.name,
file_name,
language: entry.language,
category: entry.category,
tags: entry.tags,
article_count: entry.articleCount,
media_count: entry.mediaCount,
publisher: entry.publisher?.name,
issued: entry['dc:issued'],
})
}

View File

@ -7,7 +7,6 @@ import {
} 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'
@ -191,13 +190,6 @@ export default function ZimRemoteExplorer() {
[fetchNextPage, isFetching, hasMore]
)
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)
@ -591,14 +583,7 @@ export default function ZimRemoteExplorer() {
/>
</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,
}
})}
data={flatData}
ref={tableParentRef}
loading={isLoading}
columns={[
@ -610,6 +595,13 @@ export default function ZimRemoteExplorer() {
},
{
accessor: 'summary',
render(record) {
return (
<span className="block max-w-md truncate text-text-muted" title={record.summary}>
{record.summary}
</span>
)
},
},
{
accessor: 'updated',
@ -644,11 +636,95 @@ export default function ZimRemoteExplorer() {
},
},
]}
className="relative overflow-x-auto overflow-y-auto h-[600px] w-full mt-4"
tableBodyStyle={{
position: 'relative',
height: `${virtualizer.getTotalSize()}px`,
expandable={{
expandedRowRender(record) {
return (
<div className="py-4 px-6">
<p className="text-sm text-text-primary mb-4">{record.summary}</p>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 text-sm">
{record.author && (
<div>
<span className="text-text-muted">Author: </span>
<span className="text-text-primary">{record.author}</span>
</div>
)}
{record.publisher && (
<div>
<span className="text-text-muted">Publisher: </span>
<span className="text-text-primary">{record.publisher}</span>
</div>
)}
{record.language && (
<div>
<span className="text-text-muted">Language: </span>
<span className="text-text-primary">{record.language}</span>
</div>
)}
{record.category && (
<div>
<span className="text-text-muted">Category: </span>
<span className="text-text-primary">{record.category}</span>
</div>
)}
{record.article_count != null && record.article_count > 0 && (
<div>
<span className="text-text-muted">Articles: </span>
<span className="text-text-primary">
{record.article_count.toLocaleString()}
</span>
</div>
)}
{record.media_count != null && record.media_count > 0 && (
<div>
<span className="text-text-muted">Media: </span>
<span className="text-text-primary">
{record.media_count.toLocaleString()}
</span>
</div>
)}
{record.issued && (
<div>
<span className="text-text-muted">Issued: </span>
<span className="text-text-primary">
{new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
}).format(new Date(record.issued))}
</span>
</div>
)}
{record.size_bytes > 0 && (
<div>
<span className="text-text-muted">Size: </span>
<span className="text-text-primary">{formatBytes(record.size_bytes)}</span>
</div>
)}
</div>
{record.tags && (
<div className="mt-4 flex flex-wrap gap-2">
{record.tags
.split(';')
.filter(Boolean)
.map((tag, i) => (
<span
key={i}
className="inline-flex items-center rounded-full bg-surface-elevated px-2.5 py-0.5 text-xs font-medium text-text-muted"
>
{tag.trim()}
</span>
))}
</div>
)}
{record.file_name && (
<div className="mt-4">
<span className="text-text-muted text-sm">File: </span>
<code className="text-xs text-text-muted">{record.file_name}</code>
</div>
)}
</div>
)
},
}}
className="overflow-y-auto h-[600px] w-full mt-4"
containerProps={{
onScroll: (e) => fetchOnBottomReached(e.currentTarget as HTMLDivElement),
}}
@ -760,7 +836,6 @@ export default function ZimRemoteExplorer() {
)}
</div>
)}
<ActiveDownloads filetype="zim" withHeader />
{/* Manage Custom Libraries Modal */}

View File

@ -64,6 +64,13 @@ export type RemoteZimFileEntry = {
download_url: string
author: string
file_name: string
language?: string
category?: string
tags?: string
article_count?: number
media_count?: number
publisher?: string
issued?: string
}
export type ExtractZIMContentOptions = {
@ -71,8 +78,8 @@ export type ExtractZIMContentOptions = {
maxArticles?: number
onProgress?: (processedArticles: number, totalArticles: number) => void
// Batch processing options to avoid lock timeouts
startOffset?: number // Article index to start from for resuming
batchSize?: number // Max articles to process in this batch
startOffset?: number // Article index to start from for resuming
batchSize?: number // Max articles to process in this batch
}
export type ExtractZIMChunkingStrategy = 'structured' | 'simple'
@ -108,4 +115,4 @@ export type ZIMContentChunk = {
// Extraction metadata
strategy: ExtractZIMChunkingStrategy
}
}