import { Head, router, usePage } from '@inertiajs/react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState, useMemo } from 'react' import { useTranslation } from 'react-i18next' 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 { getPrimaryDiskInfo } from '~/hooks/useDiskDisplayData' 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 } function buildCoreCapabilities(aiAssistantName: string, t: (key: string) => string): Capability[] { return [ { id: 'information', name: t('easySetup.capabilities.information.name'), technicalName: 'Kiwix', description: t('easySetup.capabilities.information.description'), features: [ t('easySetup.capabilities.information.features.wikipedia'), t('easySetup.capabilities.information.features.medical'), t('easySetup.capabilities.information.features.diy'), t('easySetup.capabilities.information.features.gutenberg'), ], services: [SERVICE_NAMES.KIWIX], icon: 'IconBooks', }, { id: 'education', name: t('easySetup.capabilities.education.name'), technicalName: 'Kolibri', description: t('easySetup.capabilities.education.description'), features: [ t('easySetup.capabilities.education.features.khan'), t('easySetup.capabilities.education.features.k12'), t('easySetup.capabilities.education.features.exercises'), t('easySetup.capabilities.education.features.progress'), ], services: [SERVICE_NAMES.KOLIBRI], icon: 'IconSchool', }, { id: 'ai', name: aiAssistantName, technicalName: 'Ollama', description: t('easySetup.capabilities.ai.description'), features: [ t('easySetup.capabilities.ai.features.private'), t('easySetup.capabilities.ai.features.offline'), t('easySetup.capabilities.ai.features.questions'), t('easySetup.capabilities.ai.features.local'), ], services: [SERVICE_NAMES.OLLAMA], icon: 'IconRobot', }, ] } function buildAdditionalTools(t: (key: string) => string): Capability[] { return [ { id: 'notes', name: t('easySetup.capabilities.notes.name'), technicalName: 'FlatNotes', description: t('easySetup.capabilities.notes.description'), features: [t('easySetup.capabilities.notes.features.markdown'), t('easySetup.capabilities.notes.features.local'), t('easySetup.capabilities.notes.features.noAccount')], services: [SERVICE_NAMES.FLATNOTES], icon: 'IconNotes', }, { id: 'datatools', name: t('easySetup.capabilities.datatools.name'), technicalName: 'CyberChef', description: t('easySetup.capabilities.datatools.description'), features: [ t('easySetup.capabilities.datatools.features.encode'), t('easySetup.capabilities.datatools.features.encryption'), t('easySetup.capabilities.datatools.features.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 { t } = useTranslation() const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props const CORE_CAPABILITIES = buildCoreCapabilities(aiAssistantName, t) const ADDITIONAL_TOOLS = buildAdditionalTools(t) 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 const storageInfo = getPrimaryDiskInfo(systemInfo?.disk, systemInfo?.fsSize) 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: t('easySetup.noInternetSetup'), }) 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: t('easySetup.setupComplete'), }) router.visit('/easy-setup/complete') } catch (error) { console.error('Error during setup:', error) addNotification({ type: 'error', message: t('easySetup.setupError'), }) } 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: t('easySetup.steps.apps') }, { number: 2, label: t('easySetup.steps.maps') }, { number: 3, label: t('easySetup.steps.content') }, { number: 4, label: t('easySetup.steps.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-surface-primary hover:border-desert-green hover:shadow-sm cursor-pointer' )} >

{capability.name}

{installed && ( {t('easySetup.installed')} )}

{t('home.poweredBy', { name: 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 (

{t('easySetup.step1.heading')}

{t('easySetup.step1.subheading')}

{allInstalled ? (

{t('easySetup.step1.allInstalled')}

router.visit('/settings/apps')} > {t('easySetup.step1.manageApps')}
) : ( <> {/* Core Capabilities */} {existingCoreCapabilities.length > 0 && (

{t('easySetup.step1.coreCapabilities')}

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

{t('easySetup.step2.heading')}

{t('easySetup.step2.subheading')}

{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) && (
)}
))}
) : (

{t('easySetup.step2.noCollections')}

)}
) 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 (

{t('easySetup.step3.heading')}

{isAiSelected && isInformationSelected ? t('easySetup.step3.subtextBoth') : isAiSelected ? t('easySetup.step3.subtextAi') : isInformationSelected ? t('easySetup.step3.subtextInfo') : t('easySetup.step3.subtextDefault')}

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

{t('easySetup.step3.aiModels')}

{t('easySetup.step3.aiModelsSubtext')}

{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-surface-primary hover:border-desert-green hover:shadow-sm', !isOnline && 'opacity-50 cursor-not-allowed' )} >

{model.name}

{model.description}

{model.tags?.[0]?.size && (
{t('easySetup.step3.size', { size: model.tags[0].size })}
)}
{selectedAiModels.includes(model.name) && ( )}
))}
) : (

{t('easySetup.step3.noModels')}

)}
)} {/* 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 */}

{t('easySetup.step3.additionalContent')}

{t('easySetup.step3.additionalContentSubtext')}

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

{t('easySetup.step3.noContentCapabilities')}

)}
) } const renderStep4 = () => { const hasSelections = selectedServices.length > 0 || selectedMapCollections.length > 0 || selectedTiers.size > 0 || selectedAiModels.length > 0 || (selectedWikipedia !== null && selectedWikipedia !== 'none') return (

{t('easySetup.step4.heading')}

{t('easySetup.step4.subheading')}

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

{t('easySetup.step4.capabilitiesToInstall')}

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

{t('easySetup.step4.mapCollections', { count: selectedMapCollections.length })}

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

{t('easySetup.step4.contentCategories', { count: 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} ({t('easySetup.step4.files', { count: resources.length })})
    {resources.map((resource, idx) => (
  • {resource.title}
  • ))}
) })}
)} {selectedWikipedia && selectedWikipedia !== 'none' && (

{t('easySetup.step4.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` : t('easySetup.step4.noDownload')}
) : null })()}
)} {selectedAiModels.length > 0 && (

{t('easySetup.step4.aiModelsToDownload', { count: 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 && ( {t('easySetup.back')} )}

{(() => { const count = [...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS].filter((cap) => cap.services.some((s) => selectedServices.includes(s)) ).length return `${count} ${count === 1 ? t('easySetup.capability') : t('easySetup.capabilities')}` })()} , {selectedMapCollections.length} {t('easySetup.steps.maps').toLowerCase()} , {selectedTiers.size} {t('easySetup.steps.content').toLowerCase()} , {selectedAiModels.length} {t('easySetup.step3.aiModels')}

router.visit('/home')} disabled={isProcessing} variant="outline" > {t('easySetup.cancelGoHome')} {currentStep < 4 ? ( {t('easySetup.next')} ) : ( {t('easySetup.completeSetup')} )}
) }