From 3cb5dceb1d3906298ba8051393d39528e86cce78 Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Sun, 18 Jan 2026 15:52:47 -0800 Subject: [PATCH] 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 --- admin/inertia/components/CategoryCard.tsx | 88 +++++++ .../inertia/components/TierSelectionModal.tsx | 205 +++++++++++++++++ admin/inertia/pages/easy-setup/index.tsx | 214 +++++++++++++++--- admin/types/downloads.ts | 30 +++ collections/kiwix-categories.json | 85 +++++++ 5 files changed, 589 insertions(+), 33 deletions(-) create mode 100644 admin/inertia/components/CategoryCard.tsx create mode 100644 admin/inertia/components/TierSelectionModal.tsx create mode 100644 collections/kiwix-categories.json diff --git a/admin/inertia/components/CategoryCard.tsx b/admin/inertia/components/CategoryCard.tsx new file mode 100644 index 0000000..2f70c33 --- /dev/null +++ b/admin/inertia/components/CategoryCard.tsx @@ -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 = ({ 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 ( +
onClick?.(category)} + > +
+
+
+ +

{category.name}

+
+ {selectedTier ? ( +
+ + {selectedTier.name} +
+ ) : ( + + )} +
+
+ +

{category.description}

+ +
+

+ {category.tiers.length} tiers available +

+
+ {category.tiers.map((tier) => ( + + {tier.name} + {tier.recommended && ' *'} + + ))} +
+

+ Size: {formatBytes(minSize, 1)} - {formatBytes(maxSize, 1)} +

+
+
+ ) +} + +export default CategoryCard diff --git a/admin/inertia/components/TierSelectionModal.tsx b/admin/inertia/components/TierSelectionModal.tsx new file mode 100644 index 0000000..9603627 --- /dev/null +++ b/admin/inertia/components/TierSelectionModal.tsx @@ -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 = ({ + 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 ( + + + +
+ + +
+
+ + + {/* Header */} +
+
+
+ +
+ + {category.name} + +

{category.description}

+
+
+ +
+
+ + {/* Content */} +
+

+ Select a tier based on your storage capacity and needs. Higher tiers include all content from lower tiers. +

+ +
+ {category.tiers.map((tier, index) => { + const allResources = getAllResourcesForTier(tier) + const totalSize = getTierTotalSize(tier) + const isSelected = selectedTierSlug === tier.slug + + return ( +
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' + )} + > +
+
+
+

+ {tier.name} +

+ {tier.recommended && ( + + Recommended + + )} + {tier.includesTier && ( + + (includes {category.tiers.find(t => t.slug === tier.includesTier)?.name}) + + )} +
+

{tier.description}

+ + {/* Resources preview */} +
+

+ {allResources.length} resources included: +

+
+ {allResources.map((resource, idx) => ( +
+ +
+ {resource.title} + + ({formatBytes(resource.size_mb * 1024 * 1024, 0)}) + +
+
+ ))} +
+
+
+ +
+
+ {formatBytes(totalSize, 1)} +
+
+ {isSelected && } +
+
+
+
+ ) + })} +
+ + {/* Info note */} +
+ +

+ You can change your selection at any time. Downloads will begin when you complete the setup wizard. +

+
+
+ + {/* Footer */} +
+ +
+
+
+
+
+
+
+ ) +} + +export default TierSelectionModal diff --git a/admin/inertia/pages/easy-setup/index.tsx b/admin/inertia/pages/easy-setup/index.tsx index 28bcd8a..33b4305 100644 --- a/admin/inertia/pages/easy-setup/index.tsx +++ b/admin/inertia/pages/easy-setup/index.tsx @@ -6,17 +6,35 @@ import StyledButton from '~/components/StyledButton' import api from '~/lib/api' import { ServiceSlim } from '../../../types/services' import CuratedCollectionCard from '~/components/CuratedCollectionCard' +import CategoryCard from '~/components/CategoryCard' +import TierSelectionModal from '~/components/TierSelectionModal' import LoadingSpinner from '~/components/LoadingSpinner' import Alert from '~/components/Alert' import { IconCheck } from '@tabler/icons-react' import { useNotifications } from '~/context/NotificationContext' import useInternetStatus from '~/hooks/useInternetStatus' import classNames from 'classnames' +import { CuratedCategory, CategoryTier, CategoryResource } from '../../../types/downloads' type WizardStep = 1 | 2 | 3 | 4 const CURATED_MAP_COLLECTIONS_KEY = 'curated-map-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[] } }) { const [currentStep, setCurrentStep] = useState(1) @@ -25,6 +43,11 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim const [selectedZimCollections, setSelectedZimCollections] = useState([]) const [isProcessing, setIsProcessing] = useState(false) + // Category/tier selection state + const [selectedTiers, setSelectedTiers] = useState>(new Map()) + const [tierModalOpen, setTierModalOpen] = useState(false) + const [activeCategory, setActiveCategory] = useState(null) + const { addNotification } = useNotifications() const { isOnline } = useInternetStatus() const queryClient = useQueryClient() @@ -32,7 +55,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim const anySelectionMade = selectedServices.length > 0 || selectedMapCollections.length > 0 || - selectedZimCollections.length > 0 + selectedZimCollections.length > 0 || + selectedTiers.size > 0 const { data: mapCollections, isLoading: isLoadingMaps } = useQuery({ queryKey: [CURATED_MAP_COLLECTIONS_KEY], @@ -46,6 +70,20 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim 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( (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 = () => { if (!isOnline) return false // Must be online to proceed 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) + // Download collections and individual tier resources + const tierResources = getSelectedTierResources() const downloadPromises = [ ...selectedMapCollections.map((slug) => api.downloadMapCollection(slug)), ...selectedZimCollections.map((slug) => api.downloadZimCollection(slug)), + ...tierResources.map((resource) => api.downloadRemoteZimFile(resource.url)), ] await Promise.all(downloadPromises) @@ -359,44 +438,79 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim const renderStep3 = () => (
-

Choose ZIM Files

+

Choose Content Collections

- 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.

- {isLoadingZims ? ( + + {/* Curated Categories with Tiers */} + {isLoadingCategories ? (
- ) : zimCollections && zimCollections.length > 0 ? ( -
- {zimCollections.map((collection) => ( -
- 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' - )} - > - - {selectedZimCollections.includes(collection.slug) && ( -
- -
- )} + ) : categories && categories.length > 0 ? ( + <> +
+ {categories.map((category) => ( + + ))} +
+ + {/* Tier Selection Modal */} + + + ) : null} + + {/* Legacy flat collections - show if available and no categories */} + {(!categories || categories.length === 0) && ( + <> + {isLoadingZims ? ( +
+
- ))} -
- ) : ( -
-

No ZIM collections available at this time.

-
+ ) : zimCollections && zimCollections.length > 0 ? ( +
+ {zimCollections.map((collection) => ( +
+ 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' + )} + > + + {selectedZimCollections.includes(collection.slug) && ( +
+ +
+ )} +
+ ))} +
+ ) : ( +
+

No content collections available at this time.

+
+ )} + )}
) @@ -405,7 +519,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim const hasSelections = selectedServices.length > 0 || selectedMapCollections.length > 0 || - selectedZimCollections.length > 0 + selectedZimCollections.length > 0 || + selectedTiers.size > 0 return (
@@ -482,6 +597,39 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
)} + {selectedTiers.size > 0 && ( +
+

+ Content Categories ({selectedTiers.size}) +

+ {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 ( +
+
+ + + {category.name} - {tier.name} + + + ({resources.length} files) + +
+
    + {resources.map((resource, idx) => ( +
  • + {resource.title} +
  • + ))} +
+
+ ) + })} +
+ )} +