@better-forge/ui 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +13 -13
  3. package/icons.json +170 -170
  4. package/package.json +71 -71
  5. package/src/api/client.ts +139 -139
  6. package/src/components/ConfirmIdentityModal.tsx +82 -82
  7. package/src/components/ConfirmModal.tsx +83 -83
  8. package/src/components/CopyButton.tsx +71 -71
  9. package/src/components/CredentialSection.tsx +270 -270
  10. package/src/components/ErrorBoundary.tsx +47 -47
  11. package/src/components/ModalShell.tsx +160 -160
  12. package/src/components/ResetTwoFactorModal.tsx +74 -74
  13. package/src/components/SidePanel.tsx +96 -96
  14. package/src/components/SingleSignOnModal.tsx +161 -161
  15. package/src/components/SsoConfirmation.tsx +96 -96
  16. package/src/components/VaultTypePicker.tsx +131 -131
  17. package/src/components/WhatsNew.tsx +84 -84
  18. package/src/components/formParts.tsx +59 -59
  19. package/src/components/ui/Alert.tsx +45 -45
  20. package/src/components/ui/DataTable.tsx +225 -225
  21. package/src/components/ui/Field.tsx +63 -63
  22. package/src/components/ui/PageHeader.tsx +36 -36
  23. package/src/components/ui/Pagination.tsx +37 -37
  24. package/src/components/ui/QuickStartPanel.tsx +57 -57
  25. package/src/components/ui/StateViews.tsx +135 -135
  26. package/src/components/ui/Switch.tsx +65 -65
  27. package/src/components/ui/Toast.tsx +104 -104
  28. package/src/components/ui/index.ts +9 -9
  29. package/src/config.ts +46 -46
  30. package/src/hooks/useDarkMode.ts +38 -38
  31. package/src/hooks/useDebouncedValue.ts +22 -22
  32. package/src/index.ts +42 -42
  33. package/src/lib/changelog.ts +93 -93
  34. package/src/lib/dateFormat.ts +90 -90
  35. package/src/navigation.ts +6 -6
  36. package/src/pages/ApiTokens.tsx +361 -361
  37. package/src/pages/AuditLog.tsx +388 -388
  38. package/src/pages/Backups.tsx +170 -170
  39. package/src/pages/ChangePassword.tsx +84 -84
  40. package/src/pages/Login.tsx +275 -275
  41. package/src/pages/Settings.tsx +264 -264
  42. package/src/pages/Setup.tsx +438 -438
  43. package/src/pages/SsoConfirm.tsx +26 -26
  44. package/src/pages/SsoTestResult.tsx +104 -104
  45. package/src/pages/Users.tsx +426 -426
  46. package/src/pages/Vault.tsx +1036 -1036
  47. package/src/shell/AdminShell.tsx +256 -256
  48. package/src/shell/guards.tsx +88 -88
  49. package/styles/base.css +521 -521
  50. package/styles/icons.css +4 -4
  51. package/tailwind-preset.js +79 -79
@@ -1,104 +1,104 @@
1
- import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
- import { createPortal } from 'react-dom'
3
- import { MODAL_Z_INDEX } from '../ModalShell'
4
-
5
- export type ToastTone = 'success' | 'error' | 'info'
6
-
7
- export interface ToastOptions {
8
- /** Milliseconds before it disappears on its own; 0 keeps it until dismissed. Default 4000. */
9
- duration?: number | undefined
10
- }
11
-
12
- export interface ToastApi {
13
- success: (message: ReactNode, options?: ToastOptions) => number
14
- error: (message: ReactNode, options?: ToastOptions) => number
15
- info: (message: ReactNode, options?: ToastOptions) => number
16
- dismiss: (id: number) => void
17
- }
18
-
19
- interface ToastEntry { id: number; tone: ToastTone; message: ReactNode; duration: number }
20
-
21
- export const TOAST_DURATION = 4000
22
-
23
- const noop: ToastApi = { success: () => 0, error: () => 0, info: () => 0, dismiss: () => {} }
24
-
25
- // Outside a provider (isolated component tests) toasts are silently dropped instead of throwing.
26
- const ToastContext = createContext<ToastApi>(noop)
27
-
28
- /** `const toast = useToast(); toast.success('Saved')`. The returned object is stable across renders. */
29
- export function useToast(): ToastApi {
30
- return useContext(ToastContext)
31
- }
32
-
33
- const TONE_STYLE: Record<ToastTone, { icon: string; color: string }> = {
34
- success: { icon: 'check_circle', color: 'var(--m3-up)' },
35
- error: { icon: 'error', color: 'var(--m3-down)' },
36
- info: { icon: 'info', color: 'var(--m3-on-surface-variant)' },
37
- }
38
-
39
- function ToastItem({ toast, onDismiss }: { toast: ToastEntry; onDismiss: (id: number) => void }) {
40
- const [paused, setPaused] = useState(false)
41
- const { icon, color } = TONE_STYLE[toast.tone]
42
-
43
- useEffect(() => {
44
- if (paused || toast.duration <= 0) return
45
- const timer = setTimeout(() => onDismiss(toast.id), toast.duration)
46
- return () => clearTimeout(timer)
47
- }, [paused, toast.duration, toast.id, onDismiss])
48
-
49
- return (
50
- <div
51
- role={toast.tone === 'error' ? 'alert' : 'status'}
52
- data-tone={toast.tone}
53
- className="admin-toast pointer-events-auto flex items-start gap-3 rounded-xl px-4 py-3 text-sm w-full"
54
- style={{ background: 'var(--m3-surface-container-lowest)', color: 'var(--m3-on-surface)', border: '1px solid var(--m3-outline-variant)', borderLeft: `4px solid ${color}`, boxShadow: '0 8px 24px rgba(0,0,0,0.14)' }}
55
- onMouseEnter={() => setPaused(true)}
56
- onMouseLeave={() => setPaused(false)}
57
- onFocus={() => setPaused(true)}
58
- onBlur={() => setPaused(false)}
59
- >
60
- <span className="material-symbols-outlined flex-shrink-0 mt-px" aria-hidden="true" style={{ fontSize: '18px', color }}>{icon}</span>
61
- <div className="flex-1 min-w-0 break-words">{toast.message}</div>
62
- <button type="button" onClick={() => onDismiss(toast.id)} aria-label="Dismiss" className="btn-icon -my-1 -mr-2 w-7 h-7">
63
- <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '16px' }}>close</span>
64
- </button>
65
- </div>
66
- )
67
- }
68
-
69
- /** Mount once near the root; renders the bottom-right toast stack above every modal. */
70
- export function ToastProvider({ children }: { children: ReactNode }) {
71
- const [toasts, setToasts] = useState<ToastEntry[]>([])
72
- const nextId = useRef(1)
73
-
74
- const dismiss = useCallback((id: number) => {
75
- setToasts((current) => current.filter((toast) => toast.id !== id))
76
- }, [])
77
-
78
- const api = useMemo<ToastApi>(() => {
79
- const push = (tone: ToastTone) => (message: ReactNode, options?: ToastOptions) => {
80
- const id = nextId.current++
81
- setToasts((current) => [...current.slice(-4), { id, tone, message, duration: options?.duration ?? TOAST_DURATION }])
82
- return id
83
- }
84
- return { success: push('success'), error: push('error'), info: push('info'), dismiss }
85
- }, [dismiss])
86
-
87
- return (
88
- <ToastContext.Provider value={api}>
89
- {children}
90
- {createPortal(
91
- <div
92
- role="region"
93
- aria-live="polite"
94
- aria-label="Notifications"
95
- className="fixed bottom-4 right-4 left-4 sm:left-auto sm:w-96 flex flex-col items-end gap-2 pointer-events-none"
96
- style={{ zIndex: MODAL_Z_INDEX + 1 }}
97
- >
98
- {toasts.map((toast) => <ToastItem key={toast.id} toast={toast} onDismiss={dismiss} />)}
99
- </div>,
100
- document.body,
101
- )}
102
- </ToastContext.Provider>
103
- )
104
- }
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { MODAL_Z_INDEX } from '../ModalShell'
4
+
5
+ export type ToastTone = 'success' | 'error' | 'info'
6
+
7
+ export interface ToastOptions {
8
+ /** Milliseconds before it disappears on its own; 0 keeps it until dismissed. Default 4000. */
9
+ duration?: number | undefined
10
+ }
11
+
12
+ export interface ToastApi {
13
+ success: (message: ReactNode, options?: ToastOptions) => number
14
+ error: (message: ReactNode, options?: ToastOptions) => number
15
+ info: (message: ReactNode, options?: ToastOptions) => number
16
+ dismiss: (id: number) => void
17
+ }
18
+
19
+ interface ToastEntry { id: number; tone: ToastTone; message: ReactNode; duration: number }
20
+
21
+ export const TOAST_DURATION = 4000
22
+
23
+ const noop: ToastApi = { success: () => 0, error: () => 0, info: () => 0, dismiss: () => {} }
24
+
25
+ // Outside a provider (isolated component tests) toasts are silently dropped instead of throwing.
26
+ const ToastContext = createContext<ToastApi>(noop)
27
+
28
+ /** `const toast = useToast(); toast.success('Saved')`. The returned object is stable across renders. */
29
+ export function useToast(): ToastApi {
30
+ return useContext(ToastContext)
31
+ }
32
+
33
+ const TONE_STYLE: Record<ToastTone, { icon: string; color: string }> = {
34
+ success: { icon: 'check_circle', color: 'var(--m3-up)' },
35
+ error: { icon: 'error', color: 'var(--m3-down)' },
36
+ info: { icon: 'info', color: 'var(--m3-on-surface-variant)' },
37
+ }
38
+
39
+ function ToastItem({ toast, onDismiss }: { toast: ToastEntry; onDismiss: (id: number) => void }) {
40
+ const [paused, setPaused] = useState(false)
41
+ const { icon, color } = TONE_STYLE[toast.tone]
42
+
43
+ useEffect(() => {
44
+ if (paused || toast.duration <= 0) return
45
+ const timer = setTimeout(() => onDismiss(toast.id), toast.duration)
46
+ return () => clearTimeout(timer)
47
+ }, [paused, toast.duration, toast.id, onDismiss])
48
+
49
+ return (
50
+ <div
51
+ role={toast.tone === 'error' ? 'alert' : 'status'}
52
+ data-tone={toast.tone}
53
+ className="admin-toast pointer-events-auto flex items-start gap-3 rounded-xl px-4 py-3 text-sm w-full"
54
+ style={{ background: 'var(--m3-surface-container-lowest)', color: 'var(--m3-on-surface)', border: '1px solid var(--m3-outline-variant)', borderLeft: `4px solid ${color}`, boxShadow: '0 8px 24px rgba(0,0,0,0.14)' }}
55
+ onMouseEnter={() => setPaused(true)}
56
+ onMouseLeave={() => setPaused(false)}
57
+ onFocus={() => setPaused(true)}
58
+ onBlur={() => setPaused(false)}
59
+ >
60
+ <span className="material-symbols-outlined flex-shrink-0 mt-px" aria-hidden="true" style={{ fontSize: '18px', color }}>{icon}</span>
61
+ <div className="flex-1 min-w-0 break-words">{toast.message}</div>
62
+ <button type="button" onClick={() => onDismiss(toast.id)} aria-label="Dismiss" className="btn-icon -my-1 -mr-2 w-7 h-7">
63
+ <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '16px' }}>close</span>
64
+ </button>
65
+ </div>
66
+ )
67
+ }
68
+
69
+ /** Mount once near the root; renders the bottom-right toast stack above every modal. */
70
+ export function ToastProvider({ children }: { children: ReactNode }) {
71
+ const [toasts, setToasts] = useState<ToastEntry[]>([])
72
+ const nextId = useRef(1)
73
+
74
+ const dismiss = useCallback((id: number) => {
75
+ setToasts((current) => current.filter((toast) => toast.id !== id))
76
+ }, [])
77
+
78
+ const api = useMemo<ToastApi>(() => {
79
+ const push = (tone: ToastTone) => (message: ReactNode, options?: ToastOptions) => {
80
+ const id = nextId.current++
81
+ setToasts((current) => [...current.slice(-4), { id, tone, message, duration: options?.duration ?? TOAST_DURATION }])
82
+ return id
83
+ }
84
+ return { success: push('success'), error: push('error'), info: push('info'), dismiss }
85
+ }, [dismiss])
86
+
87
+ return (
88
+ <ToastContext.Provider value={api}>
89
+ {children}
90
+ {createPortal(
91
+ <div
92
+ role="region"
93
+ aria-live="polite"
94
+ aria-label="Notifications"
95
+ className="fixed bottom-4 right-4 left-4 sm:left-auto sm:w-96 flex flex-col items-end gap-2 pointer-events-none"
96
+ style={{ zIndex: MODAL_Z_INDEX + 1 }}
97
+ >
98
+ {toasts.map((toast) => <ToastItem key={toast.id} toast={toast} onDismiss={dismiss} />)}
99
+ </div>,
100
+ document.body,
101
+ )}
102
+ </ToastContext.Provider>
103
+ )
104
+ }
@@ -1,9 +1,9 @@
1
- export { Alert, type AlertTone } from './Alert'
2
- export { Field, type FieldControlProps } from './Field'
3
- export { PageContainer, PageHeader } from './PageHeader'
4
- export { QuickStartPanel, type QuickStartOption } from './QuickStartPanel'
5
- export { Pagination } from './Pagination'
6
- export { DataTable, DataTh, MAX_COLUMN_WIDTH, MIN_COLUMN_WIDTH, ResizeHandle, useTableSort, type SortDirection, type SortValue, type TableSort } from './DataTable'
7
- export { EmptyState, EmptyStateLink, EmptyTableRow, ErrorState, LoadingState } from './StateViews'
8
- export { Switch } from './Switch'
9
- export { TOAST_DURATION, ToastProvider, useToast, type ToastApi, type ToastOptions, type ToastTone } from './Toast'
1
+ export { Alert, type AlertTone } from './Alert'
2
+ export { Field, type FieldControlProps } from './Field'
3
+ export { PageContainer, PageHeader } from './PageHeader'
4
+ export { QuickStartPanel, type QuickStartOption } from './QuickStartPanel'
5
+ export { Pagination } from './Pagination'
6
+ export { DataTable, DataTh, MAX_COLUMN_WIDTH, MIN_COLUMN_WIDTH, ResizeHandle, useTableSort, type SortDirection, type SortValue, type TableSort } from './DataTable'
7
+ export { EmptyState, EmptyStateLink, EmptyTableRow, ErrorState, LoadingState } from './StateViews'
8
+ export { Switch } from './Switch'
9
+ export { TOAST_DURATION, ToastProvider, useToast, type ToastApi, type ToastOptions, type ToastTone } from './Toast'
package/src/config.ts CHANGED
@@ -1,46 +1,46 @@
1
- export interface UiConfig {
2
- /** Names what the interface keeps in the browser: keys in storage and window events start with it (`<prefix>-dark-mode`). */
3
- storagePrefix: string
4
- /** Where the API answers. */
5
- apiBase: string
6
- /** The cookie the server puts the CSRF token in; it is sent back in a header on every change. */
7
- csrfCookie: string
8
- /** Where the interface is served, without a trailing slash (`/admin`): the pages to sign in and to change a password are below it. */
9
- basePath: string
10
- /** For a user who does not belong in this interface (say, one who may only view a public page): the address to send them to instead. */
11
- redirectAfterSignIn?: (user: { role: string }) => string | null
12
- /** The names and pictures the application is known by. */
13
- brand: { name: string; logoLight: string; logoDark: string }
14
- /** Wording that names things only the application knows. */
15
- texts: {
16
- /** What an API token can never manage, in the sentence "It cannot manage ..." (for example `users, single sign-on, vaults or other tokens`). */
17
- tokenCannotManage: string
18
- /** What a token with the vault permission can attach a vault secret to (`a job or channel`). */
19
- tokenVaultTargets: string
20
- /** Said when a test sign-in would create an account; `{user}` is replaced by the account. */
21
- ssoNewAccount: string
22
- /** Added to the single sign-on settings to say who may sign in; empty adds nothing. */
23
- ssoSignInHint: string
24
- /** The name of the application, as the interface calls it in sentences. */
25
- appName: string
26
- /** What uses the secrets kept in a vault (`jobs`), in sentences like "... that jobs can use". */
27
- secretConsumers: string
28
- /** An example of the path of a secret in an external vault. */
29
- vaultPathExample: string
30
- }
31
- }
32
-
33
- let current: UiConfig = { storagePrefix: 'better', apiBase: '/api/v1', csrfCookie: 'better_csrf', basePath: '', brand: { name: 'Better', logoLight: '', logoDark: '' }, texts: { ssoNewAccount: 'Creates the account {user} and links this identity-provider account to it.', ssoSignInHint: '', appName: 'This application', secretConsumers: 'the application', vaultPathExample: 'app/database', tokenCannotManage: 'users, single sign-on, vaults or other tokens', tokenVaultTargets: 'an object' } }
34
-
35
- /**
36
- * Tells the interface what application it is part of. Call it before anything is rendered; what is read from storage
37
- * is read on first use, after this.
38
- */
39
- export function configureUi(options: Partial<UiConfig>): void {
40
- current = { ...current, ...options }
41
- }
42
-
43
- export const uiConfig = (): UiConfig => current
44
-
45
- /** The name the interface uses for `name` in storage and in window events. */
46
- export const storageKey = (name: string): string => `${current.storagePrefix}-${name}`
1
+ export interface UiConfig {
2
+ /** Names what the interface keeps in the browser: keys in storage and window events start with it (`<prefix>-dark-mode`). */
3
+ storagePrefix: string
4
+ /** Where the API answers. */
5
+ apiBase: string
6
+ /** The cookie the server puts the CSRF token in; it is sent back in a header on every change. */
7
+ csrfCookie: string
8
+ /** Where the interface is served, without a trailing slash (`/admin`): the pages to sign in and to change a password are below it. */
9
+ basePath: string
10
+ /** For a user who does not belong in this interface (say, one who may only view a public page): the address to send them to instead. */
11
+ redirectAfterSignIn?: (user: { role: string }) => string | null
12
+ /** The names and pictures the application is known by. */
13
+ brand: { name: string; logoLight: string; logoDark: string }
14
+ /** Wording that names things only the application knows. */
15
+ texts: {
16
+ /** What an API token can never manage, in the sentence "It cannot manage ..." (for example `users, single sign-on, vaults or other tokens`). */
17
+ tokenCannotManage: string
18
+ /** What a token with the vault permission can attach a vault secret to (`a job or channel`). */
19
+ tokenVaultTargets: string
20
+ /** Said when a test sign-in would create an account; `{user}` is replaced by the account. */
21
+ ssoNewAccount: string
22
+ /** Added to the single sign-on settings to say who may sign in; empty adds nothing. */
23
+ ssoSignInHint: string
24
+ /** The name of the application, as the interface calls it in sentences. */
25
+ appName: string
26
+ /** What uses the secrets kept in a vault (`jobs`), in sentences like "... that jobs can use". */
27
+ secretConsumers: string
28
+ /** An example of the path of a secret in an external vault. */
29
+ vaultPathExample: string
30
+ }
31
+ }
32
+
33
+ let current: UiConfig = { storagePrefix: 'better', apiBase: '/api/v1', csrfCookie: 'better_csrf', basePath: '', brand: { name: 'Better', logoLight: '', logoDark: '' }, texts: { ssoNewAccount: 'Creates the account {user} and links this identity-provider account to it.', ssoSignInHint: '', appName: 'This application', secretConsumers: 'the application', vaultPathExample: 'app/database', tokenCannotManage: 'users, single sign-on, vaults or other tokens', tokenVaultTargets: 'an object' } }
34
+
35
+ /**
36
+ * Tells the interface what application it is part of. Call it before anything is rendered; what is read from storage
37
+ * is read on first use, after this.
38
+ */
39
+ export function configureUi(options: Partial<UiConfig>): void {
40
+ current = { ...current, ...options }
41
+ }
42
+
43
+ export const uiConfig = (): UiConfig => current
44
+
45
+ /** The name the interface uses for `name` in storage and in window events. */
46
+ export const storageKey = (name: string): string => `${current.storagePrefix}-${name}`
@@ -1,38 +1,38 @@
1
- import { useState, useEffect } from 'react'
2
- import { safeLocalStorage } from '@better-forge/contracts'
3
- import { storageKey } from '../config'
4
-
5
- // The public status app keeps a copy of this hook with the same behaviour, since it does not use this package.
6
-
7
- export function useDarkMode(): [boolean, () => void] {
8
- const [isDark, setIsDark] = useState(() => {
9
- if (typeof window === 'undefined') return false
10
- const stored = safeLocalStorage.get(storageKey('dark-mode'))
11
- if (stored !== null) return stored === 'true'
12
- return window.matchMedia('(prefers-color-scheme: dark)').matches
13
- })
14
-
15
- useEffect(() => {
16
- const html = document.documentElement
17
- if (isDark) {
18
- html.classList.add('dark')
19
- } else {
20
- html.classList.remove('dark')
21
- }
22
- safeLocalStorage.set(storageKey('dark-mode'), String(isDark))
23
- // Every mounted instance (e.g. the sidebar toggle and a page that swaps its logo) listens for this, so one toggle
24
- // updates them all.
25
- window.dispatchEvent(new CustomEvent(storageKey('dark-mode-change'), { detail: isDark }))
26
- }, [isDark])
27
-
28
- useEffect(() => {
29
- const handler = (e: Event) => {
30
- const dark = (e as CustomEvent<boolean>).detail
31
- setIsDark(dark)
32
- }
33
- window.addEventListener(storageKey('dark-mode-change'), handler)
34
- return () => window.removeEventListener(storageKey('dark-mode-change'), handler)
35
- }, [])
36
-
37
- return [isDark, () => setIsDark((d) => !d)]
38
- }
1
+ import { useState, useEffect } from 'react'
2
+ import { safeLocalStorage } from '@better-forge/contracts'
3
+ import { storageKey } from '../config'
4
+
5
+ // The public status app keeps a copy of this hook with the same behaviour, since it does not use this package.
6
+
7
+ export function useDarkMode(): [boolean, () => void] {
8
+ const [isDark, setIsDark] = useState(() => {
9
+ if (typeof window === 'undefined') return false
10
+ const stored = safeLocalStorage.get(storageKey('dark-mode'))
11
+ if (stored !== null) return stored === 'true'
12
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
13
+ })
14
+
15
+ useEffect(() => {
16
+ const html = document.documentElement
17
+ if (isDark) {
18
+ html.classList.add('dark')
19
+ } else {
20
+ html.classList.remove('dark')
21
+ }
22
+ safeLocalStorage.set(storageKey('dark-mode'), String(isDark))
23
+ // Every mounted instance (e.g. the sidebar toggle and a page that swaps its logo) listens for this, so one toggle
24
+ // updates them all.
25
+ window.dispatchEvent(new CustomEvent(storageKey('dark-mode-change'), { detail: isDark }))
26
+ }, [isDark])
27
+
28
+ useEffect(() => {
29
+ const handler = (e: Event) => {
30
+ const dark = (e as CustomEvent<boolean>).detail
31
+ setIsDark(dark)
32
+ }
33
+ window.addEventListener(storageKey('dark-mode-change'), handler)
34
+ return () => window.removeEventListener(storageKey('dark-mode-change'), handler)
35
+ }, [])
36
+
37
+ return [isDark, () => setIsDark((d) => !d)]
38
+ }
@@ -1,22 +1,22 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react'
2
-
3
- /**
4
- * Returns `value` once it has stopped changing for `delay` ms, plus a `flush` that applies it immediately
5
- * (e.g. on Enter) or sets an explicit value (e.g. clearing a search box).
6
- */
7
- export function useDebouncedValue<T>(value: T, delay = 300): [T, (next?: T) => void] {
8
- const [debounced, setDebounced] = useState(value)
9
- const latest = useRef(value)
10
-
11
- useEffect(() => {
12
- latest.current = value
13
- const timer = setTimeout(() => setDebounced(value), delay)
14
- return () => clearTimeout(timer)
15
- }, [value, delay])
16
-
17
- const flush = useCallback((next?: T) => {
18
- setDebounced(next === undefined ? latest.current : next)
19
- }, [])
20
-
21
- return [debounced, flush]
22
- }
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+
3
+ /**
4
+ * Returns `value` once it has stopped changing for `delay` ms, plus a `flush` that applies it immediately
5
+ * (e.g. on Enter) or sets an explicit value (e.g. clearing a search box).
6
+ */
7
+ export function useDebouncedValue<T>(value: T, delay = 300): [T, (next?: T) => void] {
8
+ const [debounced, setDebounced] = useState(value)
9
+ const latest = useRef(value)
10
+
11
+ useEffect(() => {
12
+ latest.current = value
13
+ const timer = setTimeout(() => setDebounced(value), delay)
14
+ return () => clearTimeout(timer)
15
+ }, [value, delay])
16
+
17
+ const flush = useCallback((next?: T) => {
18
+ setDebounced(next === undefined ? latest.current : next)
19
+ }, [])
20
+
21
+ return [debounced, flush]
22
+ }
package/src/index.ts CHANGED
@@ -1,42 +1,42 @@
1
- export * from './components/ui'
2
- export { ConfirmModal } from './components/ConfirmModal'
3
- export { ConfirmIdentityModal } from './components/ConfirmIdentityModal'
4
- export { ErrorBoundary, isChunkLoadError } from './components/ErrorBoundary'
5
- export { CopyButton } from './components/CopyButton'
6
- export { Modal, ModalFooter, ModalHeader, ModalShell, MODAL_BACKDROP, MODAL_Z_INDEX } from './components/ModalShell'
7
- export { SidePanelFrame, SideTabStrip, type SidePanelLayout, type SidePanelMeta, type SideTab } from './components/SidePanel'
8
- export { VaultTypePicker, VAULT_TYPE_ICONS, isReferenceVault, type VaultTypeValue } from './components/VaultTypePicker'
9
- export { useDebouncedValue } from './hooks/useDebouncedValue'
10
- export { compareVersions, parseChangelog, releasesBetween, summarizeRelease, type ReleaseGroup, type ReleaseItem, type ReleaseNotes, type ReleaseSummary } from './lib/changelog'
11
- export { configureUi, storageKey } from './config'
12
- export { useDarkMode } from './hooks/useDarkMode'
13
- export {
14
- DATE_FORMATS, TIME_FORMATS, formatClock, formatDate, formatDateTime, formatDayMonth, getDatePrefs, setDatePrefs, useDatePrefs,
15
- type DateFormat, type DatePrefs, type TimeFormat,
16
- } from './lib/dateFormat'
17
- export { uiConfig, type UiConfig } from './config'
18
- export { navigation } from './navigation'
19
- export {
20
- ApiError, api, clearSession, enterAfterSignIn, getCurrentUser, isAuthenticated, mustChangePassword, setSession, type AuthUser,
21
- } from './api/client'
22
- export { WhatsNew, markWhatsNewSeen, pendingWhatsNew } from './components/WhatsNew'
23
- export { createAdminShell, type AdminShellConfig, type ExternalLink, type NavItem, type NavSection } from './shell/AdminShell'
24
- export { default as BackupsPage } from './pages/Backups'
25
- export { default as ChangePasswordPage } from './pages/ChangePassword'
26
- export { default as SettingsPage } from './pages/Settings'
27
- export { default as SsoConfirmPage } from './pages/SsoConfirm'
28
- export { default as SsoTestResultPage } from './pages/SsoTestResult'
29
- export { ResetTwoFactorModal } from './components/ResetTwoFactorModal'
30
- export { SingleSignOnModal } from './components/SingleSignOnModal'
31
- export {
32
- ssoConfirmChannel, SsoConfirmationNote, isSsoConfirmationRequired, isSsoSession, useSsoConfirmation, type SsoConfirmationState,
33
- } from './components/SsoConfirmation'
34
- export { default as ApiTokensPage } from './pages/ApiTokens'
35
- export { default as AuditLogPage, type AuditEntities } from './pages/AuditLog'
36
- export { default as UsersPage, type UserRole, type UsersPageAction, type UsersPageProps } from './pages/Users'
37
- export { ConnectionStringSection, CredentialSection } from './components/CredentialSection'
38
- export { JSON_MAPPING_FIELDS, Note, SectionDivider, toggleInSet, type SecretSummary, type VaultPickerProps, type VaultSummary } from './components/formParts'
39
- export { default as VaultPage } from './pages/Vault'
40
- export { default as LoginPage, type LoginPageProps } from './pages/Login'
41
- export { default as SetupPage } from './pages/Setup'
42
- export { RequireAuth, RequirePasswordChanged, SetupGate, createRoleGuard } from './shell/guards'
1
+ export * from './components/ui'
2
+ export { ConfirmModal } from './components/ConfirmModal'
3
+ export { ConfirmIdentityModal } from './components/ConfirmIdentityModal'
4
+ export { ErrorBoundary, isChunkLoadError } from './components/ErrorBoundary'
5
+ export { CopyButton } from './components/CopyButton'
6
+ export { Modal, ModalFooter, ModalHeader, ModalShell, MODAL_BACKDROP, MODAL_Z_INDEX } from './components/ModalShell'
7
+ export { SidePanelFrame, SideTabStrip, type SidePanelLayout, type SidePanelMeta, type SideTab } from './components/SidePanel'
8
+ export { VaultTypePicker, VAULT_TYPE_ICONS, isReferenceVault, type VaultTypeValue } from './components/VaultTypePicker'
9
+ export { useDebouncedValue } from './hooks/useDebouncedValue'
10
+ export { compareVersions, parseChangelog, releasesBetween, summarizeRelease, type ReleaseGroup, type ReleaseItem, type ReleaseNotes, type ReleaseSummary } from './lib/changelog'
11
+ export { configureUi, storageKey } from './config'
12
+ export { useDarkMode } from './hooks/useDarkMode'
13
+ export {
14
+ DATE_FORMATS, TIME_FORMATS, formatClock, formatDate, formatDateTime, formatDayMonth, getDatePrefs, setDatePrefs, useDatePrefs,
15
+ type DateFormat, type DatePrefs, type TimeFormat,
16
+ } from './lib/dateFormat'
17
+ export { uiConfig, type UiConfig } from './config'
18
+ export { navigation } from './navigation'
19
+ export {
20
+ ApiError, api, clearSession, enterAfterSignIn, getCurrentUser, isAuthenticated, mustChangePassword, setSession, type AuthUser,
21
+ } from './api/client'
22
+ export { WhatsNew, markWhatsNewSeen, pendingWhatsNew } from './components/WhatsNew'
23
+ export { createAdminShell, type AdminShellConfig, type ExternalLink, type NavItem, type NavSection } from './shell/AdminShell'
24
+ export { default as BackupsPage } from './pages/Backups'
25
+ export { default as ChangePasswordPage } from './pages/ChangePassword'
26
+ export { default as SettingsPage } from './pages/Settings'
27
+ export { default as SsoConfirmPage } from './pages/SsoConfirm'
28
+ export { default as SsoTestResultPage } from './pages/SsoTestResult'
29
+ export { ResetTwoFactorModal } from './components/ResetTwoFactorModal'
30
+ export { SingleSignOnModal } from './components/SingleSignOnModal'
31
+ export {
32
+ ssoConfirmChannel, SsoConfirmationNote, isSsoConfirmationRequired, isSsoSession, useSsoConfirmation, type SsoConfirmationState,
33
+ } from './components/SsoConfirmation'
34
+ export { default as ApiTokensPage } from './pages/ApiTokens'
35
+ export { default as AuditLogPage, type AuditEntities } from './pages/AuditLog'
36
+ export { default as UsersPage, type UserRole, type UsersPageAction, type UsersPageProps } from './pages/Users'
37
+ export { ConnectionStringSection, CredentialSection } from './components/CredentialSection'
38
+ export { JSON_MAPPING_FIELDS, Note, SectionDivider, toggleInSet, type SecretSummary, type VaultPickerProps, type VaultSummary } from './components/formParts'
39
+ export { default as VaultPage } from './pages/Vault'
40
+ export { default as LoginPage, type LoginPageProps } from './pages/Login'
41
+ export { default as SetupPage } from './pages/Setup'
42
+ export { RequireAuth, RequirePasswordChanged, SetupGate, createRoleGuard } from './shell/guards'