Skip to content

Commit 7bf3f25

Browse files
chriscrosstalkclaude
authored andcommitted
feat: Add storage projection bar to easy setup wizard
Adds a dynamic storage projection bar that shows users how their selections will impact disk space: - Displays current disk usage and projected usage after installation - Updates in real-time as users select maps, ZIM collections, and tiers - Color-coded warnings (green→tan→orange→red) based on projected usage - Shows "exceeds available space" warning if selections exceed capacity - Works on both Linux (disk array) and Windows (fsSize array) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c03f2ae commit 7bf3f25

File tree

2 files changed

+176
-1
lines changed

2 files changed

+176
-1
lines changed
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import classNames from '~/lib/classNames'
2+
import { formatBytes } from '~/lib/util'
3+
import { IconAlertTriangle, IconServer } from '@tabler/icons-react'
4+
5+
interface StorageProjectionBarProps {
6+
totalSize: number // Total disk size in bytes
7+
currentUsed: number // Currently used space in bytes
8+
projectedAddition: number // Additional space that will be used in bytes
9+
}
10+
11+
export default function StorageProjectionBar({
12+
totalSize,
13+
currentUsed,
14+
projectedAddition,
15+
}: StorageProjectionBarProps) {
16+
const projectedTotal = currentUsed + projectedAddition
17+
const currentPercent = (currentUsed / totalSize) * 100
18+
const projectedPercent = (projectedAddition / totalSize) * 100
19+
const projectedTotalPercent = (projectedTotal / totalSize) * 100
20+
const remainingAfter = totalSize - projectedTotal
21+
const willExceed = projectedTotal > totalSize
22+
23+
// Determine warning level based on projected total
24+
const getProjectedColor = () => {
25+
if (willExceed) return 'bg-desert-red'
26+
if (projectedTotalPercent >= 90) return 'bg-desert-orange'
27+
if (projectedTotalPercent >= 75) return 'bg-desert-tan'
28+
return 'bg-desert-olive'
29+
}
30+
31+
const getProjectedGlow = () => {
32+
if (willExceed) return 'shadow-desert-red/50'
33+
if (projectedTotalPercent >= 90) return 'shadow-desert-orange/50'
34+
if (projectedTotalPercent >= 75) return 'shadow-desert-tan/50'
35+
return 'shadow-desert-olive/50'
36+
}
37+
38+
return (
39+
<div className="bg-desert-stone-lighter/30 rounded-lg p-4 border border-desert-stone-light">
40+
<div className="flex items-center justify-between mb-3">
41+
<div className="flex items-center gap-2">
42+
<IconServer size={20} className="text-desert-green" />
43+
<span className="font-semibold text-desert-green">Storage</span>
44+
</div>
45+
<div className="text-sm text-desert-stone-dark font-mono">
46+
{formatBytes(projectedTotal, 1)} / {formatBytes(totalSize, 1)}
47+
{projectedAddition > 0 && (
48+
<span className="text-desert-stone ml-2">
49+
(+{formatBytes(projectedAddition, 1)} selected)
50+
</span>
51+
)}
52+
</div>
53+
</div>
54+
55+
{/* Progress bar */}
56+
<div className="relative">
57+
<div className="h-8 bg-desert-green-lighter/20 rounded-lg border border-desert-stone-light overflow-hidden">
58+
{/* Current usage - darker/subdued */}
59+
<div
60+
className="absolute h-full bg-desert-stone transition-all duration-300"
61+
style={{ width: `${Math.min(currentPercent, 100)}%` }}
62+
/>
63+
{/* Projected addition - highlighted */}
64+
{projectedAddition > 0 && (
65+
<div
66+
className={classNames(
67+
'absolute h-full transition-all duration-300 shadow-lg',
68+
getProjectedColor(),
69+
getProjectedGlow()
70+
)}
71+
style={{
72+
left: `${Math.min(currentPercent, 100)}%`,
73+
width: `${Math.min(projectedPercent, 100 - currentPercent)}%`,
74+
}}
75+
/>
76+
)}
77+
</div>
78+
79+
{/* Percentage label */}
80+
<div
81+
className={classNames(
82+
'absolute top-1/2 -translate-y-1/2 font-bold text-sm',
83+
projectedTotalPercent > 15
84+
? 'left-3 text-desert-white drop-shadow-md'
85+
: 'right-3 text-desert-green'
86+
)}
87+
>
88+
{Math.round(projectedTotalPercent)}%
89+
</div>
90+
</div>
91+
92+
{/* Legend and warnings */}
93+
<div className="flex items-center justify-between mt-3">
94+
<div className="flex items-center gap-4 text-xs">
95+
<div className="flex items-center gap-1.5">
96+
<div className="w-3 h-3 rounded bg-desert-stone" />
97+
<span className="text-desert-stone-dark">Current ({formatBytes(currentUsed, 1)})</span>
98+
</div>
99+
{projectedAddition > 0 && (
100+
<div className="flex items-center gap-1.5">
101+
<div className={classNames('w-3 h-3 rounded', getProjectedColor())} />
102+
<span className="text-desert-stone-dark">
103+
Selected (+{formatBytes(projectedAddition, 1)})
104+
</span>
105+
</div>
106+
)}
107+
</div>
108+
109+
{willExceed ? (
110+
<div className="flex items-center gap-1.5 text-desert-red text-xs font-medium">
111+
<IconAlertTriangle size={14} />
112+
<span>Exceeds available space by {formatBytes(projectedTotal - totalSize, 1)}</span>
113+
</div>
114+
) : (
115+
<div className="text-xs text-desert-stone">
116+
{formatBytes(remainingAfter, 1)} will remain free
117+
</div>
118+
)}
119+
</div>
120+
</div>
121+
)
122+
}

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Head, router } from '@inertiajs/react'
22
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
3-
import { useEffect, useState } from 'react'
3+
import { useEffect, useState, useMemo } from 'react'
44
import AppLayout from '~/layouts/AppLayout'
55
import StyledButton from '~/components/StyledButton'
66
import api from '~/lib/api'
@@ -10,9 +10,11 @@ import CategoryCard from '~/components/CategoryCard'
1010
import TierSelectionModal from '~/components/TierSelectionModal'
1111
import LoadingSpinner from '~/components/LoadingSpinner'
1212
import Alert from '~/components/Alert'
13+
import StorageProjectionBar from '~/components/StorageProjectionBar'
1314
import { IconCheck } from '@tabler/icons-react'
1415
import { useNotifications } from '~/context/NotificationContext'
1516
import useInternetStatus from '~/hooks/useInternetStatus'
17+
import { useSystemInfo } from '~/hooks/useSystemInfo'
1618
import classNames from 'classnames'
1719
import { CuratedCategory, CategoryTier, CategoryResource } from '../../../types/downloads'
1820

@@ -51,6 +53,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
5153
const { addNotification } = useNotifications()
5254
const { isOnline } = useInternetStatus()
5355
const queryClient = useQueryClient()
56+
const { data: systemInfo } = useSystemInfo({ enabled: true })
5457

5558
const anySelectionMade =
5659
selectedServices.length > 0 ||
@@ -144,6 +147,47 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
144147
return resources
145148
}
146149

150+
// Calculate total projected storage from all selections
151+
const projectedStorageBytes = useMemo(() => {
152+
let totalBytes = 0
153+
154+
// Add tier resources
155+
const tierResources = getSelectedTierResources()
156+
totalBytes += tierResources.reduce((sum, r) => sum + r.size_mb * 1024 * 1024, 0)
157+
158+
// Add map collections
159+
if (mapCollections) {
160+
selectedMapCollections.forEach((slug) => {
161+
const collection = mapCollections.find((c) => c.slug === slug)
162+
if (collection) {
163+
totalBytes += collection.resources.reduce((sum, r) => sum + r.size_mb * 1024 * 1024, 0)
164+
}
165+
})
166+
}
167+
168+
// Add ZIM collections
169+
if (zimCollections) {
170+
selectedZimCollections.forEach((slug) => {
171+
const collection = zimCollections.find((c) => c.slug === slug)
172+
if (collection) {
173+
totalBytes += collection.resources.reduce((sum, r) => sum + r.size_mb * 1024 * 1024, 0)
174+
}
175+
})
176+
}
177+
178+
return totalBytes
179+
}, [selectedTiers, selectedMapCollections, selectedZimCollections, categories, mapCollections, zimCollections])
180+
181+
// Get primary disk/filesystem info for storage projection
182+
// Try disk array first (Linux/production), fall back to fsSize (Windows/dev)
183+
const primaryDisk = systemInfo?.disk?.[0]
184+
const primaryFs = systemInfo?.fsSize?.[0]
185+
const storageInfo = primaryDisk
186+
? { totalSize: primaryDisk.totalSize, totalUsed: primaryDisk.totalUsed }
187+
: primaryFs
188+
? { totalSize: primaryFs.size, totalUsed: primaryFs.used }
189+
: null
190+
147191
const canProceedToNextStep = () => {
148192
if (!isOnline) return false // Must be online to proceed
149193
if (currentStep === 1) return true // Can skip app installation
@@ -657,6 +701,15 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
657701
<div className="max-w-7xl mx-auto px-4 py-8">
658702
<div className="bg-white rounded-md shadow-md">
659703
{renderStepIndicator()}
704+
{storageInfo && (
705+
<div className="px-6 pt-4">
706+
<StorageProjectionBar
707+
totalSize={storageInfo.totalSize}
708+
currentUsed={storageInfo.totalUsed}
709+
projectedAddition={projectedStorageBytes}
710+
/>
711+
</div>
712+
)}
660713
<div className="p-6 min-h-fit">
661714
{currentStep === 1 && renderStep1()}
662715
{currentStep === 2 && renderStep2()}

0 commit comments

Comments
 (0)