mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
improve UI/UX
This commit is contained in:
@@ -575,52 +575,6 @@ function setWindowAlwaysOnTop(window: BrowserWindow, next: boolean): void {
|
||||
})
|
||||
}
|
||||
|
||||
function attachWindowShortcuts(window: BrowserWindow): void {
|
||||
const scopeKeys = new Map([
|
||||
['1', 0],
|
||||
['2', 1],
|
||||
['3', 2],
|
||||
['4', 3],
|
||||
['5', 4],
|
||||
['6', 5],
|
||||
['7', 6],
|
||||
['8', 7],
|
||||
])
|
||||
|
||||
window.webContents.on('before-input-event', (_event, input: Electron.Input) => {
|
||||
if (input.type !== 'keyDown') {
|
||||
return
|
||||
}
|
||||
|
||||
const isMainWindow = isMainRendererWindow(window)
|
||||
|
||||
if (!input.alt && !input.control && !input.meta && !input.shift) {
|
||||
if (isMainWindow) {
|
||||
const scopeIndex = scopeKeys.get(input.key)
|
||||
if (scopeIndex !== undefined) {
|
||||
window.webContents.send('shortcut:toggle-scope', scopeIndex)
|
||||
return
|
||||
}
|
||||
|
||||
if (input.key === ' ') {
|
||||
window.webContents.send('shortcut:toggle-capture')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (input.key === 't') {
|
||||
setWindowAlwaysOnTop(window, !window.isAlwaysOnTop())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isMainWindow && input.key === ',' && input.meta && !input.alt && !input.control && !input.shift) {
|
||||
window.webContents.send('shortcut:toggle-settings')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function loadRendererTarget(window: BrowserWindow, query: Record<string, string>): void {
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
const url = new URL(process.env.ELECTRON_RENDERER_URL)
|
||||
@@ -746,7 +700,6 @@ function createMainWindow(): void {
|
||||
scheduleMainWindowBoundsSave(mainWindow)
|
||||
})
|
||||
|
||||
attachWindowShortcuts(mainWindow)
|
||||
loadRendererTarget(mainWindow, { window: 'main' })
|
||||
}
|
||||
|
||||
@@ -877,7 +830,6 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro
|
||||
popoutWindow.on('move', () => emitPopoutBoundsChanged(kind, popoutWindow))
|
||||
popoutWindow.on('resize', () => emitPopoutBoundsChanged(kind, popoutWindow))
|
||||
|
||||
attachWindowShortcuts(popoutWindow)
|
||||
loadRendererTarget(popoutWindow, { window: 'scope-popout', scope: kind })
|
||||
return popoutWindow
|
||||
}
|
||||
|
||||
@@ -98,21 +98,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.on('window:always-on-top-changed', handler)
|
||||
return () => ipcRenderer.removeListener('window:always-on-top-changed', handler)
|
||||
},
|
||||
onToggleScope: (callback: (index: number) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, index: number): void => callback(index)
|
||||
ipcRenderer.on('shortcut:toggle-scope', handler)
|
||||
return () => ipcRenderer.removeListener('shortcut:toggle-scope', handler)
|
||||
},
|
||||
onToggleCapture: (callback: () => void) => {
|
||||
const handler = (): void => callback()
|
||||
ipcRenderer.on('shortcut:toggle-capture', handler)
|
||||
return () => ipcRenderer.removeListener('shortcut:toggle-capture', handler)
|
||||
},
|
||||
onToggleSettings: (callback: () => void) => {
|
||||
const handler = (): void => callback()
|
||||
ipcRenderer.on('shortcut:toggle-settings', handler)
|
||||
return () => ipcRenderer.removeListener('shortcut:toggle-settings', handler)
|
||||
},
|
||||
onMainWindowBoundsChanged: (callback: (bounds: WindowBounds) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, bounds: WindowBounds): void => callback(bounds)
|
||||
ipcRenderer.on('window:bounds-changed', handler)
|
||||
|
||||
+39
-31
@@ -5,31 +5,35 @@ import SettingsPanel from './components/SettingsPanel'
|
||||
import BottomBar from './components/BottomBar'
|
||||
import ScopePopoutBridge from './components/ScopePopoutBridge'
|
||||
import WindowResizeOverlay from './components/WindowResizeOverlay'
|
||||
import AppBanner from './components/AppBanner'
|
||||
import { useSettingsStore } from './stores/settingsStore'
|
||||
import { useAstraStore } from './stores/astraStore'
|
||||
import { useAudioStore } from './stores/audioStore'
|
||||
import { useThemeStore } from './stores/themeStore'
|
||||
import { SCOPE_KINDS } from '../types/scope'
|
||||
import { useUiStore } from './stores/uiStore'
|
||||
|
||||
const DEFAULT_SETTINGS_HEIGHT = 400
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const [toolbarVisible, setToolbarVisible] = useState(false)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [settingsPanelHeight, setSettingsPanelHeight] = useState(0)
|
||||
const [bottomBarHeight, setBottomBarHeight] = useState(0)
|
||||
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const externalProfileOpenQueueRef = useRef(Promise.resolve())
|
||||
|
||||
const toggleScope = useSettingsStore((s) => s.toggleScope)
|
||||
const initializeProfiles = useSettingsStore((s) => s.initializeProfiles)
|
||||
const applyExternalProfileSnapshot = useSettingsStore((s) => s.applyExternalProfileSnapshot)
|
||||
const guardProfileTransition = useSettingsStore((s) => s.guardProfileTransition)
|
||||
const importProfileFromPath = useSettingsStore((s) => s.importProfileFromPath)
|
||||
const showProfilesFolder = useSettingsStore((s) => s.showProfilesFolder)
|
||||
const updateMainWindowBounds = useSettingsStore((s) => s.updateMainWindowBounds)
|
||||
const initializeThemes = useThemeStore((s) => s.initializeThemes)
|
||||
const applyExternalThemeSnapshot = useThemeStore((s) => s.applyExternalThemeSnapshot)
|
||||
const initializeAstra = useAstraStore((s) => s.initialize)
|
||||
const settingsOpen = useUiStore((s) => s.settingsOpen)
|
||||
const toggleSettings = useUiStore((s) => s.toggleSettings)
|
||||
const setSettingsOpen = useUiStore((s) => s.setSettingsOpen)
|
||||
const showBanner = useUiStore((s) => s.showBanner)
|
||||
|
||||
// Auto-capture on launch
|
||||
useEffect(() => {
|
||||
@@ -71,9 +75,32 @@ export default function App(): JSX.Element {
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
window.alert(error instanceof Error && error.message
|
||||
const message = error instanceof Error && error.message
|
||||
? error.message
|
||||
: `Prism could not open ${path}.`)
|
||||
: `Prism could not open ${path}.`
|
||||
|
||||
showBanner({
|
||||
tone: 'error',
|
||||
message,
|
||||
actions: [
|
||||
{
|
||||
label: 'Open Folder',
|
||||
onSelect: async () => {
|
||||
try {
|
||||
await showProfilesFolder()
|
||||
} catch (folderError) {
|
||||
showBanner({
|
||||
tone: 'error',
|
||||
message: folderError instanceof Error && folderError.message
|
||||
? folderError.message
|
||||
: 'Could not open the profiles folder.',
|
||||
actions: [],
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
const unsubscribeCloseRequested = window.electronAPI.onMainCloseRequested(() => {
|
||||
@@ -104,6 +131,8 @@ export default function App(): JSX.Element {
|
||||
initializeProfiles,
|
||||
initializeThemes,
|
||||
initializeAstra,
|
||||
showBanner,
|
||||
showProfilesFolder,
|
||||
updateMainWindowBounds,
|
||||
])
|
||||
|
||||
@@ -133,12 +162,12 @@ export default function App(): JSX.Element {
|
||||
}, [settingsOpen])
|
||||
|
||||
const handleToggleSettings = useCallback(() => {
|
||||
setSettingsOpen((prev) => !prev)
|
||||
}, [])
|
||||
toggleSettings()
|
||||
}, [toggleSettings])
|
||||
|
||||
const handleCloseSettings = useCallback(() => {
|
||||
setSettingsOpen(false)
|
||||
}, [])
|
||||
}, [setSettingsOpen])
|
||||
|
||||
const handleAltDragStart = useCallback((event: React.MouseEvent) => {
|
||||
if (event.altKey && event.button === 0) {
|
||||
@@ -160,29 +189,6 @@ export default function App(): JSX.Element {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Keyboard shortcuts from main process
|
||||
useEffect(() => {
|
||||
const unsubs = [
|
||||
window.electronAPI.onToggleScope((index) => {
|
||||
if (index >= 0 && index < SCOPE_KINDS.length) {
|
||||
toggleScope(SCOPE_KINDS[index])
|
||||
}
|
||||
}),
|
||||
window.electronAPI.onToggleCapture(() => {
|
||||
const { isCapturing, startCapture, stopCapture } = useAudioStore.getState()
|
||||
if (isCapturing) {
|
||||
stopCapture()
|
||||
} else {
|
||||
startCapture()
|
||||
}
|
||||
}),
|
||||
window.electronAPI.onToggleSettings(() => {
|
||||
setSettingsOpen((prev) => !prev)
|
||||
}),
|
||||
]
|
||||
return () => unsubs.forEach((unsub) => unsub())
|
||||
}, [toggleScope])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="prism-app"
|
||||
@@ -203,6 +209,8 @@ export default function App(): JSX.Element {
|
||||
|
||||
<ScopePopoutBridge />
|
||||
|
||||
<AppBanner />
|
||||
|
||||
{settingsOpen && (
|
||||
<div className="prism-settings-region" style={{ height: settingsHeight }}>
|
||||
<SettingsPanel onHeightChange={setSettingsPanelHeight} />
|
||||
|
||||
@@ -114,7 +114,7 @@ function toDeviceSourceDescriptor(device: MediaDeviceInfo): CaptureSourceDescrip
|
||||
function getDefaultSystemSourceDescriptor(): CaptureSourceDescriptor {
|
||||
return {
|
||||
id: DEFAULT_SYSTEM_SOURCE_ID,
|
||||
label: 'System Output',
|
||||
label: 'Default Output',
|
||||
kind: 'system',
|
||||
isDefault: true,
|
||||
}
|
||||
@@ -702,7 +702,8 @@ class AudioCapture {
|
||||
|
||||
const activeSystemBackend = this.resolveCandidateBackends(this.backendSupport ?? DEFAULT_BACKEND_SUPPORT, 'system')[0]
|
||||
const sources = await activeSystemBackend.listSources()
|
||||
return sources.length ? sources : [getDefaultSystemSourceDescriptor()]
|
||||
const dedupedSources = sources.filter((source) => source.id !== DEFAULT_SYSTEM_SOURCE_ID)
|
||||
return [getDefaultSystemSourceDescriptor(), ...dedupedSources]
|
||||
}
|
||||
|
||||
async listDevices(): Promise<MediaDeviceInfo[]> {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Vendored
-3
@@ -88,9 +88,6 @@ declare global {
|
||||
requestScopePopIn: (kind: ScopeKind) => void
|
||||
sendScopePopoutSettingsUpdate: (kind: ScopeKind, partial: unknown) => void
|
||||
onAlwaysOnTopChanged: (callback: (isOnTop: boolean) => void) => () => void
|
||||
onToggleScope: (callback: (index: number) => void) => () => void
|
||||
onToggleCapture: (callback: () => void) => () => void
|
||||
onToggleSettings: (callback: () => void) => () => void
|
||||
onMainWindowBoundsChanged: (callback: (bounds: WindowBounds) => void) => () => void
|
||||
onAstraStateChanged: (callback: (state: AstraIntegrationState) => void) => () => void
|
||||
onMainCloseRequested: (callback: () => void) => () => void
|
||||
|
||||
@@ -7,6 +7,7 @@ import ScopeModule from '../components/ScopeModule'
|
||||
import ScopeSettingsSection from '../components/ScopeSettingsSection'
|
||||
import WindowResizeOverlay from '../components/WindowResizeOverlay'
|
||||
import { usePerformanceStore } from '../stores/performanceStore'
|
||||
import { useUiStore } from '../stores/uiStore'
|
||||
import { ScopePopoutDataSource } from './ScopePopoutDataSource'
|
||||
import { FrameScheduler } from '../visualizers/frameScheduler'
|
||||
|
||||
@@ -66,9 +67,10 @@ const defaultTheme = resolveTheme(createDefaultTheme())
|
||||
export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps): JSX.Element {
|
||||
const [snapshot, setSnapshot] = useState<ScopePopoutSnapshot<ScopeKind> | null>(null)
|
||||
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false)
|
||||
const [miniSettingsOpen, setMiniSettingsOpen] = useState(false)
|
||||
const prevMiniSettingsOpenRef = useRef(false)
|
||||
const frameTarget = usePerformanceStore((s) => s.frameTarget)
|
||||
const miniSettingsOpen = useUiStore((s) => s.settingsOpen)
|
||||
const setMiniSettingsOpen = useUiStore((s) => s.setSettingsOpen)
|
||||
const frameScheduler = useMemo(() => new FrameScheduler({ frameTarget }), [])
|
||||
const dataSource = useMemo(() => new ScopePopoutDataSource(scopeKind), [scopeKind])
|
||||
|
||||
@@ -226,7 +228,7 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
|
||||
<button
|
||||
type="button"
|
||||
className={`scope-popout__button ${miniSettingsOpen ? 'is-active' : ''}`.trim()}
|
||||
onClick={() => setMiniSettingsOpen((prev) => !prev)}
|
||||
onClick={() => setMiniSettingsOpen(!miniSettingsOpen)}
|
||||
aria-label="Toggle mini settings"
|
||||
title="Mini settings"
|
||||
>
|
||||
|
||||
@@ -21,15 +21,17 @@ interface AudioState {
|
||||
isCapturing: boolean
|
||||
captureStatus: 'idle' | 'connecting' | 'capturing' | 'error'
|
||||
captureError: string | null
|
||||
captureNotice: string | null
|
||||
sampleRate: number
|
||||
channelCount: number
|
||||
inputGainDb: number
|
||||
setInputGain: (db: number) => void
|
||||
clearCaptureNotice: () => void
|
||||
refreshSystemSources: () => Promise<void>
|
||||
refreshDevices: () => Promise<void>
|
||||
refreshBackendSupport: () => Promise<void>
|
||||
selectSystemSource: (sourceId: string | null) => Promise<void>
|
||||
selectDevice: (deviceId: string) => Promise<void>
|
||||
selectDevice: (deviceId: string | null) => Promise<void>
|
||||
setCaptureMode: (mode: CaptureMode) => void
|
||||
setCapturePolicy: (policy: CaptureBackendPolicy) => Promise<void>
|
||||
startCapture: () => Promise<void>
|
||||
@@ -49,6 +51,19 @@ function applyCaptureStatus(status: CaptureManagerStatus): Partial<AudioState> {
|
||||
}
|
||||
}
|
||||
|
||||
function describeInputDevice(deviceId: string, devices: MediaDeviceInfo[]): string {
|
||||
const matchingDevice = devices.find((device) => device.deviceId === deviceId)
|
||||
if (matchingDevice?.label) {
|
||||
return matchingDevice.label
|
||||
}
|
||||
|
||||
return `Input ${deviceId.slice(0, 8)}`
|
||||
}
|
||||
|
||||
function describeSystemSource(sourceId: string, sources: CaptureSourceDescriptor[]): string {
|
||||
return sources.find((source) => source.id === sourceId)?.label ?? 'The selected output device'
|
||||
}
|
||||
|
||||
export const useAudioStore = create<AudioState>((set, get) => ({
|
||||
systemSources: [],
|
||||
devices: [],
|
||||
@@ -62,6 +77,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
||||
isCapturing: false,
|
||||
captureStatus: 'idle',
|
||||
captureError: null,
|
||||
captureNotice: null,
|
||||
sampleRate: 48000,
|
||||
channelCount: 2,
|
||||
inputGainDb: 0,
|
||||
@@ -71,24 +87,59 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
||||
set({ inputGainDb: db })
|
||||
},
|
||||
|
||||
clearCaptureNotice: () => {
|
||||
set({ captureNotice: null })
|
||||
},
|
||||
|
||||
refreshSystemSources: async () => {
|
||||
const previousSelectedSystemSourceId = get().selectedSystemSourceId
|
||||
const previousSources = get().systemSources
|
||||
const systemSources = await audioCapture.listSources('system')
|
||||
const fallbackSourceId = systemSources[0]?.id ?? null
|
||||
const currentSelectedSystemSourceId = get().selectedSystemSourceId
|
||||
const nextSelectedSystemSourceId = currentSelectedSystemSourceId && systemSources.some((source) => source.id === currentSelectedSystemSourceId)
|
||||
? currentSelectedSystemSourceId
|
||||
const nextSelectedSystemSourceId = previousSelectedSystemSourceId
|
||||
&& systemSources.some((source) => source.id === previousSelectedSystemSourceId)
|
||||
? previousSelectedSystemSourceId
|
||||
: fallbackSourceId
|
||||
const shouldShowFallbackNotice = Boolean(
|
||||
previousSelectedSystemSourceId
|
||||
&& previousSelectedSystemSourceId !== nextSelectedSystemSourceId
|
||||
&& get().captureMode === 'system',
|
||||
)
|
||||
|
||||
audioCapture.setSelectedSystemSourceId(nextSelectedSystemSourceId)
|
||||
set({
|
||||
set((state) => ({
|
||||
...state,
|
||||
systemSources,
|
||||
selectedSystemSourceId: nextSelectedSystemSourceId,
|
||||
})
|
||||
captureNotice: shouldShowFallbackNotice
|
||||
? `${describeSystemSource(previousSelectedSystemSourceId!, previousSources)} is unavailable. Prism switched to Default Output.`
|
||||
: state.captureNotice,
|
||||
}))
|
||||
},
|
||||
|
||||
refreshDevices: async () => {
|
||||
const previousSelectedDeviceId = get().selectedDeviceId
|
||||
const previousDevices = get().devices
|
||||
const devices = await audioCapture.listDevices()
|
||||
set({ devices })
|
||||
const nextSelectedDeviceId = previousSelectedDeviceId
|
||||
&& devices.some((device) => device.deviceId === previousSelectedDeviceId)
|
||||
? previousSelectedDeviceId
|
||||
: null
|
||||
const shouldShowFallbackNotice = Boolean(
|
||||
previousSelectedDeviceId
|
||||
&& previousSelectedDeviceId !== nextSelectedDeviceId
|
||||
&& get().captureMode === 'device',
|
||||
)
|
||||
|
||||
audioCapture.setSelectedDeviceId(nextSelectedDeviceId)
|
||||
set((state) => ({
|
||||
...state,
|
||||
devices,
|
||||
selectedDeviceId: nextSelectedDeviceId,
|
||||
captureNotice: shouldShowFallbackNotice
|
||||
? `${describeInputDevice(previousSelectedDeviceId!, previousDevices)} is unavailable. Prism switched to Default Input.`
|
||||
: state.captureNotice,
|
||||
}))
|
||||
},
|
||||
|
||||
refreshBackendSupport: async () => {
|
||||
@@ -103,13 +154,20 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
||||
set({
|
||||
selectedSystemSourceId: audioCapture.getSelectedSystemSourceId(),
|
||||
captureMode: 'system',
|
||||
captureError: null,
|
||||
captureNotice: null,
|
||||
})
|
||||
},
|
||||
|
||||
selectDevice: async (deviceId: string) => {
|
||||
selectDevice: async (deviceId: string | null) => {
|
||||
audioCapture.setSelectedDeviceId(deviceId)
|
||||
audioCapture.setCaptureMode('device')
|
||||
set({ selectedDeviceId: deviceId, captureMode: 'device' })
|
||||
set({
|
||||
selectedDeviceId: deviceId,
|
||||
captureMode: 'device',
|
||||
captureError: null,
|
||||
captureNotice: null,
|
||||
})
|
||||
},
|
||||
|
||||
setCaptureMode: (mode: CaptureMode) => {
|
||||
@@ -135,6 +193,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
||||
audioCapture.setCaptureMode(captureMode)
|
||||
audioCapture.setBackendPolicy(capturePolicy)
|
||||
await get().refreshBackendSupport()
|
||||
await get().refreshDevices()
|
||||
|
||||
const { selectedDeviceId, selectedSystemSourceId } = get()
|
||||
|
||||
@@ -145,11 +204,12 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
||||
}
|
||||
|
||||
const status = audioCapture.getStatus()
|
||||
set({
|
||||
set((state) => ({
|
||||
...state,
|
||||
...applyCaptureStatus(status),
|
||||
captureStatus: 'capturing',
|
||||
captureError: null,
|
||||
})
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('Failed to start audio capture:', err)
|
||||
const message = err instanceof Error ? err.message : 'Unknown audio capture error'
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../../shared/profileState'
|
||||
import { useThemeStore } from './themeStore'
|
||||
import { buildProfileDraft, profilesMatch } from './profileDraft'
|
||||
import { useUiStore } from './uiStore'
|
||||
|
||||
export type { ScopeSettings } from '../../types/settings'
|
||||
|
||||
@@ -710,7 +711,11 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
try {
|
||||
await get().updateActiveProfile()
|
||||
} catch (error) {
|
||||
window.alert(getErrorMessage(error, 'Could not save the profile.'))
|
||||
useUiStore.getState().showBanner({
|
||||
tone: 'error',
|
||||
message: getErrorMessage(error, 'Could not save the profile.'),
|
||||
actions: [],
|
||||
})
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type UiBannerTone = 'info' | 'error'
|
||||
|
||||
export interface UiBannerAction {
|
||||
label: string
|
||||
onSelect?: () => void | Promise<void>
|
||||
dismissOnSelect?: boolean
|
||||
}
|
||||
|
||||
export interface UiBanner {
|
||||
id: number
|
||||
tone: UiBannerTone
|
||||
message: string
|
||||
actions: UiBannerAction[]
|
||||
}
|
||||
|
||||
interface UiStoreState {
|
||||
settingsOpen: boolean
|
||||
banner: UiBanner | null
|
||||
setSettingsOpen: (open: boolean) => void
|
||||
toggleSettings: () => void
|
||||
showBanner: (banner: Omit<UiBanner, 'id'>) => void
|
||||
dismissBanner: (bannerId?: number) => void
|
||||
}
|
||||
|
||||
let nextBannerId = 1
|
||||
|
||||
export const useUiStore = create<UiStoreState>((set) => ({
|
||||
settingsOpen: false,
|
||||
banner: null,
|
||||
|
||||
setSettingsOpen: (open) => {
|
||||
set({ settingsOpen: open })
|
||||
},
|
||||
|
||||
toggleSettings: () => {
|
||||
set((state) => ({ settingsOpen: !state.settingsOpen }))
|
||||
},
|
||||
|
||||
showBanner: (banner) => {
|
||||
set({
|
||||
banner: {
|
||||
...banner,
|
||||
id: nextBannerId++,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
dismissBanner: (bannerId) => {
|
||||
set((state) => {
|
||||
if (bannerId && state.banner?.id !== bannerId) {
|
||||
return state
|
||||
}
|
||||
|
||||
return { banner: null }
|
||||
})
|
||||
},
|
||||
}))
|
||||
@@ -207,6 +207,77 @@ select {
|
||||
box-shadow: 0 -16px 34px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.app-banner-layer {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
z-index: 18;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-banner {
|
||||
width: min(520px, 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--control-border);
|
||||
background: linear-gradient(180deg, var(--panel-surface), var(--panel-surface-soft));
|
||||
box-shadow: var(--panel-shadow);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.app-banner--error {
|
||||
border-color: rgba(248, 113, 113, 0.32);
|
||||
}
|
||||
|
||||
.app-banner--info {
|
||||
border-color: rgba(var(--accent-rgb), 0.24);
|
||||
}
|
||||
|
||||
.app-banner__message {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.app-banner__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-banner__action,
|
||||
.app-banner__dismiss {
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--control-bg);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.app-banner__action:hover,
|
||||
.app-banner__dismiss:hover,
|
||||
.app-banner__action:focus-visible,
|
||||
.app-banner__dismiss:focus-visible {
|
||||
outline: none;
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-active);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -793,6 +864,13 @@ select {
|
||||
border-color: var(--astra-status-error);
|
||||
}
|
||||
|
||||
.astra-scope__status-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@container (max-width: 420px) {
|
||||
.astra-scope {
|
||||
--astra-cover-size: clamp(44px, min(28cqi, 60cqb), 132px);
|
||||
@@ -1256,6 +1334,14 @@ select {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-inline-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.settings-theme-swatches {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface DialogOptions {
|
||||
defaultId?: number
|
||||
cancelId?: number
|
||||
defaultValue?: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export interface DialogResult {
|
||||
|
||||
Reference in New Issue
Block a user