mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-08 09:46:15 +02:00
feat: Add tiered collection categories UI
- Add kiwix-categories.json with Medicine category and 3 tiers - Create CategoryCard component for displaying category cards - Create TierSelectionModal for tier selection UI - Integrate categories into Easy Setup wizard (Step 3) - Add TypeScript types for categories and tiers - Fallback to legacy flat collections if categories unavailable Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6f0c829d36
commit
3cb5dceb1d
88
admin/inertia/components/CategoryCard.tsx
Normal file
88
admin/inertia/components/CategoryCard.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { formatBytes } from '~/lib/util'
|
||||||
|
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
|
||||||
|
import { CuratedCategory, CategoryTier } from '../../types/downloads'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import { IconChevronRight, IconCircleCheck } from '@tabler/icons-react'
|
||||||
|
|
||||||
|
export interface CategoryCardProps {
|
||||||
|
category: CuratedCategory
|
||||||
|
selectedTier?: CategoryTier | null
|
||||||
|
onClick?: (category: CuratedCategory) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategoryCard: React.FC<CategoryCardProps> = ({ category, selectedTier, onClick }) => {
|
||||||
|
// Calculate total size range across all tiers
|
||||||
|
const getTierTotalSize = (tier: CategoryTier, allTiers: CategoryTier[]): number => {
|
||||||
|
let total = tier.resources.reduce((acc, r) => acc + r.size_mb * 1024 * 1024, 0)
|
||||||
|
|
||||||
|
// Add included tier sizes recursively
|
||||||
|
if (tier.includesTier) {
|
||||||
|
const includedTier = allTiers.find(t => t.slug === tier.includesTier)
|
||||||
|
if (includedTier) {
|
||||||
|
total += getTierTotalSize(includedTier, allTiers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
const minSize = getTierTotalSize(category.tiers[0], category.tiers)
|
||||||
|
const maxSize = getTierTotalSize(category.tiers[category.tiers.length - 1], category.tiers)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'flex flex-col bg-desert-green rounded-lg p-6 text-white border shadow-sm hover:shadow-lg transition-shadow cursor-pointer h-80',
|
||||||
|
selectedTier ? 'border-lime-400 border-2' : 'border-desert-green'
|
||||||
|
)}
|
||||||
|
onClick={() => onClick?.(category)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<div className="flex justify-between w-full items-center">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DynamicIcon icon={category.icon as DynamicIconName} className="w-6 h-6 mr-2" />
|
||||||
|
<h3 className="text-lg font-semibold">{category.name}</h3>
|
||||||
|
</div>
|
||||||
|
{selectedTier ? (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<IconCircleCheck className="w-5 h-5 text-lime-400" />
|
||||||
|
<span className="text-lime-400 text-sm ml-1">{selectedTier.name}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<IconChevronRight className="w-5 h-5 text-white opacity-70" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-200 grow">{category.description}</p>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t border-white/20">
|
||||||
|
<p className="text-sm text-gray-300 mb-2">
|
||||||
|
{category.tiers.length} tiers available
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{category.tiers.map((tier) => (
|
||||||
|
<span
|
||||||
|
key={tier.slug}
|
||||||
|
className={classNames(
|
||||||
|
'text-xs px-2 py-1 rounded',
|
||||||
|
tier.recommended
|
||||||
|
? 'bg-lime-500/30 text-lime-200'
|
||||||
|
: 'bg-white/10 text-gray-300',
|
||||||
|
selectedTier?.slug === tier.slug && 'ring-2 ring-lime-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tier.name}
|
||||||
|
{tier.recommended && ' *'}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-300 text-xs mt-3">
|
||||||
|
Size: {formatBytes(minSize, 1)} - {formatBytes(maxSize, 1)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategoryCard
|
||||||
205
admin/inertia/components/TierSelectionModal.tsx
Normal file
205
admin/inertia/components/TierSelectionModal.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { Fragment } from 'react'
|
||||||
|
import { Dialog, Transition } from '@headlessui/react'
|
||||||
|
import { IconX, IconCheck, IconInfoCircle } from '@tabler/icons-react'
|
||||||
|
import { CuratedCategory, CategoryTier, CategoryResource } from '../../types/downloads'
|
||||||
|
import { formatBytes } from '~/lib/util'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
|
||||||
|
|
||||||
|
interface TierSelectionModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
category: CuratedCategory | null
|
||||||
|
selectedTierSlug?: string | null
|
||||||
|
onSelectTier: (category: CuratedCategory, tier: CategoryTier) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
category,
|
||||||
|
selectedTierSlug,
|
||||||
|
onSelectTier,
|
||||||
|
}) => {
|
||||||
|
if (!category) return null
|
||||||
|
|
||||||
|
// Get all resources for a tier (including inherited resources)
|
||||||
|
const getAllResourcesForTier = (tier: CategoryTier): CategoryResource[] => {
|
||||||
|
const resources = [...tier.resources]
|
||||||
|
|
||||||
|
if (tier.includesTier) {
|
||||||
|
const includedTier = category.tiers.find(t => t.slug === tier.includesTier)
|
||||||
|
if (includedTier) {
|
||||||
|
resources.unshift(...getAllResourcesForTier(includedTier))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resources
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTierTotalSize = (tier: CategoryTier): number => {
|
||||||
|
return getAllResourcesForTier(tier).reduce((acc, r) => acc + r.size_mb * 1024 * 1024, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-50" onClose={onClose}>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-black/50" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className="w-full max-w-4xl transform overflow-hidden rounded-lg bg-white shadow-xl transition-all">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-desert-green px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DynamicIcon
|
||||||
|
icon={category.icon as DynamicIconName}
|
||||||
|
className="w-8 h-8 text-white mr-3"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Dialog.Title className="text-xl font-semibold text-white">
|
||||||
|
{category.name}
|
||||||
|
</Dialog.Title>
|
||||||
|
<p className="text-sm text-gray-200">{category.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white/70 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<IconX size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Select a tier based on your storage capacity and needs. Higher tiers include all content from lower tiers.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{category.tiers.map((tier, index) => {
|
||||||
|
const allResources = getAllResourcesForTier(tier)
|
||||||
|
const totalSize = getTierTotalSize(tier)
|
||||||
|
const isSelected = selectedTierSlug === tier.slug
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tier.slug}
|
||||||
|
onClick={() => onSelectTier(category, tier)}
|
||||||
|
className={classNames(
|
||||||
|
'border-2 rounded-lg p-5 cursor-pointer transition-all',
|
||||||
|
isSelected
|
||||||
|
? 'border-desert-green bg-desert-green/5 shadow-md'
|
||||||
|
: 'border-gray-200 hover:border-desert-green/50 hover:shadow-sm',
|
||||||
|
tier.recommended && !isSelected && 'border-lime-500/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
{tier.name}
|
||||||
|
</h3>
|
||||||
|
{tier.recommended && (
|
||||||
|
<span className="text-xs bg-lime-500 text-white px-2 py-0.5 rounded">
|
||||||
|
Recommended
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tier.includesTier && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
(includes {category.tiers.find(t => t.slug === tier.includesTier)?.name})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 text-sm mb-3">{tier.description}</p>
|
||||||
|
|
||||||
|
{/* Resources preview */}
|
||||||
|
<div className="bg-gray-50 rounded p-3">
|
||||||
|
<p className="text-xs text-gray-500 mb-2 font-medium">
|
||||||
|
{allResources.length} resources included:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
{allResources.map((resource, idx) => (
|
||||||
|
<div key={idx} className="flex items-start text-sm">
|
||||||
|
<IconCheck size={14} className="text-desert-green mr-1.5 mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-700">{resource.title}</span>
|
||||||
|
<span className="text-gray-400 text-xs ml-1">
|
||||||
|
({formatBytes(resource.size_mb * 1024 * 1024, 0)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-4 text-right flex-shrink-0">
|
||||||
|
<div className="text-lg font-semibold text-gray-900">
|
||||||
|
{formatBytes(totalSize, 1)}
|
||||||
|
</div>
|
||||||
|
<div className={classNames(
|
||||||
|
'w-6 h-6 rounded-full border-2 flex items-center justify-center mt-2 ml-auto',
|
||||||
|
isSelected
|
||||||
|
? 'border-desert-green bg-desert-green'
|
||||||
|
: 'border-gray-300'
|
||||||
|
)}>
|
||||||
|
{isSelected && <IconCheck size={16} className="text-white" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info note */}
|
||||||
|
<div className="mt-6 flex items-start gap-2 text-sm text-gray-500 bg-blue-50 p-3 rounded">
|
||||||
|
<IconInfoCircle size={18} className="text-blue-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<p>
|
||||||
|
You can change your selection at any time. Downloads will begin when you complete the setup wizard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="bg-gray-50 px-6 py-4 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-gray-700 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TierSelectionModal
|
||||||
|
|
@ -6,17 +6,35 @@ import StyledButton from '~/components/StyledButton'
|
||||||
import api from '~/lib/api'
|
import api from '~/lib/api'
|
||||||
import { ServiceSlim } from '../../../types/services'
|
import { ServiceSlim } from '../../../types/services'
|
||||||
import CuratedCollectionCard from '~/components/CuratedCollectionCard'
|
import CuratedCollectionCard from '~/components/CuratedCollectionCard'
|
||||||
|
import CategoryCard from '~/components/CategoryCard'
|
||||||
|
import TierSelectionModal from '~/components/TierSelectionModal'
|
||||||
import LoadingSpinner from '~/components/LoadingSpinner'
|
import LoadingSpinner from '~/components/LoadingSpinner'
|
||||||
import Alert from '~/components/Alert'
|
import Alert from '~/components/Alert'
|
||||||
import { IconCheck } from '@tabler/icons-react'
|
import { IconCheck } from '@tabler/icons-react'
|
||||||
import { useNotifications } from '~/context/NotificationContext'
|
import { useNotifications } from '~/context/NotificationContext'
|
||||||
import useInternetStatus from '~/hooks/useInternetStatus'
|
import useInternetStatus from '~/hooks/useInternetStatus'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
import { CuratedCategory, CategoryTier, CategoryResource } from '../../../types/downloads'
|
||||||
|
|
||||||
type WizardStep = 1 | 2 | 3 | 4
|
type WizardStep = 1 | 2 | 3 | 4
|
||||||
|
|
||||||
const CURATED_MAP_COLLECTIONS_KEY = 'curated-map-collections'
|
const CURATED_MAP_COLLECTIONS_KEY = 'curated-map-collections'
|
||||||
const CURATED_ZIM_COLLECTIONS_KEY = 'curated-zim-collections'
|
const CURATED_ZIM_COLLECTIONS_KEY = 'curated-zim-collections'
|
||||||
|
const CURATED_CATEGORIES_KEY = 'curated-categories'
|
||||||
|
const CATEGORIES_URL =
|
||||||
|
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/feature/tiered-collections/collections/kiwix-categories.json'
|
||||||
|
|
||||||
|
// Helper to get all resources for a tier (including inherited resources)
|
||||||
|
const getAllResourcesForTier = (tier: CategoryTier, allTiers: CategoryTier[]): CategoryResource[] => {
|
||||||
|
const resources = [...tier.resources]
|
||||||
|
if (tier.includesTier) {
|
||||||
|
const includedTier = allTiers.find((t) => t.slug === tier.includesTier)
|
||||||
|
if (includedTier) {
|
||||||
|
resources.unshift(...getAllResourcesForTier(includedTier, allTiers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resources
|
||||||
|
}
|
||||||
|
|
||||||
export default function EasySetupWizard(props: { system: { services: ServiceSlim[] } }) {
|
export default function EasySetupWizard(props: { system: { services: ServiceSlim[] } }) {
|
||||||
const [currentStep, setCurrentStep] = useState<WizardStep>(1)
|
const [currentStep, setCurrentStep] = useState<WizardStep>(1)
|
||||||
|
|
@ -25,6 +43,11 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
const [selectedZimCollections, setSelectedZimCollections] = useState<string[]>([])
|
const [selectedZimCollections, setSelectedZimCollections] = useState<string[]>([])
|
||||||
const [isProcessing, setIsProcessing] = useState(false)
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
|
|
||||||
|
// Category/tier selection state
|
||||||
|
const [selectedTiers, setSelectedTiers] = useState<Map<string, CategoryTier>>(new Map())
|
||||||
|
const [tierModalOpen, setTierModalOpen] = useState(false)
|
||||||
|
const [activeCategory, setActiveCategory] = useState<CuratedCategory | null>(null)
|
||||||
|
|
||||||
const { addNotification } = useNotifications()
|
const { addNotification } = useNotifications()
|
||||||
const { isOnline } = useInternetStatus()
|
const { isOnline } = useInternetStatus()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
@ -32,7 +55,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
const anySelectionMade =
|
const anySelectionMade =
|
||||||
selectedServices.length > 0 ||
|
selectedServices.length > 0 ||
|
||||||
selectedMapCollections.length > 0 ||
|
selectedMapCollections.length > 0 ||
|
||||||
selectedZimCollections.length > 0
|
selectedZimCollections.length > 0 ||
|
||||||
|
selectedTiers.size > 0
|
||||||
|
|
||||||
const { data: mapCollections, isLoading: isLoadingMaps } = useQuery({
|
const { data: mapCollections, isLoading: isLoadingMaps } = useQuery({
|
||||||
queryKey: [CURATED_MAP_COLLECTIONS_KEY],
|
queryKey: [CURATED_MAP_COLLECTIONS_KEY],
|
||||||
|
|
@ -46,6 +70,20 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Fetch curated categories with tiers
|
||||||
|
const { data: categories, isLoading: isLoadingCategories } = useQuery({
|
||||||
|
queryKey: [CURATED_CATEGORIES_KEY],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(CATEGORIES_URL)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch categories')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
return data.categories as CuratedCategory[]
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
})
|
||||||
|
|
||||||
const availableServices = props.system.services.filter(
|
const availableServices = props.system.services.filter(
|
||||||
(service) => !service.installed && service.installation_status !== 'installing'
|
(service) => !service.installed && service.installation_status !== 'installing'
|
||||||
)
|
)
|
||||||
|
|
@ -68,6 +106,44 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Category/tier handlers
|
||||||
|
const handleCategoryClick = (category: CuratedCategory) => {
|
||||||
|
if (!isOnline) return
|
||||||
|
setActiveCategory(category)
|
||||||
|
setTierModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTierSelect = (category: CuratedCategory, tier: CategoryTier) => {
|
||||||
|
setSelectedTiers((prev) => {
|
||||||
|
const newMap = new Map(prev)
|
||||||
|
// If same tier is selected, deselect it
|
||||||
|
if (prev.get(category.slug)?.slug === tier.slug) {
|
||||||
|
newMap.delete(category.slug)
|
||||||
|
} else {
|
||||||
|
newMap.set(category.slug, tier)
|
||||||
|
}
|
||||||
|
return newMap
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeTierModal = () => {
|
||||||
|
setTierModalOpen(false)
|
||||||
|
setActiveCategory(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all resources from selected tiers for downloading
|
||||||
|
const getSelectedTierResources = (): CategoryResource[] => {
|
||||||
|
if (!categories) return []
|
||||||
|
const resources: CategoryResource[] = []
|
||||||
|
selectedTiers.forEach((tier, categorySlug) => {
|
||||||
|
const category = categories.find((c) => c.slug === categorySlug)
|
||||||
|
if (category) {
|
||||||
|
resources.push(...getAllResourcesForTier(tier, category.tiers))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return resources
|
||||||
|
}
|
||||||
|
|
||||||
const canProceedToNextStep = () => {
|
const canProceedToNextStep = () => {
|
||||||
if (!isOnline) return false // Must be online to proceed
|
if (!isOnline) return false // Must be online to proceed
|
||||||
if (currentStep === 1) return true // Can skip app installation
|
if (currentStep === 1) return true // Can skip app installation
|
||||||
|
|
@ -105,9 +181,12 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
|
|
||||||
await Promise.all(installPromises)
|
await Promise.all(installPromises)
|
||||||
|
|
||||||
|
// Download collections and individual tier resources
|
||||||
|
const tierResources = getSelectedTierResources()
|
||||||
const downloadPromises = [
|
const downloadPromises = [
|
||||||
...selectedMapCollections.map((slug) => api.downloadMapCollection(slug)),
|
...selectedMapCollections.map((slug) => api.downloadMapCollection(slug)),
|
||||||
...selectedZimCollections.map((slug) => api.downloadZimCollection(slug)),
|
...selectedZimCollections.map((slug) => api.downloadZimCollection(slug)),
|
||||||
|
...tierResources.map((resource) => api.downloadRemoteZimFile(resource.url)),
|
||||||
]
|
]
|
||||||
|
|
||||||
await Promise.all(downloadPromises)
|
await Promise.all(downloadPromises)
|
||||||
|
|
@ -359,44 +438,79 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
const renderStep3 = () => (
|
const renderStep3 = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">Choose ZIM Files</h2>
|
<h2 className="text-3xl font-bold text-gray-900 mb-2">Choose Content Collections</h2>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Select ZIM file collections for offline knowledge. You can always download more later.
|
Select content categories for offline knowledge. Click a category to choose your preferred tier based on storage capacity.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isLoadingZims ? (
|
|
||||||
|
{/* Curated Categories with Tiers */}
|
||||||
|
{isLoadingCategories ? (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex justify-center py-12">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
) : zimCollections && zimCollections.length > 0 ? (
|
) : categories && categories.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<>
|
||||||
{zimCollections.map((collection) => (
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<div
|
{categories.map((category) => (
|
||||||
key={collection.slug}
|
<CategoryCard
|
||||||
onClick={() =>
|
key={category.slug}
|
||||||
isOnline && !collection.all_downloaded && toggleZimCollection(collection.slug)
|
category={category}
|
||||||
}
|
selectedTier={selectedTiers.get(category.slug) || null}
|
||||||
className={classNames(
|
onClick={handleCategoryClick}
|
||||||
'relative',
|
/>
|
||||||
selectedZimCollections.includes(collection.slug) &&
|
))}
|
||||||
'ring-4 ring-desert-green rounded-lg',
|
</div>
|
||||||
collection.all_downloaded && 'opacity-75',
|
|
||||||
!isOnline && 'opacity-50 cursor-not-allowed'
|
{/* Tier Selection Modal */}
|
||||||
)}
|
<TierSelectionModal
|
||||||
>
|
isOpen={tierModalOpen}
|
||||||
<CuratedCollectionCard collection={collection} size="large" />
|
onClose={closeTierModal}
|
||||||
{selectedZimCollections.includes(collection.slug) && (
|
category={activeCategory}
|
||||||
<div className="absolute top-2 right-2 bg-desert-green rounded-full p-1">
|
selectedTierSlug={activeCategory ? selectedTiers.get(activeCategory.slug)?.slug : null}
|
||||||
<IconCheck size={32} className="text-white" />
|
onSelectTier={handleTierSelect}
|
||||||
</div>
|
/>
|
||||||
)}
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Legacy flat collections - show if available and no categories */}
|
||||||
|
{(!categories || categories.length === 0) && (
|
||||||
|
<>
|
||||||
|
{isLoadingZims ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<LoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : zimCollections && zimCollections.length > 0 ? (
|
||||||
</div>
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
) : (
|
{zimCollections.map((collection) => (
|
||||||
<div className="text-center py-12">
|
<div
|
||||||
<p className="text-gray-600 text-lg">No ZIM collections available at this time.</p>
|
key={collection.slug}
|
||||||
</div>
|
onClick={() =>
|
||||||
|
isOnline && !collection.all_downloaded && toggleZimCollection(collection.slug)
|
||||||
|
}
|
||||||
|
className={classNames(
|
||||||
|
'relative',
|
||||||
|
selectedZimCollections.includes(collection.slug) &&
|
||||||
|
'ring-4 ring-desert-green rounded-lg',
|
||||||
|
collection.all_downloaded && 'opacity-75',
|
||||||
|
!isOnline && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CuratedCollectionCard collection={collection} size="large" />
|
||||||
|
{selectedZimCollections.includes(collection.slug) && (
|
||||||
|
<div className="absolute top-2 right-2 bg-desert-green rounded-full p-1">
|
||||||
|
<IconCheck size={32} className="text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-600 text-lg">No content collections available at this time.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -405,7 +519,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
const hasSelections =
|
const hasSelections =
|
||||||
selectedServices.length > 0 ||
|
selectedServices.length > 0 ||
|
||||||
selectedMapCollections.length > 0 ||
|
selectedMapCollections.length > 0 ||
|
||||||
selectedZimCollections.length > 0
|
selectedZimCollections.length > 0 ||
|
||||||
|
selectedTiers.size > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -482,6 +597,39 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedTiers.size > 0 && (
|
||||||
|
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-4">
|
||||||
|
Content Categories ({selectedTiers.size})
|
||||||
|
</h3>
|
||||||
|
{Array.from(selectedTiers.entries()).map(([categorySlug, tier]) => {
|
||||||
|
const category = categories?.find((c) => c.slug === categorySlug)
|
||||||
|
if (!category) return null
|
||||||
|
const resources = getAllResourcesForTier(tier, category.tiers)
|
||||||
|
return (
|
||||||
|
<div key={categorySlug} className="mb-4 last:mb-0">
|
||||||
|
<div className="flex items-center mb-2">
|
||||||
|
<IconCheck size={20} className="text-desert-green mr-2" />
|
||||||
|
<span className="text-gray-900 font-medium">
|
||||||
|
{category.name} - {tier.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 text-sm ml-2">
|
||||||
|
({resources.length} files)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="ml-7 space-y-1">
|
||||||
|
{resources.map((resource, idx) => (
|
||||||
|
<li key={idx} className="text-sm text-gray-600">
|
||||||
|
{resource.title}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
title="Ready to Start"
|
title="Ready to Start"
|
||||||
message="Click 'Complete Setup' to begin installing apps and downloading content. This may take some time depending on your internet connection and the size of the downloads."
|
message="Click 'Complete Setup' to begin installing apps and downloading content. This may take some time depending on your internet connection and the size of the downloads."
|
||||||
|
|
|
||||||
|
|
@ -59,3 +59,33 @@ export type DownloadJobWithProgress = {
|
||||||
filepath: string
|
filepath: string
|
||||||
filetype: string
|
filetype: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tiered category types for curated collections UI
|
||||||
|
export type CategoryResource = {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
size_mb: number
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CategoryTier = {
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
description: string
|
||||||
|
recommended?: boolean
|
||||||
|
includesTier?: string
|
||||||
|
resources: CategoryResource[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CuratedCategory = {
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
icon: string
|
||||||
|
description: string
|
||||||
|
language: string
|
||||||
|
tiers: CategoryTier[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CuratedCategoriesFile = {
|
||||||
|
categories: CuratedCategory[]
|
||||||
|
}
|
||||||
|
|
|
||||||
85
collections/kiwix-categories.json
Normal file
85
collections/kiwix-categories.json
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
{
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"name": "Medicine",
|
||||||
|
"slug": "medicine",
|
||||||
|
"icon": "IconStethoscope",
|
||||||
|
"description": "Medical references, guides, and encyclopedias for healthcare information and emergency preparedness.",
|
||||||
|
"language": "en",
|
||||||
|
"tiers": [
|
||||||
|
{
|
||||||
|
"name": "Essential",
|
||||||
|
"slug": "medicine-essential",
|
||||||
|
"description": "Core medical references for first aid, medications, and emergency care. Start here.",
|
||||||
|
"recommended": true,
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"title": "Medical Library",
|
||||||
|
"description": "Field and emergency medicine books and guides",
|
||||||
|
"url": "https://download.kiwix.org/zim/other/zimgit-medicine_en_2024-08.zim",
|
||||||
|
"size_mb": 67
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "NHS Medicines A to Z",
|
||||||
|
"description": "How medicines work, dosages, side effects, and interactions",
|
||||||
|
"url": "https://download.kiwix.org/zim/zimit/nhs.uk_en_medicines_2025-12.zim",
|
||||||
|
"size_mb": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Military Medicine",
|
||||||
|
"description": "Tactical and field medicine manuals",
|
||||||
|
"url": "https://download.kiwix.org/zim/zimit/fas-military-medicine_en_2025-06.zim",
|
||||||
|
"size_mb": 78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "CDC Health Information",
|
||||||
|
"description": "Disease prevention, travel health, and outbreak information",
|
||||||
|
"url": "https://download.kiwix.org/zim/zimit/wwwnc.cdc.gov_en_all_2024-11.zim",
|
||||||
|
"size_mb": 170
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Standard",
|
||||||
|
"slug": "medicine-standard",
|
||||||
|
"description": "Comprehensive medical encyclopedia with detailed health information. Includes everything in Essential.",
|
||||||
|
"includesTier": "medicine-essential",
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"title": "MedlinePlus",
|
||||||
|
"description": "NIH's consumer health encyclopedia - diseases, conditions, drugs, supplements",
|
||||||
|
"url": "https://download.kiwix.org/zim/zimit/medlineplus.gov_en_all_2025-01.zim",
|
||||||
|
"size_mb": 1800
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Comprehensive",
|
||||||
|
"slug": "medicine-comprehensive",
|
||||||
|
"description": "Professional-level medical references and textbooks. Includes everything in Standard.",
|
||||||
|
"includesTier": "medicine-standard",
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"title": "Wikipedia Medicine",
|
||||||
|
"description": "Curated medical articles from Wikipedia with images",
|
||||||
|
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_medicine_maxi_2026-01.zim",
|
||||||
|
"size_mb": 2000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "LibreTexts Medicine",
|
||||||
|
"description": "Open-source medical textbooks and educational content",
|
||||||
|
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_med_2025-01.zim",
|
||||||
|
"size_mb": 1100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "LibrePathology",
|
||||||
|
"description": "Pathology reference for disease identification",
|
||||||
|
"url": "https://download.kiwix.org/zim/other/librepathology_en_all_maxi_2025-09.zim",
|
||||||
|
"size_mb": 76
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user