From 59b45a745ac3d0a5baa3fe097b48bfaa468a5f72 Mon Sep 17 00:00:00 2001 From: chriscrosstalk <49691103+chriscrosstalk@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:22:46 -0800 Subject: [PATCH] feat: Redesign Easy Setup wizard Step 1 with user-friendly categories (#65) - Replace technical app names with user-friendly capability categories: - "Information Library" (Kiwix) - offline Wikipedia, medical refs, etc. - "Education Platform" (Kolibri) - Khan Academy, K-12 content - "AI Assistant" (Open WebUI + Ollama) - local AI chat - Add bullet point feature lists for each core capability - Move secondary apps (Notes, Data Tools) to collapsible "Additional Tools" - Show already-installed capabilities with "Installed" badge and disabled state - Update terminology: "capabilities" instead of "apps", "content packs" instead of "ZIM collections" - Update Review step to show capability names with technical names in parentheses Co-authored-by: Claude Opus 4.5 Co-authored-by: Jake Turner <52841588+jakeaturner@users.noreply.github.com> --- admin/inertia/pages/easy-setup/index.tsx | 375 ++++++++++++++++++----- 1 file changed, 299 insertions(+), 76 deletions(-) diff --git a/admin/inertia/pages/easy-setup/index.tsx b/admin/inertia/pages/easy-setup/index.tsx index a64ecc0..d25ea90 100644 --- a/admin/inertia/pages/easy-setup/index.tsx +++ b/admin/inertia/pages/easy-setup/index.tsx @@ -10,14 +10,99 @@ 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 { IconCheck } from '@tabler/icons-react' 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' @@ -44,6 +129,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim 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()) @@ -73,6 +159,10 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim 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], @@ -91,6 +181,11 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim (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] @@ -363,75 +458,198 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim ) } - const renderStep1 = () => ( -
-
-

Choose Apps to Install

-

- Select the applications you'd like to install. You can always add more later. -

-
- {availableServices.length === 0 ? ( -
-

All available apps are already installed!

- router.visit('/settings/apps')} - > - Manage Apps - -
- ) : ( -
- {availableServices.map((service) => { - const selected = selectedServices.includes(service.service_name) + // Check if a capability is selected (all its services are in selectedServices) + const isCapabilitySelected = (capability: Capability) => { + return capability.services.every((service) => selectedServices.includes(service)) + } - return ( -
toggleServiceSelection(service.service_name)} + // 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' + )} + > +
+
+
+

-
-
-

- {service.friendly_name || service.service_name} -

-

- {service.description} -

-
-
- {selected ? ( - - ) : ( -
- )} -
+ {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 = () => (
@@ -585,20 +803,20 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim {selectedServices.length > 0 && (

- Apps to Install ({selectedServices.length}) + Capabilities to Install

    - {selectedServices.map((serviceName) => { - const service = availableServices.find((s) => s.service_name === serviceName) - return ( -
  • + {[...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS] + .filter((cap) => cap.services.some((s) => selectedServices.includes(s))) + .map((capability) => ( +
  • - {service?.friendly_name || serviceName} + {capability.name} + ({capability.technicalName})
  • - ) - })} + ))}
)} @@ -730,10 +948,15 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim )}

- {selectedServices.length} app{selectedServices.length !== 1 && 's'},{' '} - {selectedMapCollections.length} map collection - {selectedMapCollections.length !== 1 && 's'}, {selectedZimCollections.length} ZIM - collection{selectedZimCollections.length !== 1 && 's'} selected + {(() => { + 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