improve UI/UX

This commit is contained in:
Boof2015
2026-03-31 16:19:14 -04:00
parent d76abe38a8
commit afc26737cf
16 changed files with 558 additions and 158 deletions
+61
View File
@@ -0,0 +1,61 @@
import { useState, type JSX } from 'react'
import { useUiStore, type UiBannerAction } from '../stores/uiStore'
async function handleBannerAction(
action: UiBannerAction,
bannerId: number,
dismissBanner: (bannerId?: number) => void,
setPendingAction: (label: string | null) => void,
): Promise<void> {
setPendingAction(action.label)
try {
await action.onSelect?.()
} finally {
setPendingAction(null)
if (action.dismissOnSelect ?? true) {
dismissBanner(bannerId)
}
}
}
export default function AppBanner(): JSX.Element | null {
const banner = useUiStore((state) => state.banner)
const dismissBanner = useUiStore((state) => state.dismissBanner)
const [pendingAction, setPendingAction] = useState<string | null>(null)
if (!banner) {
return null
}
return (
<div className="app-banner-layer" aria-live="polite">
<div className={`app-banner app-banner--${banner.tone}`.trim()} role="status">
<div className="app-banner__message">{banner.message}</div>
<div className="app-banner__actions">
{banner.actions.map((action) => (
<button
key={`${banner.id}:${action.label}`}
type="button"
className="app-banner__action"
disabled={pendingAction !== null}
onClick={() => {
void handleBannerAction(action, banner.id, dismissBanner, setPendingAction)
}}
>
{pendingAction === action.label ? 'Working...' : action.label}
</button>
))}
<button
type="button"
className="app-banner__dismiss"
onClick={() => dismissBanner(banner.id)}
aria-label="Dismiss notification"
title="Dismiss"
>
Dismiss
</button>
</div>
</div>
</div>
)
}
+41 -5
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type CSSProperties, type JSX } from 'reac
import type { ScopeSettings } from '../../types/settings'
import type { ResolvedAstraTheme } from '../../types/theme'
import { useAstraStore } from '../stores/astraStore'
import { useUiStore } from '../stores/uiStore'
import { formatAstraTime, getAstraPlaybackProgress } from '../utils/astra'
interface AstraScopeModuleProps {
@@ -9,6 +10,12 @@ interface AstraScopeModuleProps {
settings: ScopeSettings['astra']
}
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message
? error.message
: fallback
}
function hasVisibleFields(settings: ScopeSettings['astra']): boolean {
return settings.showCoverArt
|| settings.showTitle
@@ -53,6 +60,9 @@ export default function AstraScopeModule({
const integrationState = useAstraStore((s) => s.integrationState)
const isSendingControl = useAstraStore((s) => s.isSendingControl)
const sendControl = useAstraStore((s) => s.sendControl)
const saveConfig = useAstraStore((s) => s.saveConfig)
const setSettingsOpen = useUiStore((s) => s.setSettingsOpen)
const showBanner = useUiStore((s) => s.showBanner)
const [nowMs, setNowMs] = useState(() => Date.now())
useEffect(() => {
@@ -212,11 +222,37 @@ export default function AstraScopeModule({
)}
{errorMessage && (
<div
className="astra-scope__status is-error"
>
{errorMessage}
</div>
<>
<div
className="astra-scope__status is-error"
>
{errorMessage}
</div>
<div className="astra-scope__status-actions">
<button
type="button"
className="astra-scope__control"
onClick={() => {
void saveConfig(integrationState.config).catch((error) => {
showBanner({
tone: 'error',
message: getErrorMessage(error, 'Could not reconnect to Astra.'),
actions: [],
})
})
}}
>
Retry
</button>
<button
type="button"
className="astra-scope__control"
onClick={() => setSettingsOpen(true)}
>
Open Settings
</button>
</div>
</>
)}
</div>
</div>
+135 -23
View File
@@ -4,6 +4,7 @@ import { useAudioStore } from '../stores/audioStore'
import { usePerformanceStore } from '../stores/performanceStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore } from '../stores/themeStore'
import { useUiStore } from '../stores/uiStore'
import { getHorizontalWheelScrollResult } from '../utils/horizontalWheelScroll'
import type { ScopeKind } from '../../types/scope'
import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance'
@@ -36,6 +37,14 @@ const FRAME_TARGET_LABELS: Record<VisualizerFrameTarget, string> = {
'display-sync': 'Sync',
}
const DEFAULT_INPUT_DEVICE_ID = '__default_input__'
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message
? error.message
: fallback
}
export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): JSX.Element {
const rootRef = useRef<HTMLDivElement | null>(null)
const [astraBaseUrlInput, setAstraBaseUrlInput] = useState('')
@@ -66,7 +75,9 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
isCapturing,
captureStatus,
captureError,
captureNotice,
inputGainDb,
clearCaptureNotice,
refreshSystemSources,
refreshDevices,
refreshBackendSupport,
@@ -75,6 +86,8 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
startCapture,
setInputGain,
} = useAudioStore()
const showBanner = useUiStore((s) => s.showBanner)
const setSettingsOpen = useUiStore((s) => s.setSettingsOpen)
useEffect(() => {
void refreshBackendSupport()
@@ -124,20 +137,19 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
if (value.startsWith('device:')) {
const deviceId = value.slice('device:'.length)
await selectDevice(deviceId)
await selectDevice(deviceId === DEFAULT_INPUT_DEVICE_ID ? null : deviceId)
await startCapture()
}
}
const selectedSourceValue = captureMode === 'system'
? `system:${selectedSystemSourceId ?? systemSources[0]?.id ?? '__default_system_output__'}`
: `device:${selectedDeviceId ?? ''}`
const visibleSystemSources = systemSources.length
? systemSources
: [{ id: '__default_system_output__', label: 'System Output', kind: 'system', isDefault: true }]
: [{ id: '__default_system_output__', label: 'Default Output', kind: 'system', isDefault: true }]
const defaultSystemSourceId = visibleSystemSources[0]?.id ?? '__default_system_output__'
const showInputDevices = devices.length > 0
const selectedSourceValue = captureMode === 'system'
? `system:${selectedSystemSourceId ?? defaultSystemSourceId}`
: `device:${selectedDeviceId ?? DEFAULT_INPUT_DEVICE_ID}`
const indicatorLabel = isCapturing
? 'Capturing'
@@ -161,7 +173,46 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
baseUrl: astraBaseUrlInput,
token: astraTokenInput,
}
await saveAstraConfig(nextConfig)
try {
await saveAstraConfig(nextConfig)
} catch (error) {
showBanner({
tone: 'error',
message: getErrorMessage(error, 'Could not save the Astra settings.'),
actions: [],
})
}
}
const handleRetryCapture = async (): Promise<void> => {
clearCaptureNotice()
await startCapture()
}
const handleUseDefaultSource = async (): Promise<void> => {
clearCaptureNotice()
if (captureMode === 'system') {
await selectSystemSource(defaultSystemSourceId)
} else {
await selectDevice(null)
}
await startCapture()
}
const handleRetryAstra = async (): Promise<void> => {
await handleSaveAstraConfig()
}
const handleShowThemesFolder = async (): Promise<void> => {
try {
await showThemesFolder()
} catch (error) {
showBanner({
tone: 'error',
message: getErrorMessage(error, 'Could not open the themes folder.'),
actions: [],
})
}
}
const handleRailWheel = (event: WheelEvent<HTMLDivElement>): void => {
@@ -193,6 +244,11 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
: astraState.connectionState === 'error'
? 'Error'
: 'Off'
const captureMessage = captureError ?? captureNotice
const astraErrorMessage = astraState.lastError ?? astraState.lastControlError
const canUseDefaultSource = captureMode === 'system'
? selectedSystemSourceId !== defaultSystemSourceId
: selectedDeviceId !== null
return (
<div className="bottom-bar" ref={rootRef}>
@@ -243,7 +299,7 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
type="button"
className="settings-chip"
onClick={() => {
void showThemesFolder()
void handleShowThemesFolder()
}}
>
Folder
@@ -290,8 +346,28 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
</div>
</div>
{astraState.lastError ? (
<div className="settings-error-text bottom-bar__error-text">{astraState.lastError}</div>
{astraErrorMessage ? (
<>
<div className="settings-error-text bottom-bar__error-text">{astraErrorMessage}</div>
<div className="settings-inline-actions">
<button
type="button"
className="settings-chip"
onClick={() => {
void handleRetryAstra()
}}
>
Retry
</button>
<button
type="button"
className="settings-chip"
onClick={() => setSettingsOpen(true)}
>
Open Settings
</button>
</div>
</>
) : null}
</div>
</section>
@@ -312,19 +388,20 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
<optgroup label="Output Devices">
{visibleSystemSources.map((source) => (
<option key={source.id} value={`system:${source.id}`}>
{source.isDefault ? `${source.label} (Default)` : source.label}
{source.isDefault && !source.label.toLowerCase().includes('default')
? `${source.label} (Default)`
: source.label}
</option>
))}
</optgroup>
<optgroup label="Input Devices">
<option value={`device:${DEFAULT_INPUT_DEVICE_ID}`}>Default Input</option>
{devices.map((device) => (
<option key={device.deviceId} value={`device:${device.deviceId}`}>
{device.label || `Input ${device.deviceId.slice(0, 8)}`}
</option>
))}
</optgroup>
{showInputDevices ? (
<optgroup label="Input Devices">
{devices.map((device) => (
<option key={device.deviceId} value={`device:${device.deviceId}`}>
{device.label || `Input ${device.deviceId.slice(0, 8)}`}
</option>
))}
</optgroup>
) : null}
</ThemedSelect>
<div className={`settings-status-pill is-${captureStatus}`.trim()}>
@@ -333,8 +410,43 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
</div>
</div>
{captureError ? (
<div className="settings-error-text bottom-bar__error-text">{captureError}</div>
{captureMessage ? (
<>
<div className={`${captureError ? 'settings-error-text' : 'settings-info-text'} bottom-bar__error-text`.trim()}>
{captureMessage}
</div>
<div className="settings-inline-actions">
<button
type="button"
className="settings-chip"
onClick={() => {
void handleRetryCapture()
}}
>
Retry
</button>
{canUseDefaultSource ? (
<button
type="button"
className="settings-chip"
onClick={() => {
void handleUseDefaultSource()
}}
>
Use Default
</button>
) : null}
{!captureError && captureNotice ? (
<button
type="button"
className="settings-chip"
onClick={clearCaptureNotice}
>
Dismiss
</button>
) : null}
</div>
</>
) : null}
</div>
</section>
+8
View File
@@ -23,6 +23,11 @@ export default function DialogApp(): JSX.Element {
const submit = useCallback((buttonIndex: number) => {
if (!config) return
const isPrimaryPromptSubmit = config.type === 'prompt' && buttonIndex === (config.defaultId ?? 0)
if (isPrimaryPromptSubmit && !inputValue.trim()) {
return
}
const result: DialogResult = { buttonIndex }
if (config.type === 'prompt') {
result.value = inputValue
@@ -47,6 +52,7 @@ export default function DialogApp(): JSX.Element {
const primaryIndex = config.defaultId ?? 0
const cancelId = config.cancelId ?? config.buttons.length - 1
const isPromptPrimaryDisabled = config.type === 'prompt' && !inputValue.trim()
return (
<div className="dialog-root" onKeyDown={handleKeyDown} tabIndex={-1}>
@@ -63,6 +69,7 @@ export default function DialogApp(): JSX.Element {
type="text"
className="dialog-input"
value={inputValue}
placeholder={config.placeholder}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
@@ -88,6 +95,7 @@ export default function DialogApp(): JSX.Element {
].filter(Boolean).join(' ')}
onClick={() => submit(i)}
autoFocus={i === primaryIndex && config.type !== 'prompt'}
disabled={i === primaryIndex && isPromptPrimaryDisabled}
>
{label}
</button>
+44 -17
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef, type JSX, type PointerEvent as ReactPointerEvent } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { useUiStore } from '../stores/uiStore'
function SettingsIcon(): JSX.Element {
return (
@@ -101,11 +102,37 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
const updateActiveProfile = useSettingsStore((s) => s.updateActiveProfile)
const importProfileFromDialog = useSettingsStore((s) => s.importProfileFromDialog)
const showProfilesFolder = useSettingsStore((s) => s.showProfilesFolder)
const showBanner = useUiStore((s) => s.showBanner)
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false)
const [showReposition, setShowReposition] = useState(false)
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false)
const profileButtonRef = useRef<HTMLButtonElement>(null)
const showProfileErrorBanner = useCallback((error: unknown, fallback: string, includeOpenFolder = false) => {
const actions = includeOpenFolder
? [{
label: 'Open Folder',
onSelect: async () => {
try {
await showProfilesFolder()
} catch (folderError) {
showBanner({
tone: 'error',
message: getErrorMessage(folderError, 'Could not open the profiles folder.'),
actions: [],
})
}
},
}]
: []
showBanner({
tone: 'error',
message: getErrorMessage(error, fallback),
actions,
})
}, [showBanner, showProfilesFolder])
useEffect(() => {
void window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop)
const unsubscribe = window.electronAPI.onAlwaysOnTopChanged(setIsAlwaysOnTop)
@@ -114,33 +141,33 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
const handleSaveNew = useCallback(async () => {
setIsProfileMenuOpen(false)
const count = Object.keys(useSettingsStore.getState().profiles).length
const result = await window.electronAPI.showDialog({
type: 'prompt',
title: 'Save as New Profile',
message: 'Profile name',
message: 'Enter a name for the new profile.',
buttons: ['Save', 'Cancel'],
defaultId: 0,
cancelId: 1,
defaultValue: `Profile ${count}`,
defaultValue: '',
placeholder: 'Profile name',
})
if (result.buttonIndex !== 0 || !result.value?.trim()) return
try {
await saveProfile(result.value.trim())
} catch (error) {
window.alert(getErrorMessage(error, 'Could not save the profile.'))
showProfileErrorBanner(error, 'Could not save the profile.')
}
}, [saveProfile])
}, [saveProfile, showProfileErrorBanner])
const handleSaveOverwrite = useCallback(async () => {
try {
await updateActiveProfile()
} catch (error) {
window.alert(getErrorMessage(error, 'Could not update the active profile.'))
showProfileErrorBanner(error, 'Could not update the active profile.')
} finally {
setIsProfileMenuOpen(false)
}
}, [updateActiveProfile])
}, [showProfileErrorBanner, updateActiveProfile])
const handleRenameActive = useCallback(async (id: string) => {
const profile = useSettingsStore.getState().profiles[id]
@@ -164,9 +191,9 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
try {
await renameProfile(id, result.value.trim())
} catch (error) {
window.alert(getErrorMessage(error, 'Could not rename the profile.'))
showProfileErrorBanner(error, 'Could not rename the profile.')
}
}, [renameProfile])
}, [renameProfile, showProfileErrorBanner])
const handleDeleteActive = useCallback(async (id: string) => {
const profile = useSettingsStore.getState().profiles[id]
@@ -190,9 +217,9 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
try {
await deleteProfile(id)
} catch (error) {
window.alert(getErrorMessage(error, 'Could not delete the profile.'))
showProfileErrorBanner(error, 'Could not delete the profile.')
}
}, [deleteProfile])
}, [deleteProfile, showProfileErrorBanner])
const handleLoadProfile = useCallback(async (id: string) => {
try {
@@ -200,11 +227,11 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
await loadProfile(id)
})
} catch (error) {
window.alert(getErrorMessage(error, 'Could not load the profile.'))
showProfileErrorBanner(error, 'Could not load the profile.', true)
} finally {
setIsProfileMenuOpen(false)
}
}, [guardProfileTransition, loadProfile])
}, [guardProfileTransition, loadProfile, showProfileErrorBanner])
const handleImportProfile = useCallback(async () => {
try {
@@ -212,21 +239,21 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
await importProfileFromDialog()
})
} catch (error) {
window.alert(getErrorMessage(error, 'Could not import the profile file.'))
showProfileErrorBanner(error, 'Could not import the profile file.', true)
} finally {
setIsProfileMenuOpen(false)
}
}, [guardProfileTransition, importProfileFromDialog])
}, [guardProfileTransition, importProfileFromDialog, showProfileErrorBanner])
const handleShowProfilesFolder = useCallback(async () => {
try {
await showProfilesFolder()
} catch (error) {
window.alert(getErrorMessage(error, 'Could not open the profiles folder.'))
showProfileErrorBanner(error, 'Could not open the profiles folder.')
} finally {
setIsProfileMenuOpen(false)
}
}, [showProfilesFolder])
}, [showProfileErrorBanner, showProfilesFolder])
useEffect(() => {
const offClosed = window.electronAPI.onProfileMenuClosed(() => {