fix(UI): unifies Supply Depot icon and improves loading UX (#1022)

This commit is contained in:
Jake Turner 2026-06-19 21:42:44 -07:00 committed by jakeaturner
parent 5181637926
commit 02c9f72bf0
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
4 changed files with 65 additions and 33 deletions

View File

@ -8,7 +8,7 @@ interface LoadingSpinnerProps {
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
text, text,
fullscreen = true, fullscreen = false,
iconOnly = false, iconOnly = false,
light = false, light = false,
className, className,
@ -29,9 +29,12 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
} }
return ( return (
<div className={className}> <div
<div className="ui active inverted dimmer"> className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm ${className || ''}`}
<div className="ui text loader">{!iconOnly && <span>{text || 'Loading'}</span>}</div> >
<div className="flex flex-col items-center justify-center">
<div className="w-10 h-10 border-[3px] border-white border-t-transparent rounded-full animate-spin" />
{!iconOnly && <div className="text-white mt-3 font-medium">{text || 'Loading'}</div>}
</div> </div>
</div> </div>
) )

View File

@ -1,5 +1,6 @@
import { import {
IconArrowBigUpLines, IconArrowBigUpLines,
IconBox,
IconChartBar, IconChartBar,
IconDashboard, IconDashboard,
IconFolder, IconFolder,
@ -7,7 +8,6 @@ import {
IconHeart, IconHeart,
IconMapRoute, IconMapRoute,
IconSettings, IconSettings,
IconTerminal2,
IconWand, IconWand,
IconZoom IconZoom
} from '@tabler/icons-react' } from '@tabler/icons-react'
@ -23,7 +23,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
const navigation = [ const navigation = [
...(aiAssistantInstallStatus.isInstalled ? [{ name: aiAssistantName, href: '/settings/models', icon: IconWand, current: false }] : []), ...(aiAssistantInstallStatus.isInstalled ? [{ name: aiAssistantName, href: '/settings/models', icon: IconWand, current: false }] : []),
{ name: 'Supply Depot', href: '/supply-depot', icon: IconTerminal2, current: false }, { name: 'Supply Depot', href: '/supply-depot', icon: IconBox, current: false },
{ name: 'Benchmark', href: '/settings/benchmark', icon: IconChartBar, current: false }, { name: 'Benchmark', href: '/settings/benchmark', icon: IconChartBar, current: false },
{ name: 'Content Explorer', href: '/settings/zim/remote-explorer', icon: IconZoom, current: false }, { name: 'Content Explorer', href: '/settings/zim/remote-explorer', icon: IconZoom, current: false },
{ name: 'Content Manager', href: '/settings/zim', icon: IconFolder, current: false }, { name: 'Content Manager', href: '/settings/zim', icon: IconFolder, current: false },

View File

@ -1,8 +1,8 @@
import { import {
IconBolt, IconBolt,
IconBox,
IconHelp, IconHelp,
IconMapRoute, IconMapRoute,
IconPlus,
IconSettings, IconSettings,
IconWifiOff, IconWifiOff,
} from '@tabler/icons-react' } from '@tabler/icons-react'
@ -46,7 +46,7 @@ const SYSTEM_ITEMS = [
to: '/supply-depot', to: '/supply-depot',
target: '', target: '',
description: 'Browse and install curated apps, or add your own Docker container', description: 'Browse and install curated apps, or add your own Docker container',
icon: <IconPlus size={48} />, icon: <IconBox size={48} />,
installed: true, installed: true,
displayOrder: 51, displayOrder: 51,
poweredBy: null, poweredBy: null,

View File

@ -201,10 +201,6 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
(s) => s.service_name === SERVICE_NAMES.KOLIBRI_GEN2 && s.installed (s) => s.service_name === SERVICE_NAMES.KOLIBRI_GEN2 && s.installed
) )
useEffect(() => {
console.log("Education Gen 2 installed:", educationGen2Installed)
}, [educationGen2Installed])
// ── Actions ─────────────────────────────────────────────────────────────── // ── Actions ───────────────────────────────────────────────────────────────
async function handleInstall(service: ServiceSlim) { async function handleInstall(service: ServiceSlim) {
const hasWarnings = const hasWarnings =
@ -212,48 +208,61 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
if (hasWarnings && !forceInstall) return if (hasWarnings && !forceInstall) return
// Keep the modal open with a spinning confirm button while the request is in
// flight; close it once the install job is dispatched. Progress then streams
// via the InstallActivityFeed broadcast, so we drop the loading flag here.
setLoading(true) setLoading(true)
setModal(null)
const result = await api.installService(service.service_name) const result = await api.installService(service.service_name)
setModal(null)
setLoading(false) setLoading(false)
if (!result?.success) showError(result?.message || 'Failed to start installation.') if (!result?.success) showError(result?.message || 'Failed to start installation.')
} }
async function handleAffect(service: ServiceSlim, action: 'start' | 'stop' | 'restart') { async function handleAffect(service: ServiceSlim, action: 'start' | 'stop' | 'restart') {
setModal(null)
setLoading(true) setLoading(true)
const result = await api.affectService(service.service_name, action) const result = await api.affectService(service.service_name, action)
setLoading(false) setModal(null)
if (!result?.success) showError(result?.message || `Failed to ${action} service.`) if (!result?.success) {
else setTimeout(() => window.location.reload(), 1500) setLoading(false)
showError(result?.message || `Failed to ${action} service.`)
} else {
// Keep loading=true so the overlay covers the page until it reloads.
setTimeout(() => window.location.reload(), 1500)
}
} }
async function handleForceReinstall(service: ServiceSlim) { async function handleForceReinstall(service: ServiceSlim) {
setModal(null)
setLoading(true) setLoading(true)
const result = await api.forceReinstallService(service.service_name) const result = await api.forceReinstallService(service.service_name)
setModal(null)
setLoading(false) setLoading(false)
if (!result?.success) showError(result?.message || 'Failed to start reinstall.') if (!result?.success) showError(result?.message || 'Failed to start reinstall.')
} }
async function handleDelete(service: ServiceSlim) { async function handleDelete(service: ServiceSlim) {
setModal(null)
setLoading(true) setLoading(true)
const result = await api.deleteCustomApp(service.service_name, removeImage) const result = await api.deleteCustomApp(service.service_name, removeImage)
setRemoveImage(false) setRemoveImage(false)
setLoading(false) setModal(null)
if (!result?.success) showError(result?.message || 'Failed to delete app.') if (!result?.success) {
else setTimeout(() => window.location.reload(), 1000) setLoading(false)
showError(result?.message || 'Failed to delete app.')
} else {
setTimeout(() => window.location.reload(), 1000)
}
} }
async function handleUninstall(service: ServiceSlim) { async function handleUninstall(service: ServiceSlim) {
setModal(null)
setLoading(true) setLoading(true)
const result = await api.uninstallService(service.service_name, removeImage) const result = await api.uninstallService(service.service_name, removeImage)
setRemoveImage(false) setRemoveImage(false)
setLoading(false) setModal(null)
if (!result?.success) showError(result?.message || 'Failed to uninstall app.') if (!result?.success) {
else setTimeout(() => window.location.reload(), 1000) setLoading(false)
showError(result?.message || 'Failed to uninstall app.')
} else {
setTimeout(() => window.location.reload(), 1000)
}
} }
async function handleUpdate(service: ServiceSlim) { async function handleUpdate(service: ServiceSlim) {
@ -351,7 +360,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
<AppLayout> <AppLayout>
<Head title="Supply Depot" /> <Head title="Supply Depot" />
{loading && <LoadingSpinner fullscreen text="Working..." />} {loading && !modal && <LoadingSpinner fullscreen text="Working..." />}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* ── Hero / controls panel ─────────────────────────────────────────── */} {/* ── Hero / controls panel ─────────────────────────────────────────── */}
@ -532,7 +541,10 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
<StyledModal <StyledModal
title={`Install ${modal.service.friendly_name ?? modal.service.service_name}`} title={`Install ${modal.service.friendly_name ?? modal.service.service_name}`}
open open
onCancel={() => setModal(null)} onCancel={() => {
if (loading) return
setModal(null)
}}
onConfirm={() => handleInstall(modal.service)} onConfirm={() => handleInstall(modal.service)}
confirmText="Install" confirmText="Install"
confirmIcon="IconDownload" confirmIcon="IconDownload"
@ -590,7 +602,10 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
<StyledModal <StyledModal
title={`Start ${modal.service.friendly_name ?? modal.service.service_name}`} title={`Start ${modal.service.friendly_name ?? modal.service.service_name}`}
open open
onCancel={() => setModal(null)} onCancel={() => {
if (loading) return
setModal(null)
}}
onConfirm={() => handleAffect(modal.service, 'start')} onConfirm={() => handleAffect(modal.service, 'start')}
confirmText="Start" confirmText="Start"
confirmIcon="IconPlayerPlay" confirmIcon="IconPlayerPlay"
@ -606,7 +621,10 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
<StyledModal <StyledModal
title={`Stop ${modal.service.friendly_name ?? modal.service.service_name}`} title={`Stop ${modal.service.friendly_name ?? modal.service.service_name}`}
open open
onCancel={() => setModal(null)} onCancel={() => {
if (loading) return
setModal(null)
}}
onConfirm={() => handleAffect(modal.service, 'stop')} onConfirm={() => handleAffect(modal.service, 'stop')}
confirmText="Stop" confirmText="Stop"
confirmIcon="IconPlayerStop" confirmIcon="IconPlayerStop"
@ -622,7 +640,10 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
<StyledModal <StyledModal
title={`Restart ${modal.service.friendly_name ?? modal.service.service_name}`} title={`Restart ${modal.service.friendly_name ?? modal.service.service_name}`}
open open
onCancel={() => setModal(null)} onCancel={() => {
if (loading) return
setModal(null)
}}
onConfirm={() => handleAffect(modal.service, 'restart')} onConfirm={() => handleAffect(modal.service, 'restart')}
confirmText="Restart" confirmText="Restart"
confirmIcon="IconRefresh" confirmIcon="IconRefresh"
@ -638,7 +659,10 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
<StyledModal <StyledModal
title={`Force Reinstall ${modal.service.friendly_name ?? modal.service.service_name}`} title={`Force Reinstall ${modal.service.friendly_name ?? modal.service.service_name}`}
open open
onCancel={() => setModal(null)} onCancel={() => {
if (loading) return
setModal(null)
}}
onConfirm={() => handleForceReinstall(modal.service)} onConfirm={() => handleForceReinstall(modal.service)}
confirmText="Wipe & Reinstall" confirmText="Wipe & Reinstall"
confirmIcon="IconRefresh" confirmIcon="IconRefresh"
@ -659,6 +683,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
title={`Delete ${modal.service.friendly_name ?? modal.service.service_name}`} title={`Delete ${modal.service.friendly_name ?? modal.service.service_name}`}
open open
onCancel={() => { onCancel={() => {
if (loading) return
setRemoveImage(false) setRemoveImage(false)
setModal(null) setModal(null)
}} }}
@ -691,6 +716,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
title={`Uninstall ${modal.service.friendly_name ?? modal.service.service_name}`} title={`Uninstall ${modal.service.friendly_name ?? modal.service.service_name}`}
open open
onCancel={() => { onCancel={() => {
if (loading) return
setRemoveImage(false) setRemoveImage(false)
setModal(null) setModal(null)
}} }}
@ -743,7 +769,10 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
record={modal.service} record={modal.service}
currentTag={extractTag(modal.service.container_image)} currentTag={extractTag(modal.service.container_image)}
latestVersion={modal.service.available_update_version!} latestVersion={modal.service.available_update_version!}
onCancel={() => setModal(null)} onCancel={() => {
if (loading) return
setModal(null)
}}
onUpdate={(targetVersion) => { onUpdate={(targetVersion) => {
const service = modal.service const service = modal.service
setModal(null) setModal(null)