@sntlr/registry-shell 1.0.0 → 1.1.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/README.md +2 -5
- package/dist/adapter/custom.d.ts +1 -1
- package/dist/adapter/default.d.ts +3 -3
- package/dist/adapter/default.js +3 -3
- package/dist/cli/init.js +0 -3
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/shared.js +0 -5
- package/dist/cli/shared.js.map +1 -1
- package/dist/config-loader.d.ts +0 -2
- package/dist/config-loader.js +0 -1
- package/dist/define-config.d.ts +0 -6
- package/package.json +1 -1
- package/src/adapter/custom.ts +1 -1
- package/src/adapter/default.ts +3 -3
- package/src/cli/init.ts +0 -3
- package/src/cli/shared.ts +0 -4
- package/src/config-loader.ts +0 -3
- package/src/define-config.ts +0 -7
- package/src/next-app/app/globals.css +329 -329
- package/src/next-app/app/page.tsx +1 -1
- package/src/next-app/components/component-icon.tsx +140 -140
- package/src/next-app/components/heading-anchor.tsx +52 -52
- package/src/next-app/{fallback → components}/homepage.tsx +2 -3
- package/src/next-app/components/navigation-progress.tsx +62 -62
- package/src/next-app/components/resizable-preview.tsx +101 -101
- package/src/next-app/components/sidebar-provider.tsx +75 -75
- package/src/next-app/hooks/use-controls.ts +72 -72
- package/src/next-app/lib/i18n.tsx +630 -630
- package/src/next-app/lib/registry-adapter.ts +6 -32
- package/src/next-app/lib/utils.ts +6 -6
- package/src/next-app/next-env.d.ts +6 -6
- package/src/next-app/next.config.ts +0 -5
- package/src/next-app/postcss.config.mjs +7 -7
- package/src/next-app/user-aliases.d.ts +0 -7
- package/src/next-app/app/_user-global.css +0 -6
- package/src/next-app/app/_user-sources.css +0 -9
|
@@ -1,101 +1,101 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import { useCallback, useRef, useState, useEffect } from "react"
|
|
4
|
-
|
|
5
|
-
const STORAGE_KEY = "preview-height"
|
|
6
|
-
const DEFAULT_HEIGHT_DESKTOP = 384
|
|
7
|
-
const DEFAULT_HEIGHT_MOBILE = 600
|
|
8
|
-
const MIN_HEIGHT = 200
|
|
9
|
-
const MAX_HEIGHT = 1000
|
|
10
|
-
|
|
11
|
-
function getDefaultHeight(): number {
|
|
12
|
-
if (typeof window === "undefined") return DEFAULT_HEIGHT_DESKTOP
|
|
13
|
-
return window.matchMedia("(max-width: 767px)").matches
|
|
14
|
-
? DEFAULT_HEIGHT_MOBILE
|
|
15
|
-
: DEFAULT_HEIGHT_DESKTOP
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function getStoredHeight(): number {
|
|
19
|
-
if (typeof window === "undefined") return DEFAULT_HEIGHT_DESKTOP
|
|
20
|
-
const stored = sessionStorage.getItem(STORAGE_KEY)
|
|
21
|
-
if (stored) {
|
|
22
|
-
const n = Number(stored)
|
|
23
|
-
if (!isNaN(n)) return Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, n))
|
|
24
|
-
}
|
|
25
|
-
return getDefaultHeight()
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function ResizablePreview({ children }: { children: React.ReactNode }) {
|
|
29
|
-
const [height, setHeight] = useState(getStoredHeight)
|
|
30
|
-
const dragging = useRef(false)
|
|
31
|
-
const startY = useRef(0)
|
|
32
|
-
const startH = useRef(0)
|
|
33
|
-
|
|
34
|
-
useEffect(() => {
|
|
35
|
-
sessionStorage.setItem(STORAGE_KEY, String(height))
|
|
36
|
-
}, [height])
|
|
37
|
-
|
|
38
|
-
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
|
39
|
-
e.preventDefault()
|
|
40
|
-
dragging.current = true
|
|
41
|
-
startY.current = e.clientY
|
|
42
|
-
startH.current = height
|
|
43
|
-
document.body.style.cursor = "row-resize"
|
|
44
|
-
document.body.style.userSelect = "none"
|
|
45
|
-
|
|
46
|
-
function onMouseMove(ev: MouseEvent) {
|
|
47
|
-
if (!dragging.current) return
|
|
48
|
-
const newH = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, startH.current + (ev.clientY - startY.current)))
|
|
49
|
-
setHeight(newH)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function onMouseUp() {
|
|
53
|
-
dragging.current = false
|
|
54
|
-
document.body.style.cursor = ""
|
|
55
|
-
document.body.style.userSelect = ""
|
|
56
|
-
document.removeEventListener("mousemove", onMouseMove)
|
|
57
|
-
document.removeEventListener("mouseup", onMouseUp)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
document.addEventListener("mousemove", onMouseMove)
|
|
61
|
-
document.addEventListener("mouseup", onMouseUp)
|
|
62
|
-
}, [height])
|
|
63
|
-
|
|
64
|
-
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
|
65
|
-
if (e.touches.length !== 1) return
|
|
66
|
-
dragging.current = true
|
|
67
|
-
startY.current = e.touches[0].clientY
|
|
68
|
-
startH.current = height
|
|
69
|
-
|
|
70
|
-
function onTouchMove(ev: TouchEvent) {
|
|
71
|
-
if (!dragging.current || ev.touches.length !== 1) return
|
|
72
|
-
ev.preventDefault()
|
|
73
|
-
const newH = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, startH.current + (ev.touches[0].clientY - startY.current)))
|
|
74
|
-
setHeight(newH)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function onTouchEnd() {
|
|
78
|
-
dragging.current = false
|
|
79
|
-
document.removeEventListener("touchmove", onTouchMove)
|
|
80
|
-
document.removeEventListener("touchend", onTouchEnd)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
document.addEventListener("touchmove", onTouchMove, { passive: false })
|
|
84
|
-
document.addEventListener("touchend", onTouchEnd)
|
|
85
|
-
}, [height])
|
|
86
|
-
|
|
87
|
-
return (
|
|
88
|
-
<div>
|
|
89
|
-
<div style={{ height }}>
|
|
90
|
-
{children}
|
|
91
|
-
</div>
|
|
92
|
-
<div
|
|
93
|
-
className="h-3 md:h-2 cursor-row-resize flex items-center justify-center hover:bg-muted/50 active:bg-muted/50 transition-colors group touch-none"
|
|
94
|
-
onMouseDown={onMouseDown}
|
|
95
|
-
onTouchStart={onTouchStart}
|
|
96
|
-
>
|
|
97
|
-
<div className="w-8 h-0.5 rounded-full bg-border group-hover:bg-muted-foreground transition-colors" />
|
|
98
|
-
</div>
|
|
99
|
-
</div>
|
|
100
|
-
)
|
|
101
|
-
}
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useState, useEffect } from "react"
|
|
4
|
+
|
|
5
|
+
const STORAGE_KEY = "preview-height"
|
|
6
|
+
const DEFAULT_HEIGHT_DESKTOP = 384
|
|
7
|
+
const DEFAULT_HEIGHT_MOBILE = 600
|
|
8
|
+
const MIN_HEIGHT = 200
|
|
9
|
+
const MAX_HEIGHT = 1000
|
|
10
|
+
|
|
11
|
+
function getDefaultHeight(): number {
|
|
12
|
+
if (typeof window === "undefined") return DEFAULT_HEIGHT_DESKTOP
|
|
13
|
+
return window.matchMedia("(max-width: 767px)").matches
|
|
14
|
+
? DEFAULT_HEIGHT_MOBILE
|
|
15
|
+
: DEFAULT_HEIGHT_DESKTOP
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getStoredHeight(): number {
|
|
19
|
+
if (typeof window === "undefined") return DEFAULT_HEIGHT_DESKTOP
|
|
20
|
+
const stored = sessionStorage.getItem(STORAGE_KEY)
|
|
21
|
+
if (stored) {
|
|
22
|
+
const n = Number(stored)
|
|
23
|
+
if (!isNaN(n)) return Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, n))
|
|
24
|
+
}
|
|
25
|
+
return getDefaultHeight()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ResizablePreview({ children }: { children: React.ReactNode }) {
|
|
29
|
+
const [height, setHeight] = useState(getStoredHeight)
|
|
30
|
+
const dragging = useRef(false)
|
|
31
|
+
const startY = useRef(0)
|
|
32
|
+
const startH = useRef(0)
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
sessionStorage.setItem(STORAGE_KEY, String(height))
|
|
36
|
+
}, [height])
|
|
37
|
+
|
|
38
|
+
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
|
39
|
+
e.preventDefault()
|
|
40
|
+
dragging.current = true
|
|
41
|
+
startY.current = e.clientY
|
|
42
|
+
startH.current = height
|
|
43
|
+
document.body.style.cursor = "row-resize"
|
|
44
|
+
document.body.style.userSelect = "none"
|
|
45
|
+
|
|
46
|
+
function onMouseMove(ev: MouseEvent) {
|
|
47
|
+
if (!dragging.current) return
|
|
48
|
+
const newH = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, startH.current + (ev.clientY - startY.current)))
|
|
49
|
+
setHeight(newH)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function onMouseUp() {
|
|
53
|
+
dragging.current = false
|
|
54
|
+
document.body.style.cursor = ""
|
|
55
|
+
document.body.style.userSelect = ""
|
|
56
|
+
document.removeEventListener("mousemove", onMouseMove)
|
|
57
|
+
document.removeEventListener("mouseup", onMouseUp)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
document.addEventListener("mousemove", onMouseMove)
|
|
61
|
+
document.addEventListener("mouseup", onMouseUp)
|
|
62
|
+
}, [height])
|
|
63
|
+
|
|
64
|
+
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
|
65
|
+
if (e.touches.length !== 1) return
|
|
66
|
+
dragging.current = true
|
|
67
|
+
startY.current = e.touches[0].clientY
|
|
68
|
+
startH.current = height
|
|
69
|
+
|
|
70
|
+
function onTouchMove(ev: TouchEvent) {
|
|
71
|
+
if (!dragging.current || ev.touches.length !== 1) return
|
|
72
|
+
ev.preventDefault()
|
|
73
|
+
const newH = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, startH.current + (ev.touches[0].clientY - startY.current)))
|
|
74
|
+
setHeight(newH)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function onTouchEnd() {
|
|
78
|
+
dragging.current = false
|
|
79
|
+
document.removeEventListener("touchmove", onTouchMove)
|
|
80
|
+
document.removeEventListener("touchend", onTouchEnd)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
document.addEventListener("touchmove", onTouchMove, { passive: false })
|
|
84
|
+
document.addEventListener("touchend", onTouchEnd)
|
|
85
|
+
}, [height])
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div>
|
|
89
|
+
<div style={{ height }}>
|
|
90
|
+
{children}
|
|
91
|
+
</div>
|
|
92
|
+
<div
|
|
93
|
+
className="h-3 md:h-2 cursor-row-resize flex items-center justify-center hover:bg-muted/50 active:bg-muted/50 transition-colors group touch-none"
|
|
94
|
+
onMouseDown={onMouseDown}
|
|
95
|
+
onTouchStart={onTouchStart}
|
|
96
|
+
>
|
|
97
|
+
<div className="w-8 h-0.5 rounded-full bg-border group-hover:bg-muted-foreground transition-colors" />
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
@@ -1,75 +1,75 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useState, type ReactNode } from "react"
|
|
4
|
-
|
|
5
|
-
interface SidebarContextValue {
|
|
6
|
-
open: boolean
|
|
7
|
-
toggle: () => void
|
|
8
|
-
close: () => void
|
|
9
|
-
collapsed: boolean
|
|
10
|
-
setCollapsed: (v: boolean) => void
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const SidebarContext = createContext<SidebarContextValue>({
|
|
14
|
-
open: false,
|
|
15
|
-
toggle: () => {},
|
|
16
|
-
close: () => {},
|
|
17
|
-
collapsed: false,
|
|
18
|
-
setCollapsed: () => {},
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Runs synchronously after DOM commit and *before* paint, so we can swap
|
|
23
|
-
* client-only state into the tree post-hydration without ever rendering a
|
|
24
|
-
* mismatched DOM. On the server it's a no-op alias to `useEffect`.
|
|
25
|
-
*/
|
|
26
|
-
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect
|
|
27
|
-
|
|
28
|
-
export function SidebarProvider({ children }: { children: ReactNode }) {
|
|
29
|
-
// Always start with the SSR-safe defaults so the server HTML and the first
|
|
30
|
-
// client render are identical. Reading from sessionStorage / matchMedia in
|
|
31
|
-
// a `useState` initializer caused a hydration mismatch on the Header (which
|
|
32
|
-
// builds className strings from `collapsed`), and React's mismatch recovery
|
|
33
|
-
// discards the affected subtree — which restarts the bento card CSS
|
|
34
|
-
// animations on the homepage. We restore the persisted state in a layout
|
|
35
|
-
// effect that runs before the first paint, so users never see a flash.
|
|
36
|
-
const [open, setOpen] = useState(false)
|
|
37
|
-
const [collapsed, setCollapsedState] = useState(false)
|
|
38
|
-
|
|
39
|
-
useIsomorphicLayoutEffect(() => {
|
|
40
|
-
// Sidebar nav: only restore on desktop (mobile always starts closed).
|
|
41
|
-
const isMobile = window.matchMedia("(max-width: 767px)").matches
|
|
42
|
-
if (!isMobile) {
|
|
43
|
-
const storedOpen = sessionStorage.getItem("sidebar-nav-open") === "true"
|
|
44
|
-
if (storedOpen) setOpen(true)
|
|
45
|
-
}
|
|
46
|
-
// Preview fullscreen.
|
|
47
|
-
const storedCollapsed = sessionStorage.getItem("preview-fullscreen") === "true"
|
|
48
|
-
if (storedCollapsed) setCollapsedState(true)
|
|
49
|
-
}, [])
|
|
50
|
-
|
|
51
|
-
const toggle = useCallback(() => {
|
|
52
|
-
setOpen((o) => {
|
|
53
|
-
sessionStorage.setItem("sidebar-nav-open", String(!o))
|
|
54
|
-
return !o
|
|
55
|
-
})
|
|
56
|
-
}, [])
|
|
57
|
-
const close = useCallback(() => {
|
|
58
|
-
setOpen(false)
|
|
59
|
-
sessionStorage.setItem("sidebar-nav-open", "false")
|
|
60
|
-
}, [])
|
|
61
|
-
const setCollapsed = useCallback((v: boolean) => {
|
|
62
|
-
setCollapsedState(v)
|
|
63
|
-
sessionStorage.setItem("preview-fullscreen", String(v))
|
|
64
|
-
}, [])
|
|
65
|
-
|
|
66
|
-
return (
|
|
67
|
-
<SidebarContext.Provider value={{ open, toggle, close, collapsed, setCollapsed }}>
|
|
68
|
-
{children}
|
|
69
|
-
</SidebarContext.Provider>
|
|
70
|
-
)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function useMobileSidebar() {
|
|
74
|
-
return useContext(SidebarContext)
|
|
75
|
-
}
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useState, type ReactNode } from "react"
|
|
4
|
+
|
|
5
|
+
interface SidebarContextValue {
|
|
6
|
+
open: boolean
|
|
7
|
+
toggle: () => void
|
|
8
|
+
close: () => void
|
|
9
|
+
collapsed: boolean
|
|
10
|
+
setCollapsed: (v: boolean) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SidebarContext = createContext<SidebarContextValue>({
|
|
14
|
+
open: false,
|
|
15
|
+
toggle: () => {},
|
|
16
|
+
close: () => {},
|
|
17
|
+
collapsed: false,
|
|
18
|
+
setCollapsed: () => {},
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Runs synchronously after DOM commit and *before* paint, so we can swap
|
|
23
|
+
* client-only state into the tree post-hydration without ever rendering a
|
|
24
|
+
* mismatched DOM. On the server it's a no-op alias to `useEffect`.
|
|
25
|
+
*/
|
|
26
|
+
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect
|
|
27
|
+
|
|
28
|
+
export function SidebarProvider({ children }: { children: ReactNode }) {
|
|
29
|
+
// Always start with the SSR-safe defaults so the server HTML and the first
|
|
30
|
+
// client render are identical. Reading from sessionStorage / matchMedia in
|
|
31
|
+
// a `useState` initializer caused a hydration mismatch on the Header (which
|
|
32
|
+
// builds className strings from `collapsed`), and React's mismatch recovery
|
|
33
|
+
// discards the affected subtree — which restarts the bento card CSS
|
|
34
|
+
// animations on the homepage. We restore the persisted state in a layout
|
|
35
|
+
// effect that runs before the first paint, so users never see a flash.
|
|
36
|
+
const [open, setOpen] = useState(false)
|
|
37
|
+
const [collapsed, setCollapsedState] = useState(false)
|
|
38
|
+
|
|
39
|
+
useIsomorphicLayoutEffect(() => {
|
|
40
|
+
// Sidebar nav: only restore on desktop (mobile always starts closed).
|
|
41
|
+
const isMobile = window.matchMedia("(max-width: 767px)").matches
|
|
42
|
+
if (!isMobile) {
|
|
43
|
+
const storedOpen = sessionStorage.getItem("sidebar-nav-open") === "true"
|
|
44
|
+
if (storedOpen) setOpen(true)
|
|
45
|
+
}
|
|
46
|
+
// Preview fullscreen.
|
|
47
|
+
const storedCollapsed = sessionStorage.getItem("preview-fullscreen") === "true"
|
|
48
|
+
if (storedCollapsed) setCollapsedState(true)
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
51
|
+
const toggle = useCallback(() => {
|
|
52
|
+
setOpen((o) => {
|
|
53
|
+
sessionStorage.setItem("sidebar-nav-open", String(!o))
|
|
54
|
+
return !o
|
|
55
|
+
})
|
|
56
|
+
}, [])
|
|
57
|
+
const close = useCallback(() => {
|
|
58
|
+
setOpen(false)
|
|
59
|
+
sessionStorage.setItem("sidebar-nav-open", "false")
|
|
60
|
+
}, [])
|
|
61
|
+
const setCollapsed = useCallback((v: boolean) => {
|
|
62
|
+
setCollapsedState(v)
|
|
63
|
+
sessionStorage.setItem("preview-fullscreen", String(v))
|
|
64
|
+
}, [])
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<SidebarContext.Provider value={{ open, toggle, close, collapsed, setCollapsed }}>
|
|
68
|
+
{children}
|
|
69
|
+
</SidebarContext.Provider>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useMobileSidebar() {
|
|
74
|
+
return useContext(SidebarContext)
|
|
75
|
+
}
|
|
@@ -1,72 +1,72 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import { useState } from "react"
|
|
4
|
-
|
|
5
|
-
interface SelectControl {
|
|
6
|
-
type: "select"
|
|
7
|
-
options: string[]
|
|
8
|
-
default: string
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface BooleanControl {
|
|
12
|
-
type: "boolean"
|
|
13
|
-
default: boolean
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
interface TextControl {
|
|
17
|
-
type: "text"
|
|
18
|
-
default: string
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
interface NumberControl {
|
|
22
|
-
type: "number"
|
|
23
|
-
default: number
|
|
24
|
-
min?: number
|
|
25
|
-
max?: number
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
type ControlDef = SelectControl | BooleanControl | TextControl | NumberControl
|
|
29
|
-
|
|
30
|
-
export type ControlDefs = Record<string, ControlDef>
|
|
31
|
-
|
|
32
|
-
type ControlValues<T extends ControlDefs> = {
|
|
33
|
-
[K in keyof T]: T[K] extends SelectControl
|
|
34
|
-
? string
|
|
35
|
-
: T[K] extends BooleanControl
|
|
36
|
-
? boolean
|
|
37
|
-
: T[K] extends TextControl
|
|
38
|
-
? string
|
|
39
|
-
: T[K] extends NumberControl
|
|
40
|
-
? number
|
|
41
|
-
: never
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface ControlEntry {
|
|
45
|
-
name: string
|
|
46
|
-
def: ControlDef
|
|
47
|
-
value: unknown
|
|
48
|
-
onChange: (value: unknown) => void
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function useControls<T extends ControlDefs>(defs: T) {
|
|
52
|
-
const [values, setValues] = useState<Record<string, unknown>>(() => {
|
|
53
|
-
const initial: Record<string, unknown> = {}
|
|
54
|
-
for (const [key, def] of Object.entries(defs)) {
|
|
55
|
-
initial[key] = def.default
|
|
56
|
-
}
|
|
57
|
-
return initial
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
const entries: ControlEntry[] = Object.entries(defs).map(([name, def]) => ({
|
|
61
|
-
name,
|
|
62
|
-
def,
|
|
63
|
-
value: values[name],
|
|
64
|
-
onChange: (value: unknown) =>
|
|
65
|
-
setValues((prev) => ({ ...prev, [name]: value })),
|
|
66
|
-
}))
|
|
67
|
-
|
|
68
|
-
return {
|
|
69
|
-
values: values as ControlValues<T>,
|
|
70
|
-
entries,
|
|
71
|
-
}
|
|
72
|
-
}
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
|
|
5
|
+
interface SelectControl {
|
|
6
|
+
type: "select"
|
|
7
|
+
options: string[]
|
|
8
|
+
default: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface BooleanControl {
|
|
12
|
+
type: "boolean"
|
|
13
|
+
default: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface TextControl {
|
|
17
|
+
type: "text"
|
|
18
|
+
default: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface NumberControl {
|
|
22
|
+
type: "number"
|
|
23
|
+
default: number
|
|
24
|
+
min?: number
|
|
25
|
+
max?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ControlDef = SelectControl | BooleanControl | TextControl | NumberControl
|
|
29
|
+
|
|
30
|
+
export type ControlDefs = Record<string, ControlDef>
|
|
31
|
+
|
|
32
|
+
type ControlValues<T extends ControlDefs> = {
|
|
33
|
+
[K in keyof T]: T[K] extends SelectControl
|
|
34
|
+
? string
|
|
35
|
+
: T[K] extends BooleanControl
|
|
36
|
+
? boolean
|
|
37
|
+
: T[K] extends TextControl
|
|
38
|
+
? string
|
|
39
|
+
: T[K] extends NumberControl
|
|
40
|
+
? number
|
|
41
|
+
: never
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ControlEntry {
|
|
45
|
+
name: string
|
|
46
|
+
def: ControlDef
|
|
47
|
+
value: unknown
|
|
48
|
+
onChange: (value: unknown) => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useControls<T extends ControlDefs>(defs: T) {
|
|
52
|
+
const [values, setValues] = useState<Record<string, unknown>>(() => {
|
|
53
|
+
const initial: Record<string, unknown> = {}
|
|
54
|
+
for (const [key, def] of Object.entries(defs)) {
|
|
55
|
+
initial[key] = def.default
|
|
56
|
+
}
|
|
57
|
+
return initial
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const entries: ControlEntry[] = Object.entries(defs).map(([name, def]) => ({
|
|
61
|
+
name,
|
|
62
|
+
def,
|
|
63
|
+
value: values[name],
|
|
64
|
+
onChange: (value: unknown) =>
|
|
65
|
+
setValues((prev) => ({ ...prev, [name]: value })),
|
|
66
|
+
}))
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
values: values as ControlValues<T>,
|
|
70
|
+
entries,
|
|
71
|
+
}
|
|
72
|
+
}
|