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 WikipediaSelector from '~/components/WikipediaSelector' import LoadingSpinner from '~/components/LoadingSpinner' import Alert from '~/components/Alert' import { IconCheck, IconChevronDown, IconChevronUp, IconCpu, IconBooks } 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 type { CategoryWithStatus, SpecTier, SpecResource } from '../../../types/collections' import { resolveTierResources } from '~/lib/collections' import { SERVICE_NAMES } from '../../../constants/service_names' // 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: [SERVICE_NAMES.KIWIX], 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: [SERVICE_NAMES.KOLIBRI], icon: 'IconSchool', }, { id: 'ai', name: 'AI Assistant', technicalName: '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: [SERVICE_NAMES.OLLAMA], 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: [SERVICE_NAMES.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: [SERVICE_NAMES.CYBERCHEF], icon: 'IconChefHat', }, ] type WizardStep = 1 | 2 | 3 | 4 const CURATED_MAP_COLLECTIONS_KEY = 'curated-map-collections' const CURATED_CATEGORIES_KEY = 'curated-categories' const WIKIPEDIA_STATE_KEY = 'wikipedia-state' export default function EasySetupWizard(props: { system: { services: ServiceSlim[] } }) { const [currentStep, setCurrentStep] = useState(1) const [selectedServices, setSelectedServices] = useState([]) const [selectedMapCollections, setSelectedMapCollections] = useState([]) const [selectedAiModels, setSelectedAiModels] = 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) // Wikipedia selection state const [selectedWikipedia, setSelectedWikipedia] = 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 || selectedTiers.size > 0 || selectedAiModels.length > 0 || (selectedWikipedia !== null && selectedWikipedia !== 'none') const { data: mapCollections, isLoading: isLoadingMaps } = useQuery({ queryKey: [CURATED_MAP_COLLECTIONS_KEY], queryFn: () => api.listCuratedMapCollections(), refetchOnWindowFocus: false, }) // Fetch curated categories with tiers const { data: categories, isLoading: isLoadingCategories } = useQuery({ queryKey: [CURATED_CATEGORIES_KEY], queryFn: () => api.listCuratedCategories(), refetchOnWindowFocus: false, }) const { data: recommendedModels, isLoading: isLoadingRecommendedModels } = useQuery({ queryKey: ['recommended-ollama-models'], queryFn: async () => { const res = await api.getAvailableModels({ recommendedOnly: true }) if (!res) { return [] } return res.models }, refetchOnWindowFocus: false, }) // Fetch Wikipedia options and current state const { data: wikipediaState, isLoading: isLoadingWikipedia } = useQuery({ queryKey: [WIKIPEDIA_STATE_KEY], queryFn: () => api.getWikipediaState(), refetchOnWindowFocus: false, }) // All services for display purposes const allServices = props.system.services 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 toggleMapCollection = (slug: string) => { setSelectedMapCollections((prev) => prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug] ) } const toggleAiModel = (modelName: string) => { setSelectedAiModels((prev) => prev.includes(modelName) ? prev.filter((m) => m !== modelName) : [...prev, modelName] ) } // Category/tier handlers const handleCategoryClick = (category: CategoryWithStatus) => { if (!isOnline) return setActiveCategory(category) setTierModalOpen(true) } const handleTierSelect = (category: CategoryWithStatus, tier: SpecTier) => { 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 storage projection const getSelectedTierResources = (): SpecResource[] => { if (!categories) return [] const resources: SpecResource[] = [] selectedTiers.forEach((tier, categorySlug) => { const category = categories.find((c) => c.slug === categorySlug) if (category) { resources.push(...resolveTierResources(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 ?? 0) * 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 AI models if (recommendedModels) { selectedAiModels.forEach((modelName) => { const model = recommendedModels.find((m) => m.name === modelName) if (model?.tags?.[0]?.size) { // Parse size string like "4.7GB" or "1.5GB" const sizeStr = model.tags[0].size const match = sizeStr.match(/^([\d.]+)\s*(GB|MB|KB)?$/i) if (match) { const value = parseFloat(match[1]) const unit = (match[2] || 'GB').toUpperCase() if (unit === 'GB') { totalBytes += value * 1024 * 1024 * 1024 } else if (unit === 'MB') { totalBytes += value * 1024 * 1024 } else if (unit === 'KB') { totalBytes += value * 1024 } } } }) } // Add Wikipedia selection if (selectedWikipedia && wikipediaState) { const option = wikipediaState.options.find((o) => o.id === selectedWikipedia) if (option && option.size_mb > 0) { totalBytes += option.size_mb * 1024 * 1024 } } return totalBytes }, [ selectedTiers, selectedMapCollections, selectedAiModels, selectedWikipedia, categories, mapCollections, recommendedModels, wikipediaState, ]) // Get primary disk/filesystem info for storage projection // Try disk array first (Linux/production), fall back to fsSize (Windows/dev) // Filter out invalid disks (totalSize === 0) and prefer disk with root mount or largest valid disk const getPrimaryDisk = () => { if (!systemInfo?.disk || systemInfo.disk.length === 0) return null // Filter to only valid disks with actual storage const validDisks = systemInfo.disk.filter((d) => d.totalSize > 0) if (validDisks.length === 0) return null // Prefer disk containing root mount (/) or /storage mount const diskWithRoot = validDisks.find((d) => d.filesystems?.some((fs) => fs.mount === '/' || fs.mount === '/storage') ) if (diskWithRoot) return diskWithRoot // Fall back to largest valid disk return validDisks.reduce((largest, current) => current.totalSize > largest.totalSize ? current : largest ) } const primaryDisk = getPrimaryDisk() 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, category tiers, and AI models const categoryTierPromises: Promise[] = [] selectedTiers.forEach((tier, categorySlug) => { categoryTierPromises.push(api.downloadCategoryTier(categorySlug, tier.slug)) }) const downloadPromises = [ ...selectedMapCollections.map((slug) => api.downloadMapCollection(slug)), ...categoryTierPromises, ...selectedAiModels.map((modelName) => api.downloadModel(modelName)), ] await Promise.all(downloadPromises) // Select Wikipedia option if one was chosen if (selectedWikipedia && selectedWikipedia !== wikipediaState?.currentSelection?.optionId) { await api.selectWikipedia(selectedWikipedia) } 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 refreshManifests = useMutation({ mutationFn: () => api.refreshManifests(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: [CURATED_MAP_COLLECTIONS_KEY] }) queryClient.invalidateQueries({ queryKey: [CURATED_CATEGORIES_KEY] }) }, }) // Scroll to top when step changes useEffect(() => { window.scrollTo({ top: 0, behavior: 'smooth' }) }, [currentStep]) // Refresh manifests on mount to ensure we have latest data useEffect(() => { if (!refreshManifests.isPending) { refreshManifests.mutate() } }, []) // eslint-disable-line react-hooks/exhaustive-deps // Set Easy Setup as visited when user lands on this page useEffect(() => { const markAsVisited = async () => { try { await api.updateSetting('ui.hasVisitedEasySetup', 'true') } catch (error) { // Silent fail - this is non-critical console.warn('Failed to mark Easy Setup as visited:', error) } } markAsVisited() }, []) 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_installed && toggleMapCollection(collection.slug) } className={classNames( 'relative', selectedMapCollections.includes(collection.slug) && 'ring-4 ring-desert-green rounded-lg', collection.all_installed && 'opacity-75', !isOnline && 'opacity-50 cursor-not-allowed' )} > {selectedMapCollections.includes(collection.slug) && (
)}
))}
) : (

No map collections available at this time.

)}
) const renderStep3 = () => { // Check if AI or Information capabilities are selected OR already installed const isAiSelected = selectedServices.includes(SERVICE_NAMES.OLLAMA) || installedServices.some((s) => s.service_name === SERVICE_NAMES.OLLAMA) const isInformationSelected = selectedServices.includes(SERVICE_NAMES.KIWIX) || installedServices.some((s) => s.service_name === SERVICE_NAMES.KIWIX) return (

Choose Content

{isAiSelected && isInformationSelected ? 'Select AI models and content categories for offline use.' : isAiSelected ? 'Select AI models to download for offline use.' : isInformationSelected ? 'Select content categories for offline knowledge.' : 'Configure content for your selected capabilities.'}

{/* AI Model Selection - Only show if AI capability is selected */} {isAiSelected && (

AI Models

Select models to download for offline AI

{isLoadingRecommendedModels ? (
) : recommendedModels && recommendedModels.length > 0 ? (
{recommendedModels.map((model) => (
isOnline && toggleAiModel(model.name)} className={classNames( 'p-4 rounded-lg border-2 transition-all cursor-pointer', selectedAiModels.includes(model.name) ? 'border-desert-green bg-desert-green shadow-md' : 'border-desert-stone-light bg-white hover:border-desert-green hover:shadow-sm', !isOnline && 'opacity-50 cursor-not-allowed' )} >

{model.name}

{model.description}

{model.tags?.[0]?.size && (
Size: {model.tags[0].size}
)}
{selectedAiModels.includes(model.name) && ( )}
))}
) : (

No recommended AI models available at this time.

)}
)} {/* Wikipedia Selection - Only show if Information capability is selected */} {isInformationSelected && ( <> {/* Divider between AI Models and Wikipedia */} {isAiSelected &&
}
{isLoadingWikipedia ? (
) : wikipediaState && wikipediaState.options.length > 0 ? ( isOnline && setSelectedWikipedia(optionId)} disabled={!isOnline} /> ) : null}
)} {/* Curated Categories with Tiers - Only show if Information capability is selected */} {isInformationSelected && ( <> {/* Divider between Wikipedia and Additional Content */}

Additional Content

Curated collections for offline reference

{isLoadingCategories ? (
) : categories && categories.length > 0 ? ( <>
{categories.map((category) => ( ))}
{/* Tier Selection Modal */} ) : null} )} {/* Show message if no capabilities requiring content are selected */} {!isAiSelected && !isInformationSelected && (

No content-based capabilities selected. You can skip this step or go back to select capabilities that require content.

)}
) } const renderStep4 = () => { const hasSelections = selectedServices.length > 0 || selectedMapCollections.length > 0 || selectedTiers.size > 0 || selectedAiModels.length > 0 || (selectedWikipedia !== null && selectedWikipedia !== 'none') 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}
  • ) })}
)} {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 = resolveTierResources(tier, category.tiers) return (
{category.name} - {tier.name} ({resources.length} files)
    {resources.map((resource, idx) => (
  • {resource.title}
  • ))}
) })}
)} {selectedWikipedia && selectedWikipedia !== 'none' && (

Wikipedia

{(() => { const option = wikipediaState?.options.find((o) => o.id === selectedWikipedia) return option ? (
{option.name}
{option.size_mb > 0 ? `${(option.size_mb / 1024).toFixed(1)} GB` : 'No download'}
) : null })()}
)} {selectedAiModels.length > 0 && (

AI Models to Download ({selectedAiModels.length})

    {selectedAiModels.map((modelName) => { const model = recommendedModels?.find((m) => m.name === modelName) return (
  • {modelName}
    {model?.tags?.[0]?.size && ( {model.tags[0].size} )}
  • ) })}
)}
)}
) } 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'}, {selectedTiers.size}{' '} content categor{selectedTiers.size !== 1 ? 'ies' : 'y'},{' '} {selectedAiModels.length} AI model{selectedAiModels.length !== 1 && 's'} selected

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