project-nomad/admin/inertia/components/updates/ContentUpdatesSection.tsx

228 lines
8.0 KiB
TypeScript

import { useState } from 'react'
import StyledButton from '~/components/StyledButton'
import StyledTable from '~/components/StyledTable'
import StyledSectionHeader from '~/components/StyledSectionHeader'
import ActiveDownloads from '~/components/ActiveDownloads'
import Alert from '~/components/Alert'
import type { ContentUpdateCheckResult, ResourceUpdateInfo } from '../../../types/collections'
import api from '~/lib/api'
import { useQueryClient } from '@tanstack/react-query'
import { useNotifications } from '~/context/NotificationContext'
import { formatBytes } from '~/lib/util'
export default function ContentUpdatesSection() {
const { addNotification } = useNotifications()
const queryClient = useQueryClient()
const [checkResult, setCheckResult] = useState<ContentUpdateCheckResult | null>(null)
const [isChecking, setIsChecking] = useState(false)
const [applyingIds, setApplyingIds] = useState<Set<string>>(new Set())
const [isApplyingAll, setIsApplyingAll] = useState(false)
const handleCheck = async () => {
setIsChecking(true)
try {
const result = await api.checkForContentUpdates()
if (result) {
setCheckResult(result)
}
} catch {
setCheckResult({
updates: [],
checked_at: new Date().toISOString(),
error: 'Failed to check for content updates',
})
} finally {
setIsChecking(false)
}
}
const handleApply = async (update: ResourceUpdateInfo) => {
setApplyingIds((prev) => new Set(prev).add(update.resource_id))
try {
const result = await api.applyContentUpdate(update)
if (result?.success) {
addNotification({ type: 'success', message: `Update started for ${update.resource_id}` })
// Remove from the updates list
setCheckResult((prev) =>
prev
? { ...prev, updates: prev.updates.filter((u) => u.resource_id !== update.resource_id) }
: prev
)
// Force Active Downloads to refetch now — small updates finish before the next
// idle poll fires, so without this the user wouldn't see them.
queryClient.invalidateQueries({ queryKey: ['download-jobs'] })
} else {
addNotification({ type: 'error', message: result?.error || 'Failed to start update' })
}
} catch {
addNotification({ type: 'error', message: `Failed to start update for ${update.resource_id}` })
} finally {
setApplyingIds((prev) => {
const next = new Set(prev)
next.delete(update.resource_id)
return next
})
}
}
const handleApplyAll = async () => {
if (!checkResult?.updates.length) return
setIsApplyingAll(true)
try {
const result = await api.applyAllContentUpdates(checkResult.updates)
if (result?.results) {
const succeeded = result.results.filter((r) => r.success).length
const failed = result.results.filter((r) => !r.success).length
if (succeeded > 0) {
addNotification({ type: 'success', message: `Started ${succeeded} update(s)` })
}
if (failed > 0) {
addNotification({ type: 'error', message: `${failed} update(s) could not be started` })
}
// Remove successful updates from the list
const successIds = new Set(result.results.filter((r) => r.success).map((r) => r.resource_id))
setCheckResult((prev) =>
prev
? { ...prev, updates: prev.updates.filter((u) => !successIds.has(u.resource_id)) }
: prev
)
if (successIds.size > 0) {
queryClient.invalidateQueries({ queryKey: ['download-jobs'] })
}
}
} catch {
addNotification({ type: 'error', message: 'Failed to apply updates' })
} finally {
setIsApplyingAll(false)
}
}
return (
<div className="mt-8">
<StyledSectionHeader title="Manual Content Updates" />
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden p-6">
<div className="flex items-center justify-between">
<p className="text-desert-stone-dark">
Check if newer versions of your installed ZIM files and maps are available.
</p>
<StyledButton
variant="primary"
icon="IconRefresh"
onClick={handleCheck}
loading={isChecking}
>
Check for Content Updates
</StyledButton>
</div>
{checkResult?.error && (
<Alert
type="warning"
title="Update Check Issue"
message={checkResult.error}
variant="bordered"
className="my-4"
/>
)}
{checkResult && !checkResult.error && checkResult.updates.length === 0 && (
<Alert
type="success"
title="All Content Up to Date"
message="All your installed content is running the latest available version."
variant="bordered"
className="my-4"
/>
)}
{checkResult && checkResult.updates.length > 0 && (
<div className="mt-4">
<div className="flex items-center justify-between mb-3">
<p className="text-sm text-desert-stone-dark">
{checkResult.updates.length} update(s) available
</p>
<StyledButton
variant="primary"
size="sm"
icon="IconDownload"
onClick={handleApplyAll}
loading={isApplyingAll}
>
Update All ({checkResult.updates.length})
</StyledButton>
</div>
<StyledTable
data={checkResult.updates}
columns={[
{
accessor: 'resource_id',
title: 'Title',
render: (record) => (
<span className="font-medium text-desert-green">{record.resource_id}</span>
),
},
{
accessor: 'resource_type',
title: 'Type',
render: (record) => (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${record.resource_type === 'zim'
? 'bg-blue-100 text-blue-800'
: 'bg-emerald-100 text-emerald-800'
}`}
>
{record.resource_type === 'zim' ? 'ZIM' : 'Map'}
</span>
),
},
{
accessor: 'size_bytes',
title: 'Size',
render: (record) => (
<span className="text-desert-stone-dark">
{record.size_bytes ? formatBytes(record.size_bytes, 1) : '—'}
</span>
),
},
{
accessor: 'installed_version',
title: 'Version',
render: (record) => (
<span className="text-desert-stone-dark">
{record.installed_version} {record.latest_version}
</span>
),
},
{
accessor: 'resource_id',
title: '',
render: (record) => (
<StyledButton
variant="secondary"
size="sm"
icon="IconDownload"
onClick={() => handleApply(record)}
loading={applyingIds.has(record.resource_id)}
>
Update
</StyledButton>
),
},
]}
/>
</div>
)}
{checkResult?.checked_at && (
<p className="text-xs text-desert-stone mt-3">
Last checked: {new Date(checkResult.checked_at).toLocaleString()}
</p>
)}
</div>
<ActiveDownloads withHeader />
</div>
)
}