@nous-research/ui 0.15.0 → 0.17.0
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.
- package/CHANGELOG.md +266 -0
- package/README.md +24 -4
- package/dist/fonts.js +1 -0
- package/dist/hooks/use-below-breakpoint.d.ts +2 -0
- package/dist/hooks/use-below-breakpoint.js +17 -0
- package/dist/hooks/use-capped-frame.js +1 -0
- package/dist/hooks/use-confirm-delete.d.ts +10 -0
- package/dist/hooks/use-confirm-delete.js +35 -0
- package/dist/hooks/use-css-var-dims.js +1 -0
- package/dist/hooks/use-gpu-tier.js +1 -0
- package/dist/hooks/use-render-loop.js +1 -0
- package/dist/hooks/use-smooth-controls.js +1 -0
- package/dist/hooks/use-toast.d.ts +7 -0
- package/dist/hooks/use-toast.js +21 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +23 -1
- package/dist/ui/basic-page.js +1 -0
- package/dist/ui/components/animated-count.js +1 -0
- package/dist/ui/components/ascii.js +1 -0
- package/dist/ui/components/badge.js +2 -1
- package/dist/ui/components/badges/nous-girl.js +1 -0
- package/dist/ui/components/blend-mode.js +1 -0
- package/dist/ui/components/blink.js +1 -0
- package/dist/ui/components/bottom-sheet.d.ts +15 -0
- package/dist/ui/components/bottom-sheet.js +192 -0
- package/dist/ui/components/button.js +2 -1
- package/dist/ui/components/card.d.ts +5 -0
- package/dist/ui/components/card.js +74 -0
- package/dist/ui/components/checkbox.d.ts +1 -1
- package/dist/ui/components/checkbox.js +2 -1
- package/dist/ui/components/command-block.js +4 -3
- package/dist/ui/components/confirm-dialog.d.ts +13 -0
- package/dist/ui/components/confirm-dialog.js +113 -0
- package/dist/ui/components/cursor.js +1 -0
- package/dist/ui/components/dialog.d.ts +15 -0
- package/dist/ui/components/dialog.js +171 -0
- package/dist/ui/components/dropdown-menu.js +1 -0
- package/dist/ui/components/fit-text/index.js +1 -0
- package/dist/ui/components/graphs/bar-chart.js +1 -0
- package/dist/ui/components/graphs/index.js +1 -0
- package/dist/ui/components/graphs/line-chart.js +1 -0
- package/dist/ui/components/graphs/utils.js +1 -0
- package/dist/ui/components/grid/index.js +1 -0
- package/dist/ui/components/hover-bg.js +1 -0
- package/dist/ui/components/icons/arrow.js +1 -0
- package/dist/ui/components/icons/check.js +1 -0
- package/dist/ui/components/icons/chevron.js +1 -0
- package/dist/ui/components/icons/discord.js +1 -0
- package/dist/ui/components/icons/eye.js +1 -0
- package/dist/ui/components/icons/gear.js +1 -0
- package/dist/ui/components/icons/github.js +1 -0
- package/dist/ui/components/icons/hamburger.js +1 -0
- package/dist/ui/components/icons/heart.js +1 -0
- package/dist/ui/components/icons/index.js +1 -0
- package/dist/ui/components/icons/link.js +1 -0
- package/dist/ui/components/icons/minus.js +1 -0
- package/dist/ui/components/icons/search.js +1 -0
- package/dist/ui/components/image-distortion.js +1 -0
- package/dist/ui/components/input.d.ts +1 -0
- package/dist/ui/components/input.js +21 -0
- package/dist/ui/components/label.d.ts +1 -0
- package/dist/ui/components/label.js +18 -0
- package/dist/ui/components/leva-client.js +1 -0
- package/dist/ui/components/list-item.js +3 -2
- package/dist/ui/components/overlays/blend-modes.js +1 -0
- package/dist/ui/components/overlays/glitch.js +1 -0
- package/dist/ui/components/overlays/greys.js +1 -0
- package/dist/ui/components/overlays/index.js +1 -0
- package/dist/ui/components/overlays/lens-layers.js +1 -0
- package/dist/ui/components/overlays/lens.js +1 -0
- package/dist/ui/components/overlays/noise.js +1 -0
- package/dist/ui/components/overlays/vignette.js +1 -0
- package/dist/ui/components/poster.js +1 -0
- package/dist/ui/components/progress.js +1 -0
- package/dist/ui/components/scene-canvas.js +1 -0
- package/dist/ui/components/scramble.js +1 -0
- package/dist/ui/components/segmented.js +5 -4
- package/dist/ui/components/select.js +1 -0
- package/dist/ui/components/selection-switcher.js +1 -0
- package/dist/ui/components/separator.d.ts +5 -0
- package/dist/ui/components/separator.js +22 -0
- package/dist/ui/components/shader.js +1 -0
- package/dist/ui/components/socials.js +1 -0
- package/dist/ui/components/spinner.js +1 -0
- package/dist/ui/components/stats.js +2 -1
- package/dist/ui/components/switch.js +1 -0
- package/dist/ui/components/tabs.js +4 -3
- package/dist/ui/components/terminal-demo.js +2 -1
- package/dist/ui/components/theme-toggle.js +1 -0
- package/dist/ui/components/tier-card.js +2 -1
- package/dist/ui/components/toast.d.ts +8 -0
- package/dist/ui/components/toast.js +39 -0
- package/dist/ui/components/tv.js +1 -0
- package/dist/ui/components/typography/h1.js +1 -0
- package/dist/ui/components/typography/h2.js +1 -0
- package/dist/ui/components/typography/index.js +1 -0
- package/dist/ui/components/typography/legend.js +1 -0
- package/dist/ui/components/typography/small.js +1 -0
- package/dist/ui/components/watchlist.js +2 -1
- package/dist/ui/footer.js +1 -0
- package/dist/ui/globals.css +47 -3
- package/dist/ui/header.js +1 -0
- package/dist/ui/layout-wrapper.js +2 -1
- package/dist/utils/color.js +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/poly.js +1 -0
- package/package.json +5 -3
- package/src/assets/filler-bg0.webp +0 -0
- package/src/assets.d.ts +38 -0
- package/src/fonts/Collapse-Bold.woff2 +0 -0
- package/src/fonts/Collapse-BoldItalic.woff2 +0 -0
- package/src/fonts/Collapse-Italic.woff2 +0 -0
- package/src/fonts/Collapse-Light.woff2 +0 -0
- package/src/fonts/Collapse-LightItalic.woff2 +0 -0
- package/src/fonts/Collapse-Regular.woff2 +0 -0
- package/src/fonts/Collapse-Thin.woff2 +0 -0
- package/src/fonts/Collapse-ThinItalic.woff2 +0 -0
- package/src/fonts/Mondwest-Regular.woff2 +0 -0
- package/src/fonts/Neuebit-Bold.woff2 +0 -0
- package/src/fonts/RulesCompressed-Medium.woff2 +0 -0
- package/src/fonts/RulesCompressed-Regular.woff2 +0 -0
- package/src/fonts/RulesExpanded-Bold.woff2 +0 -0
- package/src/fonts/RulesExpanded-Regular.woff2 +0 -0
- package/src/fonts.ts +6 -0
- package/src/hooks/use-below-breakpoint.ts +21 -0
- package/src/hooks/use-capped-frame.ts +18 -0
- package/src/hooks/use-confirm-delete.ts +43 -0
- package/src/hooks/use-css-var-dims.ts +39 -0
- package/src/hooks/use-gpu-tier.ts +165 -0
- package/src/hooks/use-render-loop.ts +121 -0
- package/src/hooks/use-smooth-controls.ts +318 -0
- package/src/hooks/use-toast.ts +29 -0
- package/src/index.ts +130 -0
- package/src/ui/basic-page.tsx +34 -0
- package/src/ui/build.css +4 -0
- package/src/ui/components/animated-count.stories.tsx +67 -0
- package/src/ui/components/animated-count.tsx +168 -0
- package/src/ui/components/ascii.stories.tsx +30 -0
- package/src/ui/components/ascii.tsx +110 -0
- package/src/ui/components/badge.stories.tsx +31 -0
- package/src/ui/components/badge.tsx +60 -0
- package/src/ui/components/badges/nous-girl.tsx +52 -0
- package/src/ui/components/blend-mode.stories.tsx +33 -0
- package/src/ui/components/blend-mode.tsx +129 -0
- package/src/ui/components/blink.stories.tsx +32 -0
- package/src/ui/components/blink.tsx +21 -0
- package/src/ui/components/bottom-sheet.stories.tsx +43 -0
- package/src/ui/components/bottom-sheet.tsx +227 -0
- package/src/ui/components/button.stories.tsx +68 -0
- package/src/ui/components/button.tsx +170 -0
- package/src/ui/components/card.stories.tsx +63 -0
- package/src/ui/components/card.tsx +85 -0
- package/src/ui/components/checkbox.stories.tsx +113 -0
- package/src/ui/components/checkbox.tsx +36 -0
- package/src/ui/components/command-block.stories.tsx +52 -0
- package/src/ui/components/command-block.tsx +86 -0
- package/src/ui/components/confirm-dialog.stories.tsx +91 -0
- package/src/ui/components/confirm-dialog.tsx +130 -0
- package/src/ui/components/cursor.tsx +115 -0
- package/src/ui/components/dialog.stories.tsx +169 -0
- package/src/ui/components/dialog.tsx +177 -0
- package/src/ui/components/dropdown-menu.stories.tsx +52 -0
- package/src/ui/components/dropdown-menu.tsx +117 -0
- package/src/ui/components/fit-text/fit-text.css +42 -0
- package/src/ui/components/fit-text/index.stories.tsx +33 -0
- package/src/ui/components/fit-text/index.tsx +45 -0
- package/src/ui/components/forms.stories.tsx +173 -0
- package/src/ui/components/graphs/bar-chart.tsx +153 -0
- package/src/ui/components/graphs/index.stories.tsx +64 -0
- package/src/ui/components/graphs/index.tsx +4 -0
- package/src/ui/components/graphs/line-chart.tsx +213 -0
- package/src/ui/components/graphs/utils.tsx +265 -0
- package/src/ui/components/grid/grid.css +79 -0
- package/src/ui/components/grid/index.tsx +19 -0
- package/src/ui/components/hover-bg.stories.tsx +29 -0
- package/src/ui/components/hover-bg.tsx +15 -0
- package/src/ui/components/icons/arrow.tsx +42 -0
- package/src/ui/components/icons/check.tsx +14 -0
- package/src/ui/components/icons/chevron.tsx +45 -0
- package/src/ui/components/icons/discord.tsx +16 -0
- package/src/ui/components/icons/eye.tsx +12 -0
- package/src/ui/components/icons/gear.tsx +51 -0
- package/src/ui/components/icons/github.tsx +16 -0
- package/src/ui/components/icons/hamburger.tsx +52 -0
- package/src/ui/components/icons/heart.tsx +12 -0
- package/src/ui/components/icons/index.ts +12 -0
- package/src/ui/components/icons/link.tsx +14 -0
- package/src/ui/components/icons/minus.tsx +14 -0
- package/src/ui/components/icons/search.tsx +28 -0
- package/src/ui/components/image-distortion.stories.tsx +120 -0
- package/src/ui/components/image-distortion.tsx +498 -0
- package/src/ui/components/input.stories.tsx +39 -0
- package/src/ui/components/input.tsx +20 -0
- package/src/ui/components/label.stories.tsx +26 -0
- package/src/ui/components/label.tsx +16 -0
- package/src/ui/components/leva-client.tsx +14 -0
- package/src/ui/components/list-item.stories.tsx +83 -0
- package/src/ui/components/list-item.tsx +37 -0
- package/src/ui/components/overlays/blend-modes.ts +13 -0
- package/src/ui/components/overlays/glitch.tsx +243 -0
- package/src/ui/components/overlays/greys.tsx +386 -0
- package/src/ui/components/overlays/index.tsx +47 -0
- package/src/ui/components/overlays/lens-layers.tsx +119 -0
- package/src/ui/components/overlays/lens.ts +91 -0
- package/src/ui/components/overlays/noise.tsx +174 -0
- package/src/ui/components/overlays/vignette.tsx +60 -0
- package/src/ui/components/poster.stories.tsx +513 -0
- package/src/ui/components/poster.tsx +411 -0
- package/src/ui/components/progress.stories.tsx +48 -0
- package/src/ui/components/progress.tsx +56 -0
- package/src/ui/components/scene-canvas.tsx +254 -0
- package/src/ui/components/scramble.stories.tsx +49 -0
- package/src/ui/components/scramble.tsx +95 -0
- package/src/ui/components/segmented.stories.tsx +101 -0
- package/src/ui/components/segmented.tsx +81 -0
- package/src/ui/components/select.stories.tsx +88 -0
- package/src/ui/components/select.tsx +267 -0
- package/src/ui/components/selection-switcher.tsx +44 -0
- package/src/ui/components/separator.stories.tsx +33 -0
- package/src/ui/components/separator.tsx +24 -0
- package/src/ui/components/shader.tsx +83 -0
- package/src/ui/components/socials.tsx +42 -0
- package/src/ui/components/spinner.stories.tsx +101 -0
- package/src/ui/components/spinner.tsx +60 -0
- package/src/ui/components/stats.stories.tsx +24 -0
- package/src/ui/components/stats.tsx +53 -0
- package/src/ui/components/switch.stories.tsx +77 -0
- package/src/ui/components/switch.tsx +48 -0
- package/src/ui/components/tabs.stories.tsx +101 -0
- package/src/ui/components/tabs.tsx +66 -0
- package/src/ui/components/terminal-demo.stories.tsx +67 -0
- package/src/ui/components/terminal-demo.tsx +189 -0
- package/src/ui/components/theme-toggle.stories.tsx +47 -0
- package/src/ui/components/theme-toggle.tsx +66 -0
- package/src/ui/components/tier-card.stories.tsx +217 -0
- package/src/ui/components/tier-card.tsx +190 -0
- package/src/ui/components/toast.stories.tsx +55 -0
- package/src/ui/components/toast.tsx +49 -0
- package/src/ui/components/tv.stories.tsx +37 -0
- package/src/ui/components/tv.tsx +257 -0
- package/src/ui/components/typography/h1.tsx +18 -0
- package/src/ui/components/typography/h2.tsx +18 -0
- package/src/ui/components/typography/index.tsx +54 -0
- package/src/ui/components/typography/legend.tsx +24 -0
- package/src/ui/components/typography/small.tsx +11 -0
- package/src/ui/components/watchlist.stories.tsx +33 -0
- package/src/ui/components/watchlist.tsx +105 -0
- package/src/ui/fonts.css +63 -0
- package/src/ui/footer.tsx +111 -0
- package/src/ui/globals.css +395 -0
- package/src/ui/header.tsx +398 -0
- package/src/ui/layout-wrapper.tsx +11 -0
- package/src/utils/color.ts +21 -0
- package/src/utils/index.ts +62 -0
- package/src/utils/poly.ts +26 -0
- package/dist/ui/components/modal/index.d.ts +0 -8
- package/dist/ui/components/modal/index.js +0 -34
- package/dist/ui/components/modal/modal.css +0 -36
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export function useConfirmDelete<TId>({
|
|
6
|
+
onDelete
|
|
7
|
+
}: {
|
|
8
|
+
onDelete: (id: TId) => Promise<void>
|
|
9
|
+
}) {
|
|
10
|
+
const [pendingId, setPendingId] = useState<TId | null>(null)
|
|
11
|
+
const [isDeleting, setIsDeleting] = useState(false)
|
|
12
|
+
|
|
13
|
+
const requestDelete = useCallback((id: TId) => {
|
|
14
|
+
setPendingId(id)
|
|
15
|
+
}, [])
|
|
16
|
+
|
|
17
|
+
const cancel = useCallback(() => {
|
|
18
|
+
if (!isDeleting) setPendingId(null)
|
|
19
|
+
}, [isDeleting])
|
|
20
|
+
|
|
21
|
+
const confirm = useCallback(async () => {
|
|
22
|
+
if (pendingId === null) return
|
|
23
|
+
const id = pendingId
|
|
24
|
+
setIsDeleting(true)
|
|
25
|
+
try {
|
|
26
|
+
await onDelete(id)
|
|
27
|
+
setPendingId(null)
|
|
28
|
+
} catch {
|
|
29
|
+
// Dialog stays open; caller can surface errors in onDelete
|
|
30
|
+
} finally {
|
|
31
|
+
setIsDeleting(false)
|
|
32
|
+
}
|
|
33
|
+
}, [pendingId, onDelete])
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
cancel,
|
|
37
|
+
confirm,
|
|
38
|
+
isDeleting,
|
|
39
|
+
isOpen: pendingId !== null,
|
|
40
|
+
pendingId,
|
|
41
|
+
requestDelete
|
|
42
|
+
} as const
|
|
43
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
|
|
5
|
+
export function useCssVarDims(
|
|
6
|
+
name: string,
|
|
7
|
+
ref: React.RefObject<HTMLElement | null>
|
|
8
|
+
) {
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!ref.current) {
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const update = (width: number, height: number) => {
|
|
15
|
+
document.documentElement.style.setProperty(
|
|
16
|
+
`--${name}-width`,
|
|
17
|
+
`${width}px`
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
document.documentElement.style.setProperty(
|
|
21
|
+
`--${name}-height`,
|
|
22
|
+
`${height}px`
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { height, width } = ref.current.getBoundingClientRect()
|
|
27
|
+
update(width, height)
|
|
28
|
+
|
|
29
|
+
const ro = new ResizeObserver(entries => {
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
update(entry.contentRect.width, entry.contentRect.height)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
ro.observe(ref.current)
|
|
36
|
+
|
|
37
|
+
return () => ro.disconnect()
|
|
38
|
+
}, [name, ref])
|
|
39
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useStore } from '@nanostores/react'
|
|
4
|
+
import { atom } from 'nanostores'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tiers:
|
|
8
|
+
* 0 — no WebGL / software renderer / prefers-reduced-motion / WebGL ctx creation failed
|
|
9
|
+
* 1 — low-end GPU (integrated, mobile, or failed perf benchmark)
|
|
10
|
+
* 2 — capable GPU (discrete / high-end integrated)
|
|
11
|
+
*
|
|
12
|
+
* Detection runs **synchronously** the first time this module is evaluated
|
|
13
|
+
* on the client (see the IIFE at the bottom of the file). That means any
|
|
14
|
+
* consumer reading `$gpuTier` during its first render already sees the
|
|
15
|
+
* post-detection value, so WebGL components can avoid trying to create a
|
|
16
|
+
* `THREE.WebGLRenderer` on hardware where context creation will fail.
|
|
17
|
+
*
|
|
18
|
+
* The previous version ran the probe inside `nanostores`'s `onMount`
|
|
19
|
+
* lifecycle, which fires from a microtask after the first listener
|
|
20
|
+
* subscribes — i.e. after the first React commit. By that point overlay
|
|
21
|
+
* components had already executed `new THREE.WebGLRenderer(...)` against
|
|
22
|
+
* the optimistic default, logged `Error creating WebGL context`, and only
|
|
23
|
+
* unmounted on a follow-up render. Eager module-load detection closes that
|
|
24
|
+
* race.
|
|
25
|
+
*/
|
|
26
|
+
export const $gpuTier = atom<GpuTier>(2)
|
|
27
|
+
|
|
28
|
+
const SOFTWARE_PATTERNS =
|
|
29
|
+
/swiftshader|llvmpipe|softpipe|software|microsoft basic/i
|
|
30
|
+
|
|
31
|
+
const LOW_END_PATTERNS =
|
|
32
|
+
/intel.*hd|intel.*uhd|intel.*iris|mali|adreno\s?[1-5]|powervr|apple gpu/i
|
|
33
|
+
|
|
34
|
+
let detected = false
|
|
35
|
+
|
|
36
|
+
function detectGpuTier() {
|
|
37
|
+
if (detected || typeof window === 'undefined') {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
detected = true
|
|
42
|
+
|
|
43
|
+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
44
|
+
$gpuTier.set(0)
|
|
45
|
+
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let gl: null | WebGLRenderingContext = null
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const canvas = document.createElement('canvas')
|
|
53
|
+
gl = (canvas.getContext('webgl') ||
|
|
54
|
+
canvas.getContext('experimental-webgl')) as null | WebGLRenderingContext
|
|
55
|
+
} catch {
|
|
56
|
+
// Some sandboxed / hardened contexts throw on getContext rather than
|
|
57
|
+
// returning null (e.g. certain corporate browser policies). Treat as
|
|
58
|
+
// "no WebGL available".
|
|
59
|
+
$gpuTier.set(0)
|
|
60
|
+
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!gl) {
|
|
65
|
+
$gpuTier.set(0)
|
|
66
|
+
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ext = gl.getExtension('WEBGL_debug_renderer_info')
|
|
71
|
+
const renderer = String(
|
|
72
|
+
ext
|
|
73
|
+
? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)
|
|
74
|
+
: gl.getParameter(gl.RENDERER)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if (SOFTWARE_PATTERNS.test(renderer)) {
|
|
78
|
+
$gpuTier.set(0)
|
|
79
|
+
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
|
80
|
+
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (LOW_END_PATTERNS.test(renderer)) {
|
|
85
|
+
$gpuTier.set(1)
|
|
86
|
+
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
|
87
|
+
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
$gpuTier.set(2)
|
|
92
|
+
|
|
93
|
+
runBenchmark(gl)
|
|
94
|
+
.then(fps => $gpuTier.set(fps < 30 ? 1 : 2))
|
|
95
|
+
.catch(() => $gpuTier.set(1))
|
|
96
|
+
.finally(() => gl?.getExtension('WEBGL_lose_context')?.loseContext())
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (typeof window !== 'undefined') {
|
|
100
|
+
detectGpuTier()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function runBenchmark(gl: WebGLRenderingContext): Promise<number> {
|
|
104
|
+
return new Promise(resolve => {
|
|
105
|
+
const vs = gl.createShader(gl.VERTEX_SHADER)!
|
|
106
|
+
const fs = gl.createShader(gl.FRAGMENT_SHADER)!
|
|
107
|
+
gl.shaderSource(
|
|
108
|
+
vs,
|
|
109
|
+
'attribute vec2 a;void main(){gl_Position=vec4(a,0,1);}'
|
|
110
|
+
)
|
|
111
|
+
gl.shaderSource(
|
|
112
|
+
fs,
|
|
113
|
+
'precision highp float;uniform float t;void main(){float v=0.;for(int i=0;i<64;i++)v+=sin(float(i)*t*.01);gl_FragColor=vec4(v*.001);}'
|
|
114
|
+
)
|
|
115
|
+
gl.compileShader(vs)
|
|
116
|
+
gl.compileShader(fs)
|
|
117
|
+
|
|
118
|
+
const prog = gl.createProgram()!
|
|
119
|
+
gl.attachShader(prog, vs)
|
|
120
|
+
gl.attachShader(prog, fs)
|
|
121
|
+
gl.linkProgram(prog)
|
|
122
|
+
gl.useProgram(prog)
|
|
123
|
+
|
|
124
|
+
const buf = gl.createBuffer()
|
|
125
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
|
|
126
|
+
gl.bufferData(
|
|
127
|
+
gl.ARRAY_BUFFER,
|
|
128
|
+
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
|
|
129
|
+
gl.STATIC_DRAW
|
|
130
|
+
)
|
|
131
|
+
const a = gl.getAttribLocation(prog, 'a')
|
|
132
|
+
gl.enableVertexAttribArray(a)
|
|
133
|
+
gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0)
|
|
134
|
+
|
|
135
|
+
const uT = gl.getUniformLocation(prog, 't')
|
|
136
|
+
let frames = 0
|
|
137
|
+
const start = performance.now()
|
|
138
|
+
|
|
139
|
+
const tick = () => {
|
|
140
|
+
gl.uniform1f(uT, frames)
|
|
141
|
+
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
|
142
|
+
gl.finish()
|
|
143
|
+
frames++
|
|
144
|
+
|
|
145
|
+
if (performance.now() - start < 200) {
|
|
146
|
+
requestAnimationFrame(tick)
|
|
147
|
+
} else {
|
|
148
|
+
const elapsed = performance.now() - start
|
|
149
|
+
gl.deleteProgram(prog)
|
|
150
|
+
gl.deleteShader(vs)
|
|
151
|
+
gl.deleteShader(fs)
|
|
152
|
+
gl.deleteBuffer(buf)
|
|
153
|
+
resolve((frames / elapsed) * 1000)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
requestAnimationFrame(tick)
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function useGpuTier() {
|
|
162
|
+
return useStore($gpuTier)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type GpuTier = 0 | 1 | 2
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Visibility- and intersection-aware render-loop helper for the WebGL
|
|
5
|
+
* overlays.
|
|
6
|
+
*
|
|
7
|
+
* The overlays were previously running fragment shaders at 60fps for the
|
|
8
|
+
* entire lifetime of the page — including when the tab was hidden, the
|
|
9
|
+
* canvas had been scrolled out of view, or the user had been idle for
|
|
10
|
+
* hours. On retina laptops the compositor cost of mix-blend-mode on a
|
|
11
|
+
* full-viewport canvas plus continuous WebGL rasterisation is enough to
|
|
12
|
+
* keep the GPU hot indefinitely, which is what manifests as "fans go
|
|
13
|
+
* crazy after 2 hours of idle".
|
|
14
|
+
*
|
|
15
|
+
* `runRenderLoop` wraps a frame callback so that it:
|
|
16
|
+
*
|
|
17
|
+
* 1. Pauses entirely when `document.hidden` is true (background tab,
|
|
18
|
+
* minimised window, screen locked).
|
|
19
|
+
* 2. Pauses when the canvas's bounding rect is offscreen (we tell
|
|
20
|
+
* `IntersectionObserver` to look at the canvas itself).
|
|
21
|
+
* 3. Optionally caps the frame rate via a min-interval — the previous
|
|
22
|
+
* `gpuTier === 1 ? setTimeout(loop, 100) : raf` trick is preserved
|
|
23
|
+
* and extended so even tier-2 GPUs cap at e.g. 30fps for overlays
|
|
24
|
+
* that don't need 60.
|
|
25
|
+
*
|
|
26
|
+
* The callback receives the *delta* time in seconds since the last call
|
|
27
|
+
* (so `uTime` advances correctly across pauses without ever skipping
|
|
28
|
+
* forward by hours).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
interface RunRenderLoopOptions {
|
|
32
|
+
/** Element to observe with IntersectionObserver. When fully out of
|
|
33
|
+
* view, the loop pauses. Pass the canvas element itself. */
|
|
34
|
+
el: Element
|
|
35
|
+
/** Min ms between frames. 0 = no cap (uses requestAnimationFrame).
|
|
36
|
+
* Anything > 0 uses setTimeout-driven scheduling. */
|
|
37
|
+
minIntervalMs?: number
|
|
38
|
+
/** Frame callback. Receives the elapsed seconds since the previous
|
|
39
|
+
* *executed* frame (not since the previous scheduled frame), so
|
|
40
|
+
* uniforms keyed off this value will not jump after a long pause. */
|
|
41
|
+
onFrame: (deltaSeconds: number) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function runRenderLoop({
|
|
45
|
+
el,
|
|
46
|
+
minIntervalMs = 0,
|
|
47
|
+
onFrame
|
|
48
|
+
}: RunRenderLoopOptions) {
|
|
49
|
+
let running = true
|
|
50
|
+
let visible = !document.hidden
|
|
51
|
+
let inView = true
|
|
52
|
+
let last = performance.now()
|
|
53
|
+
let raf = 0
|
|
54
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
55
|
+
|
|
56
|
+
const onVisibility = () => {
|
|
57
|
+
visible = !document.hidden
|
|
58
|
+
|
|
59
|
+
// When we come back from a hidden tab, reset the clock so the next
|
|
60
|
+
// frame's delta is ~one frame, not "hours since I was hidden".
|
|
61
|
+
if (visible) {
|
|
62
|
+
last = performance.now()
|
|
63
|
+
schedule()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const io = new IntersectionObserver(
|
|
68
|
+
entries => {
|
|
69
|
+
const wasInView = inView
|
|
70
|
+
inView = entries.some(e => e.isIntersecting)
|
|
71
|
+
|
|
72
|
+
if (!wasInView && inView) {
|
|
73
|
+
last = performance.now()
|
|
74
|
+
schedule()
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{ threshold: 0 }
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
io.observe(el)
|
|
81
|
+
document.addEventListener('visibilitychange', onVisibility)
|
|
82
|
+
|
|
83
|
+
const tick = () => {
|
|
84
|
+
if (!running) return
|
|
85
|
+
|
|
86
|
+
if (!visible || !inView) {
|
|
87
|
+
// Don't reschedule — we'll be re-kicked by visibilitychange or IO.
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const now = performance.now()
|
|
92
|
+
const delta = (now - last) / 1000
|
|
93
|
+
last = now
|
|
94
|
+
|
|
95
|
+
onFrame(delta)
|
|
96
|
+
schedule()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function schedule() {
|
|
100
|
+
if (!running || !visible || !inView) return
|
|
101
|
+
|
|
102
|
+
if (minIntervalMs > 0) {
|
|
103
|
+
timer = setTimeout(tick, minIntervalMs)
|
|
104
|
+
} else {
|
|
105
|
+
raf = requestAnimationFrame(tick)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
schedule()
|
|
110
|
+
|
|
111
|
+
return () => {
|
|
112
|
+
running = false
|
|
113
|
+
io.disconnect()
|
|
114
|
+
document.removeEventListener('visibilitychange', onVisibility)
|
|
115
|
+
cancelAnimationFrame(raf)
|
|
116
|
+
|
|
117
|
+
if (timer !== undefined) {
|
|
118
|
+
clearTimeout(timer)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import gsap from 'gsap'
|
|
4
|
+
import { buttonGroup, useControls } from 'leva'
|
|
5
|
+
import { atom, type WritableAtom } from 'nanostores'
|
|
6
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
7
|
+
|
|
8
|
+
const atomRegistry = new Map<string, Map<string, WritableAtom<any>>>()
|
|
9
|
+
|
|
10
|
+
const val = (v: any) =>
|
|
11
|
+
v && typeof v === 'object' && 'value' in v ? v.value : v
|
|
12
|
+
|
|
13
|
+
const isHex = (v: any) =>
|
|
14
|
+
/color/i.test(v?.type) || /^#[0-9a-f]{3,8}$/i.test(val(v))
|
|
15
|
+
|
|
16
|
+
const randHex = () =>
|
|
17
|
+
`#${Math.floor(Math.random() * 0xffffff)
|
|
18
|
+
.toString(16)
|
|
19
|
+
.padStart(6, '0')}`
|
|
20
|
+
|
|
21
|
+
const randNum = (v: any) =>
|
|
22
|
+
typeof v === 'object' && ('min' in v || 'max' in v)
|
|
23
|
+
? gsap.utils.random(v.min ?? 0, v.max ?? 1, v.step ?? 0.01)
|
|
24
|
+
: gsap.utils.random(0, 1)
|
|
25
|
+
|
|
26
|
+
export function useSmoothControls<T extends Record<string, any>>(
|
|
27
|
+
label: string,
|
|
28
|
+
initialArgs: T,
|
|
29
|
+
options?: UseSmoothControlsOptions,
|
|
30
|
+
dependencies?: Parameters<typeof useControls>[3]
|
|
31
|
+
) {
|
|
32
|
+
type R = { [K in keyof T]: T[K] extends { value: infer V } ? V : never }
|
|
33
|
+
|
|
34
|
+
const entries = useMemo(
|
|
35
|
+
() => Object.entries(initialArgs ?? {}),
|
|
36
|
+
[initialArgs]
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const values = useMemo(
|
|
40
|
+
() => entries.filter(([, v]) => !/button|folder/i.test(v?.type)),
|
|
41
|
+
[entries]
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// Tracks whether this component instance has mounted yet. When a remount
|
|
45
|
+
// happens (e.g. Storybook changing a `key` prop to force a lens reset), we
|
|
46
|
+
// want the module-scoped atoms to be reseeded from the new `initialArgs` so
|
|
47
|
+
// the first paint reflects the newly-selected preset — not leftover values
|
|
48
|
+
// from the previous mount.
|
|
49
|
+
const mountedRef = useRef(false)
|
|
50
|
+
|
|
51
|
+
const atoms = useMemo(() => {
|
|
52
|
+
const map = atomRegistry.get(label) ?? new Map<string, WritableAtom<any>>()
|
|
53
|
+
|
|
54
|
+
if (!atomRegistry.has(label)) {
|
|
55
|
+
atomRegistry.set(label, map)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const freshMount = !mountedRef.current
|
|
59
|
+
|
|
60
|
+
entries.forEach(([k, v]) => {
|
|
61
|
+
if (v?.schema) {
|
|
62
|
+
Object.keys(v.schema).forEach(sk => {
|
|
63
|
+
const key = `${k}.${sk}`
|
|
64
|
+
|
|
65
|
+
if (!map.has(key)) {
|
|
66
|
+
map.set(key, atom(val(v.schema[sk])))
|
|
67
|
+
} else if (freshMount) {
|
|
68
|
+
map.get(key)!.set(val(v.schema[sk]))
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
} else if (!map.has(k)) {
|
|
72
|
+
map.set(k, atom(val(v)))
|
|
73
|
+
} else if (freshMount) {
|
|
74
|
+
map.get(k)!.set(val(v))
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
return map
|
|
79
|
+
}, [label, entries])
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
mountedRef.current = true
|
|
83
|
+
}, [])
|
|
84
|
+
|
|
85
|
+
const hydrate = useCallback(
|
|
86
|
+
() =>
|
|
87
|
+
Object.fromEntries(
|
|
88
|
+
entries.flatMap(([k, v]) =>
|
|
89
|
+
v?.schema
|
|
90
|
+
? Object.entries(v.schema).map(([k0, v0]: [string, any]) => [
|
|
91
|
+
k0,
|
|
92
|
+
atoms.get(`${k}.${k0}`)?.get() ?? val(v0)
|
|
93
|
+
])
|
|
94
|
+
: [[k, atoms.get(k)?.get() ?? val(v)]]
|
|
95
|
+
)
|
|
96
|
+
) as R,
|
|
97
|
+
[entries, atoms]
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const [args, update] = useState<R>(hydrate)
|
|
101
|
+
const setRef = useRef<((values: Partial<R>) => void) | null>(null)
|
|
102
|
+
const atomVals = useRef<Record<string, any>>({})
|
|
103
|
+
const fromAtom = useRef(false)
|
|
104
|
+
const fromControl = useRef<Set<string>>(new Set())
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (Object.keys(args).length !== Object.keys(initialArgs).length) {
|
|
108
|
+
update(hydrate)
|
|
109
|
+
}
|
|
110
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
111
|
+
}, [initialArgs, args])
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!setRef.current) {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const unsubs: Array<() => void> = []
|
|
119
|
+
let ready = false
|
|
120
|
+
const initTimeout = setTimeout(() => (ready = true), 100)
|
|
121
|
+
|
|
122
|
+
const subscribe = (fullKey: string, updateFn: (v: any) => void) => {
|
|
123
|
+
const a = atoms.get(fullKey)
|
|
124
|
+
|
|
125
|
+
if (!a) {
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
unsubs.push(
|
|
130
|
+
a.subscribe(v => {
|
|
131
|
+
const prev = atomVals.current[fullKey]
|
|
132
|
+
atomVals.current[fullKey] = v
|
|
133
|
+
|
|
134
|
+
if (
|
|
135
|
+
setRef.current &&
|
|
136
|
+
ready &&
|
|
137
|
+
prev !== v &&
|
|
138
|
+
!fromControl.current.has(fullKey)
|
|
139
|
+
) {
|
|
140
|
+
fromAtom.current = true
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
updateFn(v)
|
|
144
|
+
} catch {
|
|
145
|
+
//
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
setTimeout(() => (fromAtom.current = false), 0)
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
atomVals.current[fullKey] = a.get()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
entries.forEach(([k, v]) => {
|
|
157
|
+
if (v?.schema) {
|
|
158
|
+
Object.keys(v.schema).forEach(sk => {
|
|
159
|
+
subscribe(`${k}.${sk}`, v => {
|
|
160
|
+
try {
|
|
161
|
+
setRef.current!({
|
|
162
|
+
[k]: { ...((args[k] as any) ?? {}), [sk]: v }
|
|
163
|
+
} as Partial<R>)
|
|
164
|
+
} catch {
|
|
165
|
+
//
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
update(st => ({
|
|
169
|
+
...st,
|
|
170
|
+
[k]: { ...((st[k] as any) ?? {}), [sk]: v }
|
|
171
|
+
}))
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
} else {
|
|
175
|
+
subscribe(k, v => {
|
|
176
|
+
try {
|
|
177
|
+
setRef.current!({ [k]: v } as Partial<R>)
|
|
178
|
+
} catch {
|
|
179
|
+
//
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
update(st => ({ ...st, [k]: v }))
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
return () => {
|
|
188
|
+
clearTimeout(initTimeout)
|
|
189
|
+
unsubs.forEach(fn => fn())
|
|
190
|
+
}
|
|
191
|
+
}, [label, entries, atoms, args])
|
|
192
|
+
|
|
193
|
+
const onChange =
|
|
194
|
+
(k: string, orig?: (e: any, k0?: string) => void) =>
|
|
195
|
+
(e: any, k0?: string) => {
|
|
196
|
+
if (fromAtom.current) {
|
|
197
|
+
return orig?.(e, k0)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const key = k0?.split('.')?.pop() ?? k
|
|
201
|
+
const fullKey = k0 ?? k
|
|
202
|
+
const a = atoms.get(fullKey)
|
|
203
|
+
|
|
204
|
+
fromControl.current.add(fullKey)
|
|
205
|
+
|
|
206
|
+
const sync = (v: any) => {
|
|
207
|
+
update(st => ({ ...st, [key]: v }))
|
|
208
|
+
a?.set(v)
|
|
209
|
+
orig?.(v, k0)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (typeof e === 'number' && args[key] !== e) {
|
|
213
|
+
gsap.to(args, {
|
|
214
|
+
duration: options?.duration ?? 0.35,
|
|
215
|
+
ease: 'circ.out',
|
|
216
|
+
[key]: e,
|
|
217
|
+
onComplete: () => void fromControl.current.delete(fullKey),
|
|
218
|
+
onUpdate: () => {
|
|
219
|
+
fromControl.current.add(fullKey)
|
|
220
|
+
sync(args[key])
|
|
221
|
+
setTimeout(() => fromControl.current.delete(fullKey), 0)
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
} else {
|
|
225
|
+
sync(e)
|
|
226
|
+
setTimeout(() => fromControl.current.delete(fullKey), 0)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const [, set] = useControls(
|
|
231
|
+
label,
|
|
232
|
+
() => ({
|
|
233
|
+
...Object.fromEntries(
|
|
234
|
+
entries.map(([k, v]) =>
|
|
235
|
+
v?.schema
|
|
236
|
+
? [
|
|
237
|
+
k,
|
|
238
|
+
{
|
|
239
|
+
...v,
|
|
240
|
+
schema: Object.fromEntries(
|
|
241
|
+
Object.entries(v.schema).map(([sk, sv]: [string, any]) => [
|
|
242
|
+
sk,
|
|
243
|
+
{ ...sv!, onChange: onChange(k, sv?.onChange) }
|
|
244
|
+
])
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
]
|
|
248
|
+
: [k, { ...v, onChange: onChange(k, v?.onChange) }]
|
|
249
|
+
)
|
|
250
|
+
),
|
|
251
|
+
|
|
252
|
+
' ': buttonGroup({
|
|
253
|
+
flatten: () =>
|
|
254
|
+
void set(Object.fromEntries(values.map(([k]) => [k, 0]))),
|
|
255
|
+
randomize: () => {
|
|
256
|
+
set(
|
|
257
|
+
Object.fromEntries(
|
|
258
|
+
values.map(([k, v]) => [k, isHex(v) ? randHex() : randNum(v)])
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
options?.onRandomize?.()
|
|
262
|
+
},
|
|
263
|
+
reset: () => {
|
|
264
|
+
set(Object.fromEntries(values.map(([k, v]) => [k, val(v)])))
|
|
265
|
+
options?.onReset?.()
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
}),
|
|
269
|
+
{ collapsed: true, ...options },
|
|
270
|
+
dependencies ?? []
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
setRef.current = set
|
|
274
|
+
|
|
275
|
+
return args
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const getControlAtom = <T = any>(
|
|
279
|
+
label: string,
|
|
280
|
+
key: string
|
|
281
|
+
): undefined | WritableAtom<T> =>
|
|
282
|
+
atomRegistry.get(label)?.get(key) as undefined | WritableAtom<T>
|
|
283
|
+
|
|
284
|
+
export const setControlValue = <T = any>(
|
|
285
|
+
label: string,
|
|
286
|
+
key: string,
|
|
287
|
+
value: T,
|
|
288
|
+
options?: { animate?: boolean; duration?: number }
|
|
289
|
+
) => {
|
|
290
|
+
const a = getControlAtom<T>(label, key)
|
|
291
|
+
|
|
292
|
+
if (!a) {
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (
|
|
297
|
+
options?.animate &&
|
|
298
|
+
typeof value === 'number' &&
|
|
299
|
+
typeof a.get() === 'number'
|
|
300
|
+
) {
|
|
301
|
+
const t = { v: a.get() }
|
|
302
|
+
|
|
303
|
+
gsap.to(t, {
|
|
304
|
+
duration: options.duration ?? 0.35,
|
|
305
|
+
ease: 'circ.out',
|
|
306
|
+
onUpdate: () => a.set(t.v),
|
|
307
|
+
v: value
|
|
308
|
+
})
|
|
309
|
+
} else {
|
|
310
|
+
a.set(value)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
type UseSmoothControlsOptions = Parameters<typeof useControls>[2] & {
|
|
315
|
+
duration?: number
|
|
316
|
+
onRandomize?: () => void
|
|
317
|
+
onReset?: () => void
|
|
318
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export function useToast(duration = 3000) {
|
|
6
|
+
const [toast, setToast] = useState<{
|
|
7
|
+
message: string
|
|
8
|
+
type: 'error' | 'success'
|
|
9
|
+
} | null>(null)
|
|
10
|
+
|
|
11
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
return () => {
|
|
15
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
16
|
+
}
|
|
17
|
+
}, [])
|
|
18
|
+
|
|
19
|
+
const showToast = useCallback(
|
|
20
|
+
(message: string, type: 'error' | 'success') => {
|
|
21
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
22
|
+
setToast({ message, type })
|
|
23
|
+
timerRef.current = setTimeout(() => setToast(null), duration)
|
|
24
|
+
},
|
|
25
|
+
[duration]
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return { showToast, toast }
|
|
29
|
+
}
|