mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01:00
feat: [wip] easy setup wizard
This commit is contained in:
parent
bb0a939458
commit
5793fc2139
21
admin/app/controllers/easy_setup_controller.ts
Normal file
21
admin/app/controllers/easy_setup_controller.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { SystemService } from '#services/system_service'
|
||||
import { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
@inject()
|
||||
export default class EasySetupController {
|
||||
constructor(private systemService: SystemService) {}
|
||||
|
||||
async index({ inertia }: HttpContext) {
|
||||
const services = await this.systemService.getServices({ installedOnly: false })
|
||||
return inertia.render('easy-setup/index', {
|
||||
system: {
|
||||
services: services,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async complete({ inertia }: HttpContext) {
|
||||
return inertia.render('easy-setup/complete')
|
||||
}
|
||||
}
|
||||
|
|
@ -85,6 +85,7 @@ export class SystemService {
|
|||
friendly_name: service.friendly_name,
|
||||
description: service.description,
|
||||
installed: service.installed,
|
||||
installation_status: service.installation_status,
|
||||
status: status ? status.status : 'unknown',
|
||||
ui_location: service.ui_location || '',
|
||||
})
|
||||
|
|
|
|||
42
admin/inertia/components/ActiveDownloads.tsx
Normal file
42
admin/inertia/components/ActiveDownloads.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import useDownloads, { useDownloadsProps } from '~/hooks/useDownloads'
|
||||
import HorizontalBarChart from './HorizontalBarChart'
|
||||
import { extractFileName } from '~/lib/util'
|
||||
import StyledSectionHeader from './StyledSectionHeader'
|
||||
|
||||
interface ActiveDownloadProps {
|
||||
filetype?: useDownloadsProps['filetype']
|
||||
withHeader?: boolean
|
||||
}
|
||||
|
||||
const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps) => {
|
||||
const { data: downloads } = useDownloads({ filetype })
|
||||
|
||||
return (
|
||||
<>
|
||||
{withHeader && <StyledSectionHeader title="Active Downloads" className="mt-12 mb-4" />}
|
||||
<div className="space-y-4">
|
||||
{downloads && downloads.length > 0 ? (
|
||||
downloads.map((download) => (
|
||||
<div className="bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
|
||||
<HorizontalBarChart
|
||||
items={[
|
||||
{
|
||||
label: extractFileName(download.filepath) || download.url,
|
||||
value: download.progress,
|
||||
total: '100%',
|
||||
used: `${download.progress}%`,
|
||||
type: download.filetype,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500">No active downloads</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActiveDownloads
|
||||
|
|
@ -89,7 +89,7 @@ export default function Alert({
|
|||
: type === 'success'
|
||||
? 'bg-desert-olive text-desert-white border-desert-olive-dark'
|
||||
: type === 'info'
|
||||
? 'bg-desert-stone text-desert-white border-desert-stone-dark'
|
||||
? 'bg-desert-green text-desert-white border-desert-green-dark'
|
||||
: ''
|
||||
)
|
||||
return classNames(baseStyles, 'shadow-sm', ...variantStyles)
|
||||
|
|
@ -102,7 +102,7 @@ export default function Alert({
|
|||
: type === 'success'
|
||||
? 'bg-desert-olive-lighter bg-opacity-20 border-desert-olive-light'
|
||||
: type === 'info'
|
||||
? 'bg-desert-stone-lighter bg-opacity-20 border-desert-stone-light'
|
||||
? 'bg-desert-green bg-opacity-20 border-desert-green-light'
|
||||
: ''
|
||||
)
|
||||
return classNames(baseStyles, 'border shadow-sm', ...variantStyles)
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ import { IconCircleCheck } from '@tabler/icons-react'
|
|||
|
||||
export interface CuratedCollectionCardProps {
|
||||
collection: CuratedCollectionWithStatus
|
||||
onClick?: (collection: CuratedCollectionWithStatus) => void
|
||||
onClick?: (collection: CuratedCollectionWithStatus) => void;
|
||||
size?: 'small' | 'large'
|
||||
}
|
||||
|
||||
const CuratedCollectionCard: React.FC<CuratedCollectionCardProps> = ({ collection, onClick }) => {
|
||||
const CuratedCollectionCard: React.FC<CuratedCollectionCardProps> = ({ collection, onClick, size = 'small' }) => {
|
||||
const totalSizeBytes = collection.resources?.reduce(
|
||||
(acc, resource) => acc + resource.size_mb * 1024 * 1024,
|
||||
0
|
||||
|
|
@ -18,7 +19,8 @@ const CuratedCollectionCard: React.FC<CuratedCollectionCardProps> = ({ collectio
|
|||
<div
|
||||
className={classNames(
|
||||
'flex flex-col bg-desert-green rounded-lg p-6 text-white border border-b-desert-green shadow-sm hover:shadow-lg transition-shadow cursor-pointer',
|
||||
{ 'opacity-65 cursor-not-allowed !hover:shadow-sm': collection.all_downloaded }
|
||||
{ 'opacity-65 cursor-not-allowed !hover:shadow-sm': collection.all_downloaded },
|
||||
{ 'h-56': size === 'small', 'h-80': size === 'large' }
|
||||
)}
|
||||
onClick={() => {
|
||||
if (collection.all_downloaded) {
|
||||
|
|
|
|||
|
|
@ -20,12 +20,13 @@ export type InstallActivityFeedProps = {
|
|||
message: string
|
||||
}>
|
||||
className?: string
|
||||
withHeader?: boolean
|
||||
}
|
||||
|
||||
const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, className }) => {
|
||||
const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, className, withHeader = false }) => {
|
||||
return (
|
||||
<div className={classNames('bg-white shadow-sm rounded-lg p-6', className)}>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Installation Activity</h2>
|
||||
{withHeader && <h2 className="text-lg font-semibold text-gray-900">Installation Activity</h2>}
|
||||
<ul role="list" className="mt-6 space-y-6 text-desert-green">
|
||||
{activity.map((activityItem, activityItemIdx) => (
|
||||
<li key={activityItem.timestamp} className="relative flex gap-x-4">
|
||||
|
|
|
|||
28
admin/inertia/hooks/useServiceInstallationActivity.ts
Normal file
28
admin/inertia/hooks/useServiceInstallationActivity.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useTransmit } from 'react-adonis-transmit'
|
||||
import { InstallActivityFeedProps } from '~/components/InstallActivityFeed'
|
||||
|
||||
export default function useServiceInstallationActivity() {
|
||||
const { subscribe } = useTransmit()
|
||||
const [installActivity, setInstallActivity] = useState<InstallActivityFeedProps['activity']>([])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribe('service-installation', (data: any) => {
|
||||
setInstallActivity((prev) => [
|
||||
...prev,
|
||||
{
|
||||
service_name: data.service_name ?? 'unknown',
|
||||
type: data.status ?? 'unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: data.message ?? 'No message provided',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return installActivity
|
||||
}
|
||||
53
admin/inertia/pages/easy-setup/complete.tsx
Normal file
53
admin/inertia/pages/easy-setup/complete.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { Head, router } from '@inertiajs/react'
|
||||
import AppLayout from '~/layouts/AppLayout'
|
||||
import StyledButton from '~/components/StyledButton'
|
||||
import Alert from '~/components/Alert'
|
||||
import useInternetStatus from '~/hooks/useInternetStatus'
|
||||
import useServiceInstallationActivity from '~/hooks/useServiceInstallationActivity'
|
||||
import InstallActivityFeed from '~/components/InstallActivityFeed'
|
||||
import ActiveDownloads from '~/components/ActiveDownloads'
|
||||
import StyledSectionHeader from '~/components/StyledSectionHeader'
|
||||
|
||||
export default function EasySetupWizardComplete() {
|
||||
const { isOnline } = useInternetStatus()
|
||||
const installActivity = useServiceInstallationActivity()
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Head title="Easy Setup Wizard Complete" />
|
||||
{!isOnline && (
|
||||
<Alert
|
||||
title="No Internet Connection"
|
||||
message="It looks like you're not connected to the internet. Installing apps and downloading content will require an internet connection."
|
||||
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 p-6">
|
||||
<StyledSectionHeader title="App Installation Activity" className=" mb-4" />
|
||||
<InstallActivityFeed
|
||||
activity={installActivity}
|
||||
className="!shadow-none border-desert-stone-light border"
|
||||
/>
|
||||
<ActiveDownloads withHeader />
|
||||
<Alert
|
||||
title="Running in the Background"
|
||||
message='Feel free to leave this page at any time - your app installs and downloads will continue in the background!'
|
||||
type="info"
|
||||
variant="solid"
|
||||
className='mt-12'
|
||||
/>
|
||||
<div className="flex justify-center mt-8 pt-4 border-t border-desert-stone-light">
|
||||
<div className="flex space-x-4">
|
||||
<StyledButton onClick={() => router.visit('/home')} icon="HomeIcon">
|
||||
Go to Home
|
||||
</StyledButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
550
admin/inertia/pages/easy-setup/index.tsx
Normal file
550
admin/inertia/pages/easy-setup/index.tsx
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
import { Head, router } from '@inertiajs/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { 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 LoadingSpinner from '~/components/LoadingSpinner'
|
||||
import Alert from '~/components/Alert'
|
||||
import { IconCheck } from '@tabler/icons-react'
|
||||
import { useNotifications } from '~/context/NotificationContext'
|
||||
import useInternetStatus from '~/hooks/useInternetStatus'
|
||||
import classNames from 'classnames'
|
||||
|
||||
type WizardStep = 1 | 2 | 3 | 4
|
||||
|
||||
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 [isProcessing, setIsProcessing] = useState(false)
|
||||
|
||||
const { addNotification } = useNotifications()
|
||||
const { isOnline } = useInternetStatus()
|
||||
|
||||
const anySelectionMade =
|
||||
selectedServices.length > 0 ||
|
||||
selectedMapCollections.length > 0 ||
|
||||
selectedZimCollections.length > 0
|
||||
|
||||
const { data: mapCollections, isLoading: isLoadingMaps } = useQuery({
|
||||
queryKey: ['curated-map-collections'],
|
||||
queryFn: () => api.listCuratedMapCollections(),
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const { data: zimCollections, isLoading: isLoadingZims } = useQuery({
|
||||
queryKey: ['curated-zim-collections'],
|
||||
queryFn: () => api.listCuratedZimCollections(),
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const availableServices = useMemo(() => {
|
||||
return props.system.services.filter((service) => !service.installed)
|
||||
}, [props.system.services])
|
||||
|
||||
const toggleServiceSelection = (serviceName: string) => {
|
||||
setSelectedServices((prev) =>
|
||||
prev.includes(serviceName) ? prev.filter((s) => s !== serviceName) : [...prev, serviceName]
|
||||
)
|
||||
}
|
||||
|
||||
const toggleMapCollection = (slug: string) => {
|
||||
setSelectedMapCollections((prev) =>
|
||||
prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]
|
||||
)
|
||||
}
|
||||
|
||||
const toggleZimCollection = (slug: string) => {
|
||||
setSelectedZimCollections((prev) =>
|
||||
prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// const downloadPromises = [
|
||||
// ...selectedMapCollections.map((slug) => api.downloadMapCollection(slug)),
|
||||
// ...selectedZimCollections.map((slug) => api.downloadZimCollection(slug)),
|
||||
// ]
|
||||
|
||||
// await Promise.all(downloadPromises)
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Setup wizard completed! Your selections are being processed.',
|
||||
})
|
||||
|
||||
// Wait a moment then redirect to completion page to show progress
|
||||
setTimeout(() => {
|
||||
router.visit('/easy-setup/complete')
|
||||
}, 2000)
|
||||
} 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 renderStepIndicator = () => {
|
||||
const steps = [
|
||||
{ number: 1, label: 'Apps' },
|
||||
{ number: 2, label: 'Maps' },
|
||||
{ number: 3, label: 'ZIM Files' },
|
||||
{ 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>
|
||||
)
|
||||
}
|
||||
|
||||
const renderStep1 = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">Choose Apps to Install</h2>
|
||||
<p className="text-gray-600">
|
||||
Select the applications you'd like to install. You can always add more later.
|
||||
</p>
|
||||
</div>
|
||||
{availableServices.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600 text-lg">All available apps are already installed!</p>
|
||||
<StyledButton
|
||||
variant="primary"
|
||||
className="mt-4"
|
||||
onClick={() => router.visit('/settings/apps')}
|
||||
>
|
||||
Manage Apps
|
||||
</StyledButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{availableServices.map((service) => {
|
||||
const selectedOrInstalled =
|
||||
selectedServices.includes(service.service_name) ||
|
||||
service.installed ||
|
||||
service.installation_status === 'installing'
|
||||
|
||||
const installedOrInstalling =
|
||||
service.installed || service.installation_status === 'installing'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={service.id}
|
||||
onClick={() =>
|
||||
!installedOrInstalling && toggleServiceSelection(service.service_name)
|
||||
}
|
||||
className={classNames(
|
||||
'p-6 rounded-lg border-2 cursor-pointer transition-all',
|
||||
selectedOrInstalled
|
||||
? 'border-desert-green bg-desert-green bg-opacity-10 shadow-md text-white'
|
||||
: 'border-desert-stone-light bg-white hover:border-desert-green hover:shadow-sm',
|
||||
installedOrInstalling ? 'opacity-50 cursor-not-allowed' : ''
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{service.friendly_name || service.service_name}
|
||||
</h3>
|
||||
<p
|
||||
className={classNames(
|
||||
'text-sm mt-1',
|
||||
selectedOrInstalled ? 'text-white' : 'text-gray-600'
|
||||
)}
|
||||
>
|
||||
{service.description}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'ml-4 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all',
|
||||
selectedOrInstalled
|
||||
? 'border-desert-green bg-desert-green'
|
||||
: 'border-desert-stone'
|
||||
)}
|
||||
>
|
||||
{selectedOrInstalled ? (
|
||||
<IconCheck size={20} className="text-white" />
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded-full bg-transparent" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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 = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">Choose ZIM Files</h2>
|
||||
<p className="text-gray-600">
|
||||
Select ZIM file collections for offline knowledge. You can always download more later.
|
||||
</p>
|
||||
</div>
|
||||
{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 ZIM collections available at this time.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderStep4 = () => {
|
||||
const hasSelections =
|
||||
selectedServices.length > 0 ||
|
||||
selectedMapCollections.length > 0 ||
|
||||
selectedZimCollections.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">
|
||||
Apps to Install ({selectedServices.length})
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{selectedServices.map((serviceName) => {
|
||||
const service = availableServices.find((s) => s.service_name === serviceName)
|
||||
return (
|
||||
<li key={serviceName} className="flex items-center">
|
||||
<IconCheck size={20} className="text-desert-green mr-2" />
|
||||
<span className="text-gray-700">
|
||||
{service?.friendly_name || serviceName}
|
||||
</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>
|
||||
)}
|
||||
|
||||
<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()}
|
||||
<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="ChevronLeftIcon"
|
||||
>
|
||||
Back
|
||||
</StyledButton>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-600">
|
||||
{selectedServices.length} app{selectedServices.length !== 1 && 's'},{' '}
|
||||
{selectedMapCollections.length} map collection
|
||||
{selectedMapCollections.length !== 1 && 's'}, {selectedZimCollections.length} ZIM
|
||||
collection{selectedZimCollections.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="ChevronRightIcon"
|
||||
>
|
||||
Next
|
||||
</StyledButton>
|
||||
) : (
|
||||
<StyledButton
|
||||
onClick={handleFinish}
|
||||
disabled={isProcessing || !isOnline || !anySelectionMade}
|
||||
loading={isProcessing}
|
||||
variant="success"
|
||||
icon="CheckIcon"
|
||||
>
|
||||
Complete Setup
|
||||
</StyledButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,18 @@
|
|||
import { IconHelp, IconMapRoute, IconPlus, IconSettings, IconWifiOff } from '@tabler/icons-react'
|
||||
import { IconBolt, IconHelp, IconMapRoute, IconPlus, IconSettings, IconWifiOff } from '@tabler/icons-react'
|
||||
import { Head } from '@inertiajs/react'
|
||||
import BouncingLogo from '~/components/BouncingLogo'
|
||||
import AppLayout from '~/layouts/AppLayout'
|
||||
import { getServiceLink } from '~/lib/navigation'
|
||||
|
||||
const STATIC_ITEMS = [
|
||||
{
|
||||
label: 'Easy Setup',
|
||||
to: '/easy-setup',
|
||||
target: '',
|
||||
description: "Not sure where to start? Use the setup wizard to quickly configure your N.O.M.A.D.!",
|
||||
icon: <IconBolt size={48} />,
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
label: 'Install Apps',
|
||||
to: '/settings/apps',
|
||||
|
|
@ -66,11 +74,11 @@ export default function Home(props: {
|
|||
<a key={item.label} href={item.to} target={item.target}>
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded border-desert-green border-2 bg-desert-green hover:bg-transparent hover:text-black text-white transition-colors shadow-sm h-48 flex flex-col items-center justify-center cursor-pointer"
|
||||
className="rounded border-desert-green border-2 bg-desert-green hover:bg-transparent hover:text-black text-white transition-colors shadow-sm h-48 flex flex-col items-center justify-center cursor-pointer text-center px-4"
|
||||
>
|
||||
<div className="flex items-center justify-center mb-2">{item.icon}</div>
|
||||
<h3 className="font-bold text-2xl">{item.label}</h3>
|
||||
<p className="text-lg mt-2">{item.description}</p>
|
||||
<p className="xl:text-lg mt-2">{item.description}</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -8,41 +8,23 @@ import { useModals } from '~/context/ModalContext'
|
|||
import StyledModal from '~/components/StyledModal'
|
||||
import api from '~/lib/api'
|
||||
import { useEffect, useState } from 'react'
|
||||
import InstallActivityFeed, { InstallActivityFeedProps } from '~/components/InstallActivityFeed'
|
||||
import { useTransmit } from 'react-adonis-transmit'
|
||||
import InstallActivityFeed from '~/components/InstallActivityFeed'
|
||||
import LoadingSpinner from '~/components/LoadingSpinner'
|
||||
import useErrorNotification from '~/hooks/useErrorNotification'
|
||||
import useInternetStatus from '~/hooks/useInternetStatus'
|
||||
import useServiceInstallationActivity from '~/hooks/useServiceInstallationActivity'
|
||||
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline'
|
||||
import { IconCheck } from '@tabler/icons-react'
|
||||
|
||||
export default function SettingsPage(props: { system: { services: ServiceSlim[] } }) {
|
||||
const { openModal, closeAllModals } = useModals()
|
||||
const { subscribe } = useTransmit()
|
||||
const { showError } = useErrorNotification()
|
||||
const { isOnline } = useInternetStatus()
|
||||
const [installActivity, setInstallActivity] = useState<InstallActivityFeedProps['activity']>([])
|
||||
const installActivity = useServiceInstallationActivity()
|
||||
|
||||
const [isInstalling, setIsInstalling] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribe('service-installation', (data: any) => {
|
||||
setInstallActivity((prev) => [
|
||||
...prev,
|
||||
{
|
||||
service_name: data.service_name ?? 'unknown',
|
||||
type: data.status ?? 'unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: data.message ?? 'No message provided',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (installActivity.length === 0) return
|
||||
if (installActivity.some((activity) => activity.type === 'completed')) {
|
||||
|
|
@ -270,7 +252,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
|||
/>
|
||||
)}
|
||||
{installActivity.length > 0 && (
|
||||
<InstallActivityFeed activity={installActivity} className="mt-8" />
|
||||
<InstallActivityFeed activity={installActivity} className="mt-8" withHeader />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,10 +13,9 @@ import DownloadURLModal from '~/components/DownloadURLModal'
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import useDownloads from '~/hooks/useDownloads'
|
||||
import StyledSectionHeader from '~/components/StyledSectionHeader'
|
||||
import HorizontalBarChart from '~/components/HorizontalBarChart'
|
||||
import { extractFileName } from '~/lib/util'
|
||||
import CuratedCollectionCard from '~/components/CuratedCollectionCard'
|
||||
import { CuratedCollectionWithStatus } from '../../../types/downloads'
|
||||
import ActiveDownloads from '~/components/ActiveDownloads'
|
||||
|
||||
const CURATED_COLLECTIONS_KEY = 'curated-map-collections'
|
||||
|
||||
|
|
@ -34,7 +33,7 @@ export default function MapsManager(props: {
|
|||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const { data: downloads, invalidate: invalidateDownloads } = useDownloads({
|
||||
const { invalidate: invalidateDownloads } = useDownloads({
|
||||
filetype: 'map',
|
||||
enabled: true,
|
||||
})
|
||||
|
|
@ -242,28 +241,7 @@ export default function MapsManager(props: {
|
|||
]}
|
||||
data={props.maps.regionFiles || []}
|
||||
/>
|
||||
<StyledSectionHeader title="Active Downloads" className="mt-12 mb-4" />
|
||||
<div className="space-y-4">
|
||||
{downloads && downloads.length > 0 ? (
|
||||
downloads.map((download) => (
|
||||
<div className="bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
|
||||
<HorizontalBarChart
|
||||
items={[
|
||||
{
|
||||
label: extractFileName(download.filepath) || download.url,
|
||||
value: download.progress,
|
||||
total: '100%',
|
||||
used: `${download.progress}%`,
|
||||
type: download.filetype,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500">No active downloads</p>
|
||||
)}
|
||||
</div>
|
||||
<ActiveDownloads filetype="map" withHeader />
|
||||
</main>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import StyledTable from '~/components/StyledTable'
|
|||
import SettingsLayout from '~/layouts/SettingsLayout'
|
||||
import { Head } from '@inertiajs/react'
|
||||
import { ListRemoteZimFilesResponse, RemoteZimFileEntry } from '../../../../types/zim'
|
||||
import { extractFileName, formatBytes } from '~/lib/util'
|
||||
import { formatBytes } from '~/lib/util'
|
||||
import StyledButton from '~/components/StyledButton'
|
||||
import { useModals } from '~/context/ModalContext'
|
||||
import StyledModal from '~/components/StyledModal'
|
||||
|
|
@ -27,7 +27,7 @@ import CuratedCollectionCard from '~/components/CuratedCollectionCard'
|
|||
import StyledSectionHeader from '~/components/StyledSectionHeader'
|
||||
import { CuratedCollectionWithStatus } from '../../../../types/downloads'
|
||||
import useDownloads from '~/hooks/useDownloads'
|
||||
import HorizontalBarChart from '~/components/HorizontalBarChart'
|
||||
import ActiveDownloads from '~/components/ActiveDownloads'
|
||||
|
||||
const CURATED_COLLECTIONS_KEY = 'curated-zim-collections'
|
||||
|
||||
|
|
@ -313,28 +313,7 @@ export default function ZimRemoteExplorer() {
|
|||
compact
|
||||
rowLines
|
||||
/>
|
||||
<StyledSectionHeader title="Active Downloads" className="mt-12 mb-4" />
|
||||
<div className="space-y-4">
|
||||
{downloads && downloads.length > 0 ? (
|
||||
downloads.map((download) => (
|
||||
<div className="bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
|
||||
<HorizontalBarChart
|
||||
items={[
|
||||
{
|
||||
label: extractFileName(download.filepath) || download.url,
|
||||
value: download.progress,
|
||||
total: '100%',
|
||||
used: `${download.progress}%`,
|
||||
type: download.filetype,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500">No active downloads</p>
|
||||
)}
|
||||
</div>
|
||||
<ActiveDownloads filetype="zim" withHeader />
|
||||
</main>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
*/
|
||||
import DocsController from '#controllers/docs_controller'
|
||||
import DownloadsController from '#controllers/downloads_controller'
|
||||
import EasySetupController from '#controllers/easy_setup_controller'
|
||||
import HomeController from '#controllers/home_controller'
|
||||
import MapsController from '#controllers/maps_controller'
|
||||
import SettingsController from '#controllers/settings_controller'
|
||||
|
|
@ -22,6 +23,9 @@ router.get('/', [HomeController, 'index'])
|
|||
router.get('/home', [HomeController, 'home'])
|
||||
router.on('/about').renderInertia('about')
|
||||
|
||||
router.get('/easy-setup', [EasySetupController, 'index'])
|
||||
router.get('/easy-setup/complete', [EasySetupController, 'complete'])
|
||||
|
||||
router
|
||||
.group(() => {
|
||||
router.get('/system', [SettingsController, 'system'])
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import Service from "#models/service";
|
||||
import Service from '#models/service'
|
||||
|
||||
|
||||
export type ServiceStatus = 'unknown' | 'running' | 'stopped';
|
||||
export type ServiceSlim = Pick<Service, 'id' | 'service_name' | 'installed' | 'ui_location' | 'friendly_name' | 'description'> & { status?: ServiceStatus };
|
||||
export type ServiceStatus = 'unknown' | 'running' | 'stopped'
|
||||
export type ServiceSlim = Pick<
|
||||
Service,
|
||||
| 'id'
|
||||
| 'service_name'
|
||||
| 'installed'
|
||||
| 'installation_status'
|
||||
| 'ui_location'
|
||||
| 'friendly_name'
|
||||
| 'description'
|
||||
> & { status?: ServiceStatus }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user