project-nomad/admin/inertia/pages/easy-setup/index.tsx

1270 lines
48 KiB
TypeScript

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, IconArrowRight } 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',
},
{
id: 'benchmark',
name: 'System Benchmark',
technicalName: 'Built-in',
description: 'Measure your server performance and compare with the NOMAD community',
features: [
'CPU, memory, and disk benchmarks',
'AI inference performance testing',
'NOMAD Score for easy comparison',
],
services: ['__builtin_benchmark'], // Special marker for built-in features
icon: 'IconChartBar',
},
]
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'
// 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<WizardStep>(1)
const [selectedServices, setSelectedServices] = useState<string[]>([])
const [selectedMapCollections, setSelectedMapCollections] = useState<string[]>([])
const [selectedZimCollections, setSelectedZimCollections] = useState<string[]>([])
const [selectedAiModels, setSelectedAiModels] = useState<string[]>([])
const [isProcessing, setIsProcessing] = useState(false)
const [showAdditionalTools, setShowAdditionalTools] = 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 { 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 ||
selectedAiModels.length > 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,
})
// 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: () => api.getRecommendedModels(),
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 toggleZimCollection = (slug: string) => {
setSelectedZimCollections((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: 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 ?? 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 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)
}
})
}
// 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
}
}
}
})
}
return totalBytes
}, [
selectedTiers,
selectedMapCollections,
selectedZimCollections,
selectedAiModels,
categories,
mapCollections,
zimCollections,
recommendedModels,
])
// 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, individual tier resources, and AI models
const tierResources = getSelectedTierResources()
const downloadPromises = [
...selectedMapCollections.map((slug) => api.downloadMapCollection(slug)),
...selectedZimCollections.map((slug) => api.downloadZimCollection(slug)),
...tierResources.map((resource) => api.downloadRemoteZimFile(resource.url)),
...selectedAiModels.map((modelName) => api.downloadModel(modelName)),
]
await Promise.all(downloadPromises)
// Save installed tiers for each selected category
const tierSavePromises = Array.from(selectedTiers.entries()).map(
([categorySlug, tier]) => api.saveInstalledTier(categorySlug, tier.slug)
)
await Promise.all(tierSavePromises)
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 (
<nav aria-label="Progress" className="px-6 pt-6">
<ol
role="list"
className="divide-y divide-gray-300 rounded-md md:flex md:divide-y-0 md:justify-between border border-desert-green"
>
{steps.map((step, stepIdx) => (
<li key={step.number} className="relative md:flex-1 md:flex md:justify-center">
{currentStep > step.number ? (
<div className="group flex w-full items-center md:justify-center">
<span className="flex items-center px-6 py-2 text-sm font-medium">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-desert-green">
<IconCheck aria-hidden="true" className="size-6 text-white" />
</span>
<span className="ml-4 text-lg font-medium text-gray-900">{step.label}</span>
</span>
</div>
) : currentStep === step.number ? (
<div
aria-current="step"
className="flex items-center px-6 py-2 text-sm font-medium md:justify-center"
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-desert-green border-2 border-desert-green">
<span className="text-white">{step.number}</span>
</span>
<span className="ml-4 text-lg font-medium text-desert-green">{step.label}</span>
</div>
) : (
<div className="group flex items-center md:justify-center">
<span className="flex items-center px-6 py-2 text-sm font-medium">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full border-2 border-gray-300">
<span className="text-gray-500">{step.number}</span>
</span>
<span className="ml-4 text-lg font-medium text-gray-500">{step.label}</span>
</span>
</div>
)}
{stepIdx !== steps.length - 1 ? (
<>
{/* Arrow separator for lg screens and up */}
<div
aria-hidden="true"
className="absolute top-0 right-0 hidden h-full w-5 md:block"
>
<svg
fill="none"
viewBox="0 0 22 80"
preserveAspectRatio="none"
className={`size-full ${currentStep > step.number ? 'text-desert-green' : 'text-gray-300'}`}
>
<path
d="M0 -2L20 40L0 82"
stroke="currentcolor"
vectorEffect="non-scaling-stroke"
strokeLinejoin="round"
/>
</svg>
</div>
</>
) : null}
</li>
))}
</ol>
</nav>
)
}
// Check if a capability is a built-in feature (not a Docker service)
const isBuiltInCapability = (capability: Capability) => {
return capability.services.some((service) => service.startsWith('__builtin_'))
}
// Check if a capability is selected (all its services are in selectedServices)
const isCapabilitySelected = (capability: Capability) => {
if (isBuiltInCapability(capability)) return false // Built-ins can't be selected
return capability.services.every((service) => selectedServices.includes(service))
}
// Check if a capability is already installed (all its services are installed)
const isCapabilityInstalled = (capability: Capability) => {
if (isBuiltInCapability(capability)) return true // Built-ins are always "installed"
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) => {
if (isBuiltInCapability(capability)) return true // Built-ins always exist
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)
const isBuiltIn = isBuiltInCapability(capability)
if (!exists) return null
// Determine visual state: installed (locked), selected (user chose it), or default
const isChecked = installed || selected
// Handle click - built-in features navigate to their page, others toggle selection
const handleClick = () => {
if (isBuiltIn) {
// Navigate to the appropriate settings page for built-in features
if (capability.id === 'benchmark') {
router.visit('/settings/benchmark')
}
} else {
toggleCapability(capability)
}
}
return (
<div
key={capability.id}
onClick={handleClick}
className={classNames(
'p-6 rounded-lg border-2 transition-all',
isBuiltIn
? 'border-desert-stone bg-desert-stone-lighter/50 hover:border-desert-green hover:shadow-sm cursor-pointer'
: 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'
)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3
className={classNames(
'text-xl font-bold',
isBuiltIn ? 'text-gray-700' : installed ? 'text-gray-700' : selected ? 'text-white' : 'text-gray-900'
)}
>
{capability.name}
</h3>
{isBuiltIn ? (
<span className="text-xs bg-desert-stone text-white px-2 py-0.5 rounded-full">
Built-in
</span>
) : installed && (
<span className="text-xs bg-desert-green text-white px-2 py-0.5 rounded-full">
Installed
</span>
)}
</div>
<p
className={classNames(
'text-sm mt-0.5',
isBuiltIn ? 'text-gray-500' : installed ? 'text-gray-500' : selected ? 'text-green-100' : 'text-gray-500'
)}
>
{isBuiltIn ? 'Click to open' : `Powered by ${capability.technicalName}`}
</p>
<p
className={classNames(
'text-sm mt-3',
isBuiltIn ? 'text-gray-600' : installed ? 'text-gray-600' : selected ? 'text-white' : 'text-gray-600'
)}
>
{capability.description}
</p>
{isCore && (
<ul
className={classNames(
'mt-3 space-y-1',
isBuiltIn ? 'text-gray-600' : installed ? 'text-gray-600' : selected ? 'text-white' : 'text-gray-600'
)}
>
{capability.features.map((feature, idx) => (
<li key={idx} className="flex items-start text-sm">
<span
className={classNames(
'mr-2',
isBuiltIn
? 'text-desert-stone'
: installed
? 'text-desert-green'
: selected
? 'text-white'
: 'text-desert-green'
)}
>
</span>
{feature}
</li>
))}
</ul>
)}
</div>
<div
className={classNames(
'ml-4 w-7 h-7 rounded-full border-2 flex items-center justify-center transition-all flex-shrink-0',
isBuiltIn
? 'border-desert-stone bg-desert-stone'
: isChecked
? installed
? 'border-desert-green bg-desert-green'
: 'border-white bg-white'
: 'border-desert-stone'
)}
>
{isBuiltIn ? (
<IconArrowRight size={16} className="text-white" />
) : isChecked && (
<IconCheck size={20} className={installed ? 'text-white' : 'text-desert-green'} />
)}
</div>
</div>
</div>
)
}
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 (
<div className="space-y-8">
<div className="text-center mb-6">
<h2 className="text-3xl font-bold text-gray-900 mb-2">What do you want NOMAD to do?</h2>
<p className="text-gray-600">
Select the capabilities you need. You can always add more later.
</p>
</div>
{allInstalled ? (
<div className="text-center py-12">
<p className="text-gray-600 text-lg">
All available capabilities are already installed!
</p>
<StyledButton
variant="primary"
className="mt-4"
onClick={() => router.visit('/settings/apps')}
>
Manage Apps
</StyledButton>
</div>
) : (
<>
{/* Core Capabilities */}
{existingCoreCapabilities.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-700 mb-4">Core Capabilities</h3>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{existingCoreCapabilities.map((capability) =>
renderCapabilityCard(capability, true)
)}
</div>
</div>
)}
{/* Additional Tools - Collapsible */}
{existingAdditionalTools.length > 0 && (
<div className="border-t border-desert-stone-light pt-6">
<button
onClick={() => setShowAdditionalTools(!showAdditionalTools)}
className="flex items-center justify-between w-full text-left"
>
<h3 className="text-md font-medium text-gray-500">Additional Tools</h3>
{showAdditionalTools ? (
<IconChevronUp size={20} className="text-gray-400" />
) : (
<IconChevronDown size={20} className="text-gray-400" />
)}
</button>
{showAdditionalTools && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
{existingAdditionalTools.map((capability) =>
renderCapabilityCard(capability, false)
)}
</div>
)}
</div>
)}
</>
)}
</div>
)
}
const renderStep2 = () => (
<div className="space-y-6">
<div className="text-center mb-6">
<h2 className="text-3xl font-bold text-gray-900 mb-2">Choose Map Regions</h2>
<p className="text-gray-600">
Select map region collections to download for offline use. You can always download more
regions later.
</p>
</div>
{isLoadingMaps ? (
<div className="flex justify-center py-12">
<LoadingSpinner />
</div>
) : mapCollections && mapCollections.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{mapCollections.map((collection) => (
<div
key={collection.slug}
onClick={() =>
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'
)}
>
<CuratedCollectionCard collection={collection} />
{selectedMapCollections.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 map collections available at this time.</p>
</div>
)}
</div>
)
const renderStep3 = () => {
// Check if AI or Information capabilities are selected OR already installed
const isAiSelected = selectedServices.includes('nomad_open_webui') ||
installedServices.some((s) => s.service_name === 'nomad_open_webui')
const isInformationSelected = selectedServices.includes('nomad_kiwix_serve') ||
installedServices.some((s) => s.service_name === 'nomad_kiwix_serve')
return (
<div className="space-y-6">
<div className="text-center mb-6">
<h2 className="text-3xl font-bold text-gray-900 mb-2">Choose Content</h2>
<p className="text-gray-600">
{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.'}
</p>
</div>
{/* AI Model Selection - Only show if AI capability is selected */}
{isAiSelected && (
<div className="mb-8">
<div className="mb-4">
<h3 className="text-2xl font-semibold text-gray-900 mb-2">Choose AI Models</h3>
<p className="text-gray-600">
Select AI models to download. We've recommended some smaller, popular models to get you started. You'll need at least one to use AI features, but you can always add more later.
</p>
</div>
{isLoadingRecommendedModels ? (
<div className="flex justify-center py-12">
<LoadingSpinner />
</div>
) : recommendedModels && recommendedModels.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{recommendedModels.map((model) => (
<div
key={model.name}
onClick={() => 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'
)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4
className={classNames(
'text-lg font-semibold mb-1',
selectedAiModels.includes(model.name) ? 'text-white' : 'text-gray-900'
)}
>
{model.name}
</h4>
<p
className={classNames(
'text-sm mb-2',
selectedAiModels.includes(model.name) ? 'text-white' : 'text-gray-600'
)}
>
{model.description}
</p>
{model.tags?.[0]?.size && (
<div
className={classNames(
'text-xs',
selectedAiModels.includes(model.name)
? 'text-green-100'
: 'text-gray-500'
)}
>
Size: {model.tags[0].size}
</div>
)}
</div>
<div
className={classNames(
'ml-4 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all flex-shrink-0',
selectedAiModels.includes(model.name)
? 'border-white bg-white'
: 'border-desert-stone'
)}
>
{selectedAiModels.includes(model.name) && (
<IconCheck size={16} className="text-desert-green" />
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-gray-50 rounded-lg">
<p className="text-gray-600">No recommended AI models available at this time.</p>
</div>
)}
</div>
)}
{/* Curated Categories with Tiers - Only show if Information capability is selected */}
{isInformationSelected && (
<>
{isLoadingCategories ? (
<div className="flex justify-center py-12">
<LoadingSpinner />
</div>
) : categories && categories.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{categories.map((category) => (
<CategoryCard
key={category.slug}
category={category}
selectedTier={selectedTiers.get(category.slug) || null}
onClick={handleCategoryClick}
/>
))}
</div>
{/* Tier Selection Modal */}
<TierSelectionModal
isOpen={tierModalOpen}
onClose={closeTierModal}
category={activeCategory}
selectedTierSlug={
activeCategory
? selectedTiers.get(activeCategory.slug)?.slug || activeCategory.installedTierSlug
: null
}
onSelectTier={handleTierSelect}
/>
</>
) : null}
{/* Legacy flat collections - show if available and no categories */}
{(!categories || categories.length === 0) && (
<>
{isLoadingZims ? (
<div className="flex justify-center py-12">
<LoadingSpinner />
</div>
) : zimCollections && zimCollections.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{zimCollections.map((collection) => (
<div
key={collection.slug}
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>
)}
</>
)}
</>
)}
{/* Show message if no capabilities requiring content are selected */}
{!isAiSelected && !isInformationSelected && (
<div className="text-center py-12">
<p className="text-gray-600 text-lg">
No content-based capabilities selected. You can skip this step or go back to select
capabilities that require content.
</p>
</div>
)}
</div>
)
}
const renderStep4 = () => {
const hasSelections =
selectedServices.length > 0 ||
selectedMapCollections.length > 0 ||
selectedZimCollections.length > 0 ||
selectedTiers.size > 0 ||
selectedAiModels.length > 0
return (
<div className="space-y-6">
<div className="text-center mb-6">
<h2 className="text-3xl font-bold text-gray-900 mb-2">Review Your Selections</h2>
<p className="text-gray-600">Review your choices before starting the setup process.</p>
</div>
{!hasSelections ? (
<Alert
title="No Selections Made"
message="You haven't selected anything to install or download. You can go back to make selections or go back to the home page."
type="info"
variant="bordered"
/>
) : (
<div className="space-y-6">
{selectedServices.length > 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">
Capabilities to Install
</h3>
<ul className="space-y-2">
{[...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS]
.filter((cap) => cap.services.some((s) => selectedServices.includes(s)))
.map((capability) => (
<li key={capability.id} className="flex items-center">
<IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-gray-700">
{capability.name}
<span className="text-gray-400 text-sm ml-2">
({capability.technicalName})
</span>
</span>
</li>
))}
</ul>
</div>
)}
{selectedMapCollections.length > 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">
Map Collections to Download ({selectedMapCollections.length})
</h3>
<ul className="space-y-2">
{selectedMapCollections.map((slug) => {
const collection = mapCollections?.find((c) => c.slug === slug)
return (
<li key={slug} className="flex items-center">
<IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-gray-700">{collection?.name || slug}</span>
</li>
)
})}
</ul>
</div>
)}
{selectedZimCollections.length > 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">
ZIM Collections to Download ({selectedZimCollections.length})
</h3>
<ul className="space-y-2">
{selectedZimCollections.map((slug) => {
const collection = zimCollections?.find((c) => c.slug === slug)
return (
<li key={slug} className="flex items-center">
<IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-gray-700">{collection?.name || slug}</span>
</li>
)
})}
</ul>
</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>
)}
{selectedAiModels.length > 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">
AI Models to Download ({selectedAiModels.length})
</h3>
<ul className="space-y-2">
{selectedAiModels.map((modelName) => {
const model = recommendedModels?.find((m) => m.name === modelName)
return (
<li key={modelName} className="flex items-center justify-between">
<div className="flex items-center">
<IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-gray-700">{modelName}</span>
</div>
{model?.tags?.[0]?.size && (
<span className="text-gray-500 text-sm">{model.tags[0].size}</span>
)}
</li>
)
})}
</ul>
</div>
)}
<Alert
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."
type="info"
variant="solid"
/>
</div>
)}
</div>
)
}
return (
<AppLayout>
<Head title="Easy Setup Wizard" />
{!isOnline && (
<Alert
title="No Internet Connection"
message="You'll need an internet connection to proceed. Please connect to the internet and try again."
type="warning"
variant="solid"
className="mb-8"
/>
)}
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="bg-white rounded-md shadow-md">
{renderStepIndicator()}
{storageInfo && (
<div className="px-6 pt-4">
<StorageProjectionBar
totalSize={storageInfo.totalSize}
currentUsed={storageInfo.totalUsed}
projectedAddition={projectedStorageBytes}
/>
</div>
)}
<div className="p-6 min-h-fit">
{currentStep === 1 && renderStep1()}
{currentStep === 2 && renderStep2()}
{currentStep === 3 && renderStep3()}
{currentStep === 4 && renderStep4()}
<div className="flex justify-between mt-8 pt-4 border-t border-desert-stone-light">
<div className="flex space-x-4 items-center">
{currentStep > 1 && (
<StyledButton
onClick={handleBack}
disabled={isProcessing}
variant="outline"
icon="IconChevronLeft"
>
Back
</StyledButton>
)}
<p className="text-sm text-gray-600">
{(() => {
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'},{' '}
{selectedAiModels.length} AI model{selectedAiModels.length !== 1 && 's'} selected
</p>
</div>
<div className="flex space-x-4">
<StyledButton
onClick={() => router.visit('/home')}
disabled={isProcessing}
variant="outline"
>
Cancel & Go to Home
</StyledButton>
{currentStep < 4 ? (
<StyledButton
onClick={handleNext}
disabled={!canProceedToNextStep() || isProcessing}
variant="primary"
icon="IconChevronRight"
>
Next
</StyledButton>
) : (
<StyledButton
onClick={handleFinish}
disabled={isProcessing || !isOnline || !anySelectionMade}
loading={isProcessing}
variant="success"
icon="IconCheck"
>
Complete Setup
</StyledButton>
)}
</div>
</div>
</div>
</div>
</div>
</AppLayout>
)
}