From 95d0816d500f1441bd3a2a596b6032736150b61c Mon Sep 17 00:00:00 2001 From: chriscrosstalk <49691103+chriscrosstalk@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:49:51 -0700 Subject: [PATCH] feat(content-manager): add sortable file size column (#698) Closes #685 Content Manager now surfaces the on-disk size of each ZIM file alongside title/summary, and lets users sort the list by Size or Title. Defaults to Size descending so the largest files are visible first. - ZimService.list() now stats each file and returns size_bytes - Content Manager table adds a formatted Size column (via formatBytes) - Sortable headers for Title and Size with asc/desc toggle --- admin/app/services/zim_service.ts | 16 +++++- admin/inertia/pages/settings/zim/index.tsx | 64 +++++++++++++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/admin/app/services/zim_service.ts b/admin/app/services/zim_service.ts index c6f427c..db6b5b7 100644 --- a/admin/app/services/zim_service.ts +++ b/admin/app/services/zim_service.ts @@ -40,7 +40,21 @@ export class ZimService { await ensureDirectoryExists(dirPath) const all = await listDirectoryContents(dirPath) - const files = all.filter((item) => item.name.endsWith('.zim')) + const zimEntries = all.filter((item) => item.name.endsWith('.zim')) + + const files = await Promise.all( + zimEntries.map(async (entry) => { + const filePath = entry.type === 'file' ? entry.key : join(dirPath, entry.name) + const stats = await getFileStatsIfExists(filePath) + return { + ...entry, + title: null, + summary: null, + author: null, + size_bytes: stats ? Number(stats.size) : null, + } + }) + ) return { files, diff --git a/admin/inertia/pages/settings/zim/index.tsx b/admin/inertia/pages/settings/zim/index.tsx index 9d22937..68ae2b1 100644 --- a/admin/inertia/pages/settings/zim/index.tsx +++ b/admin/inertia/pages/settings/zim/index.tsx @@ -1,5 +1,6 @@ import { Head } from '@inertiajs/react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMemo, useState } from 'react' import StyledTable from '~/components/StyledTable' import SettingsLayout from '~/layouts/SettingsLayout' import api from '~/lib/api' @@ -10,11 +11,18 @@ import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus' import Alert from '~/components/Alert' import { ZimFileWithMetadata } from '../../../../types/zim' import { SERVICE_NAMES } from '../../../../constants/service_names' +import { formatBytes } from '~/lib/util' +import { IconArrowDown, IconArrowUp, IconArrowsSort } from '@tabler/icons-react' + +type SortKey = 'name' | 'size' +type SortDirection = 'asc' | 'desc' export default function ZimPage() { const queryClient = useQueryClient() const { openModal, closeAllModals } = useModals() const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.KIWIX) + const [sortKey, setSortKey] = useState('size') + const [sortDirection, setSortDirection] = useState('desc') const { data, isLoading } = useQuery({ queryKey: ['zim-files'], queryFn: getFiles, @@ -25,6 +33,49 @@ export default function ZimPage() { return res.data.files } + const sortedData = useMemo(() => { + if (!data) return [] + const copy = [...data] + copy.sort((a, b) => { + let cmp = 0 + if (sortKey === 'size') { + const aSize = a.size_bytes ?? 0 + const bSize = b.size_bytes ?? 0 + cmp = aSize - bSize + } else { + const aName = (a.title || a.name).toLowerCase() + const bName = (b.title || b.name).toLowerCase() + cmp = aName.localeCompare(bName) + } + return sortDirection === 'asc' ? cmp : -cmp + }) + return copy + }, [data, sortKey, sortDirection]) + + function toggleSort(key: SortKey) { + if (sortKey === key) { + setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc')) + } else { + setSortKey(key) + setSortDirection(key === 'size' ? 'desc' : 'asc') + } + } + + function renderSortHeader(label: string, key: SortKey) { + const active = sortKey === key + const Icon = !active ? IconArrowsSort : sortDirection === 'asc' ? IconArrowUp : IconArrowDown + return ( + + ) + } + async function confirmDeleteFile(file: ZimFileWithMetadata) { openModal( ( {record.title || record.name} @@ -99,6 +150,15 @@ export default function ZimPage() { ), }, + { + accessor: 'size_bytes', + title: renderSortHeader('Size', 'size'), + render: (record) => ( + + {record.size_bytes ? formatBytes(record.size_bytes, 1) : '—'} + + ), + }, { accessor: 'actions', title: 'Actions', @@ -117,7 +177,7 @@ export default function ZimPage() { ), }, ]} - data={data || []} + data={sortedData} />