diff --git a/admin/inertia/components/StorageProjectionBar.tsx b/admin/inertia/components/StorageProjectionBar.tsx
new file mode 100644
index 0000000..36bb214
--- /dev/null
+++ b/admin/inertia/components/StorageProjectionBar.tsx
@@ -0,0 +1,122 @@
+import classNames from '~/lib/classNames'
+import { formatBytes } from '~/lib/util'
+import { IconAlertTriangle, IconServer } from '@tabler/icons-react'
+
+interface StorageProjectionBarProps {
+ totalSize: number // Total disk size in bytes
+ currentUsed: number // Currently used space in bytes
+ projectedAddition: number // Additional space that will be used in bytes
+}
+
+export default function StorageProjectionBar({
+ totalSize,
+ currentUsed,
+ projectedAddition,
+}: StorageProjectionBarProps) {
+ const projectedTotal = currentUsed + projectedAddition
+ const currentPercent = (currentUsed / totalSize) * 100
+ const projectedPercent = (projectedAddition / totalSize) * 100
+ const projectedTotalPercent = (projectedTotal / totalSize) * 100
+ const remainingAfter = totalSize - projectedTotal
+ const willExceed = projectedTotal > totalSize
+
+ // Determine warning level based on projected total
+ const getProjectedColor = () => {
+ if (willExceed) return 'bg-desert-red'
+ if (projectedTotalPercent >= 90) return 'bg-desert-orange'
+ if (projectedTotalPercent >= 75) return 'bg-desert-tan'
+ return 'bg-desert-olive'
+ }
+
+ const getProjectedGlow = () => {
+ if (willExceed) return 'shadow-desert-red/50'
+ if (projectedTotalPercent >= 90) return 'shadow-desert-orange/50'
+ if (projectedTotalPercent >= 75) return 'shadow-desert-tan/50'
+ return 'shadow-desert-olive/50'
+ }
+
+ return (
+
+
+
+
+ Storage
+
+
+ {formatBytes(projectedTotal, 1)} / {formatBytes(totalSize, 1)}
+ {projectedAddition > 0 && (
+
+ (+{formatBytes(projectedAddition, 1)} selected)
+
+ )}
+
+
+
+ {/* Progress bar */}
+
+
+ {/* Current usage - darker/subdued */}
+
+ {/* Projected addition - highlighted */}
+ {projectedAddition > 0 && (
+
+ )}
+
+
+ {/* Percentage label */}
+
15
+ ? 'left-3 text-desert-white drop-shadow-md'
+ : 'right-3 text-desert-green'
+ )}
+ >
+ {Math.round(projectedTotalPercent)}%
+
+
+
+ {/* Legend and warnings */}
+
+
+
+
+
Current ({formatBytes(currentUsed, 1)})
+
+ {projectedAddition > 0 && (
+
+
+
+ Selected (+{formatBytes(projectedAddition, 1)})
+
+
+ )}
+
+
+ {willExceed ? (
+
+
+ Exceeds available space by {formatBytes(projectedTotal - totalSize, 1)}
+
+ ) : (
+
+ {formatBytes(remainingAfter, 1)} will remain free
+
+ )}
+
+
+ )
+}
diff --git a/admin/inertia/pages/easy-setup/index.tsx b/admin/inertia/pages/easy-setup/index.tsx
index 31c5d0d..f775398 100644
--- a/admin/inertia/pages/easy-setup/index.tsx
+++ b/admin/inertia/pages/easy-setup/index.tsx
@@ -1,6 +1,6 @@
import { Head, router } from '@inertiajs/react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { useEffect, useState } from 'react'
+import { useEffect, useState, useMemo } from 'react'
import AppLayout from '~/layouts/AppLayout'
import StyledButton from '~/components/StyledButton'
import api from '~/lib/api'
@@ -10,9 +10,11 @@ import CategoryCard from '~/components/CategoryCard'
import TierSelectionModal from '~/components/TierSelectionModal'
import LoadingSpinner from '~/components/LoadingSpinner'
import Alert from '~/components/Alert'
+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'
@@ -51,6 +53,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
const { addNotification } = useNotifications()
const { isOnline } = useInternetStatus()
const queryClient = useQueryClient()
+ const { data: systemInfo } = useSystemInfo({ enabled: true })
const anySelectionMade =
selectedServices.length > 0 ||
@@ -144,6 +147,47 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
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 * 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)
+ }
+ })
+ }
+
+ return totalBytes
+ }, [selectedTiers, selectedMapCollections, selectedZimCollections, categories, mapCollections, zimCollections])
+
+ // Get primary disk/filesystem info for storage projection
+ // Try disk array first (Linux/production), fall back to fsSize (Windows/dev)
+ const primaryDisk = systemInfo?.disk?.[0]
+ 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
@@ -657,6 +701,15 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim