import { Head, router } from '@inertiajs/react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState, useMemo } from 'react' import AppLayout from '~/layouts/AppLayout' 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, IconChevronDown, IconChevronUp } from '@tabler/icons-react' import StorageProjectionBar from '~/components/StorageProjectionBar' import { useNotifications } from '~/context/NotificationContext' import useInternetStatus from '~/hooks/useInternetStatus' import { useSystemInfo } from '~/hooks/useSystemInfo' import classNames from 'classnames' import { CuratedCategory, CategoryTier, CategoryResource } from '../../../types/downloads' // Capability definitions - maps user-friendly categories to services interface Capability { id: string name: string technicalName: string description: string features: string[] services: string[] // service_name values that this capability installs icon: string } const CORE_CAPABILITIES: Capability[] = [ { id: 'information', name: 'Information Library', technicalName: 'Kiwix', description: 'Offline access to Wikipedia, medical references, how-to guides, and encyclopedias', features: [ 'Complete Wikipedia offline', 'Medical references and first aid guides', 'WikiHow articles and tutorials', 'Project Gutenberg books and literature', ], services: ['nomad_kiwix_serve'], icon: 'IconBooks', }, { id: 'education', name: 'Education Platform', technicalName: 'Kolibri', description: 'Interactive learning platform with video courses and exercises', features: [ 'Khan Academy math and science courses', 'K-12 curriculum content', 'Interactive exercises and quizzes', 'Progress tracking for learners', ], services: ['nomad_kolibri'], icon: 'IconSchool', }, { id: 'ai', name: 'AI Assistant', technicalName: 'Open WebUI + Ollama', description: 'Local AI chat that runs entirely on your hardware - no internet required', features: [ 'Private conversations that never leave your device', 'No internet connection needed after setup', 'Ask questions, get help with writing, brainstorm ideas', 'Runs on your own hardware with local AI models', ], services: ['nomad_open_webui'], // ollama is auto-installed as dependency icon: 'IconRobot', }, ] const ADDITIONAL_TOOLS: Capability[] = [ { id: 'notes', name: 'Notes', technicalName: 'FlatNotes', description: 'Simple note-taking app with local storage', features: [ 'Markdown support', 'All notes stored locally', 'No account required', ], services: ['nomad_flatnotes'], icon: 'IconNotes', }, { id: 'datatools', name: 'Data Tools', technicalName: 'CyberChef', description: 'Swiss Army knife for data encoding, encryption, and analysis', features: [ 'Encode/decode data (Base64, hex, etc.)', 'Encryption and hashing tools', 'Data format conversion', ], services: ['nomad_cyberchef'], icon: 'IconChefHat', }, ] 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://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/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) const [selectedServices, setSelectedServices] = useState([]) const [selectedMapCollections, setSelectedMapCollections] = useState([]) const [selectedZimCollections, setSelectedZimCollections] = useState([]) const [isProcessing, setIsProcessing] = useState(false) const [showAdditionalTools, setShowAdditionalTools] = 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() const { data: systemInfo } = useSystemInfo({ enabled: true }) const anySelectionMade = selectedServices.length > 0 || selectedMapCollections.length > 0 || selectedZimCollections.length > 0 || selectedTiers.size > 0 const { data: mapCollections, isLoading: isLoadingMaps } = useQuery({ queryKey: [CURATED_MAP_COLLECTIONS_KEY], queryFn: () => api.listCuratedMapCollections(), refetchOnWindowFocus: false, }) const { data: zimCollections, isLoading: isLoadingZims } = useQuery({ queryKey: [CURATED_ZIM_COLLECTIONS_KEY], queryFn: () => api.listCuratedZimCollections(), refetchOnWindowFocus: false, }) // All services for display purposes const allServices = props.system.services // Services that can still be installed (not already installed) // 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' ) // Services that are already installed const installedServices = props.system.services.filter( (service) => service.installed ) const toggleServiceSelection = (serviceName: string) => { setSelectedServices((prev) => prev.includes(serviceName) ? prev.filter((s) => s !== serviceName) : [...prev, serviceName] ) } const toggleMapCollection = (slug: string) => { setSelectedMapCollections((prev) => prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug] ) } const toggleZimCollection = (slug: string) => { setSelectedZimCollections((prev) => prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug] ) } // 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 } // Calculate total projected storage from all selections const projectedStorageBytes = useMemo(() => { let totalBytes = 0 // Add tier resources const tierResources = getSelectedTierResources() totalBytes += tierResources.reduce((sum, r) => sum + r.size_mb * 1024 * 1024, 0) // Add map collections if (mapCollections) { selectedMapCollections.forEach((slug) => { const collection = mapCollections.find((c) => c.slug === slug) if (collection) { totalBytes += collection.resources.reduce((sum, r) => sum + r.size_mb * 1024 * 1024, 0) } }) } // Add ZIM collections if (zimCollections) { selectedZimCollections.forEach((slug) => { const collection = zimCollections.find((c) => c.slug === slug) if (collection) { totalBytes += collection.resources.reduce((sum, r) => sum + r.size_mb * 1024 * 1024, 0) } }) } return totalBytes }, [selectedTiers, selectedMapCollections, selectedZimCollections, categories, mapCollections, zimCollections]) // Get primary disk/filesystem info for storage projection // Try disk array first (Linux/production), fall back to fsSize (Windows/dev) const primaryDisk = systemInfo?.disk?.[0] const primaryFs = systemInfo?.fsSize?.[0] const storageInfo = primaryDisk ? { totalSize: primaryDisk.totalSize, totalUsed: primaryDisk.totalUsed } : primaryFs ? { totalSize: primaryFs.size, totalUsed: primaryFs.used } : null const canProceedToNextStep = () => { if (!isOnline) return false // Must be online to proceed if (currentStep === 1) return true // Can skip app installation if (currentStep === 2) return true // Can skip map downloads if (currentStep === 3) return true // Can skip ZIM downloads return false } const handleNext = () => { if (currentStep < 4) { setCurrentStep((prev) => (prev + 1) as WizardStep) } } const handleBack = () => { if (currentStep > 1) { setCurrentStep((prev) => (prev - 1) as WizardStep) } } const handleFinish = async () => { if (!isOnline) { addNotification({ type: 'error', message: 'You must have an internet connection to complete the setup.', }) return } setIsProcessing(true) try { // All of these ops don't actually wait for completion, they just kick off the process, so we can run them in parallel without awaiting each one sequentially const installPromises = selectedServices.map((serviceName) => api.installService(serviceName)) 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) addNotification({ type: 'success', message: 'Setup wizard completed! Your selections are being processed.', }) router.visit('/easy-setup/complete') } catch (error) { console.error('Error during setup:', error) addNotification({ type: 'error', message: 'An error occurred during setup. Some items may not have been processed.', }) } finally { setIsProcessing(false) } } const fetchLatestMapCollections = useMutation({ mutationFn: () => api.fetchLatestMapCollections(), onSuccess: () => { addNotification({ message: 'Successfully fetched the latest map collections.', type: 'success', }) queryClient.invalidateQueries({ queryKey: [CURATED_MAP_COLLECTIONS_KEY] }) }, }) const fetchLatestZIMCollections = useMutation({ mutationFn: () => api.fetchLatestZimCollections(), onSuccess: () => { addNotification({ message: 'Successfully fetched the latest ZIM collections.', type: 'success', }) queryClient.invalidateQueries({ queryKey: [CURATED_ZIM_COLLECTIONS_KEY] }) }, }) // Auto-fetch latest collections if the list is empty useEffect(() => { if (mapCollections && mapCollections.length === 0 && !fetchLatestMapCollections.isPending) { fetchLatestMapCollections.mutate() } }, [mapCollections, fetchLatestMapCollections]) useEffect(() => { if (zimCollections && zimCollections.length === 0 && !fetchLatestZIMCollections.isPending) { fetchLatestZIMCollections.mutate() } }, [zimCollections, fetchLatestZIMCollections]) const renderStepIndicator = () => { const steps = [ { number: 1, label: 'Apps' }, { number: 2, label: 'Maps' }, { number: 3, label: 'Content' }, { number: 4, label: 'Review' }, ] return ( ) } // Check if a capability is selected (all its services are in selectedServices) const isCapabilitySelected = (capability: Capability) => { return capability.services.every((service) => selectedServices.includes(service)) } // Check if a capability is already installed (all its services are installed) const isCapabilityInstalled = (capability: Capability) => { return capability.services.every((service) => installedServices.some((s) => s.service_name === service) ) } // Check if a capability exists in the system (has at least one matching service) const capabilityExists = (capability: Capability) => { return capability.services.some((service) => allServices.some((s) => s.service_name === service) ) } // Toggle all services for a capability (only if not already installed) const toggleCapability = (capability: Capability) => { // Don't allow toggling installed capabilities if (isCapabilityInstalled(capability)) return const isSelected = isCapabilitySelected(capability) if (isSelected) { // Deselect all services in this capability setSelectedServices((prev) => prev.filter((s) => !capability.services.includes(s)) ) } else { // Select all available services in this capability const servicesToAdd = capability.services.filter((service) => availableServices.some((s) => s.service_name === service) ) setSelectedServices((prev) => [...new Set([...prev, ...servicesToAdd])]) } } const renderCapabilityCard = (capability: Capability, isCore: boolean = true) => { const selected = isCapabilitySelected(capability) const installed = isCapabilityInstalled(capability) const exists = capabilityExists(capability) if (!exists) return null // Determine visual state: installed (locked), selected (user chose it), or default const isChecked = installed || selected return (
toggleCapability(capability)} className={classNames( 'p-6 rounded-lg border-2 transition-all', installed ? 'border-desert-green bg-desert-green/20 cursor-default' : selected ? 'border-desert-green bg-desert-green shadow-md cursor-pointer' : 'border-desert-stone-light bg-white hover:border-desert-green hover:shadow-sm cursor-pointer' )} >

{capability.name}

{installed && ( Installed )}

Powered by {capability.technicalName}

{capability.description}

{isCore && (
    {capability.features.map((feature, idx) => (
  • {feature}
  • ))}
)}
{isChecked && }
) } const renderStep1 = () => { // Show all capabilities that exist in the system (including installed ones) const existingCoreCapabilities = CORE_CAPABILITIES.filter(capabilityExists) const existingAdditionalTools = ADDITIONAL_TOOLS.filter(capabilityExists) // Check if ALL capabilities are already installed (nothing left to install) const allCoreInstalled = existingCoreCapabilities.every(isCapabilityInstalled) const allAdditionalInstalled = existingAdditionalTools.every(isCapabilityInstalled) const allInstalled = allCoreInstalled && allAdditionalInstalled && existingCoreCapabilities.length > 0 return (

What do you want NOMAD to do?

Select the capabilities you need. You can always add more later.

{allInstalled ? (

All available capabilities are already installed!

router.visit('/settings/apps')} > Manage Apps
) : ( <> {/* Core Capabilities */} {existingCoreCapabilities.length > 0 && (

Core Capabilities

{existingCoreCapabilities.map((capability) => renderCapabilityCard(capability, true))}
)} {/* Additional Tools - Collapsible */} {existingAdditionalTools.length > 0 && (
{showAdditionalTools && (
{existingAdditionalTools.map((capability) => renderCapabilityCard(capability, false))}
)}
)} )}
) } const renderStep2 = () => (

Choose Map Regions

Select map region collections to download for offline use. You can always download more regions later.

{isLoadingMaps ? (
) : mapCollections && mapCollections.length > 0 ? (
{mapCollections.map((collection) => (
isOnline && !collection.all_downloaded && toggleMapCollection(collection.slug) } className={classNames( 'relative', selectedMapCollections.includes(collection.slug) && 'ring-4 ring-desert-green rounded-lg', collection.all_downloaded && 'opacity-75', !isOnline && 'opacity-50 cursor-not-allowed' )} > {selectedMapCollections.includes(collection.slug) && (
)}
))}
) : (

No map collections available at this time.

)}
) const renderStep3 = () => (

Choose Content Collections

Select content categories for offline knowledge. Click a category to choose your preferred tier based on storage capacity.

{/* Curated Categories with Tiers */} {isLoadingCategories ? (
) : 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 ? (
) : 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.

)} )}
) const renderStep4 = () => { const hasSelections = selectedServices.length > 0 || selectedMapCollections.length > 0 || selectedZimCollections.length > 0 || selectedTiers.size > 0 return (

Review Your Selections

Review your choices before starting the setup process.

{!hasSelections ? ( ) : (
{selectedServices.length > 0 && (

Capabilities to Install

    {[...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS] .filter((cap) => cap.services.some((s) => selectedServices.includes(s))) .map((capability) => (
  • {capability.name} ({capability.technicalName})
  • ))}
)} {selectedMapCollections.length > 0 && (

Map Collections to Download ({selectedMapCollections.length})

    {selectedMapCollections.map((slug) => { const collection = mapCollections?.find((c) => c.slug === slug) return (
  • {collection?.name || slug}
  • ) })}
)} {selectedZimCollections.length > 0 && (

ZIM Collections to Download ({selectedZimCollections.length})

    {selectedZimCollections.map((slug) => { const collection = zimCollections?.find((c) => c.slug === slug) return (
  • {collection?.name || slug}
  • ) })}
)} {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}
  • ))}
) })}
)}
)}
) } return ( {!isOnline && ( )}
{renderStepIndicator()} {storageInfo && (
)}
{currentStep === 1 && renderStep1()} {currentStep === 2 && renderStep2()} {currentStep === 3 && renderStep3()} {currentStep === 4 && renderStep4()}
{currentStep > 1 && ( Back )}

{(() => { const count = [...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS].filter((cap) => cap.services.some((s) => selectedServices.includes(s)) ).length return `${count} ${count === 1 ? 'capability' : 'capabilities'}` })()},{' '} {selectedMapCollections.length} map region {selectedMapCollections.length !== 1 && 's'}, {selectedZimCollections.length} content pack{selectedZimCollections.length !== 1 && 's'} selected

router.visit('/home')} disabled={isProcessing} variant="outline" > Cancel & Go to Home {currentStep < 4 ? ( Next ) : ( Complete Setup )}
) }