@exegia/corpora-ui 0.19.0 → 0.20.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/dist-lib/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.d.ts +1 -0
- package/dist-lib/components/blocks/nav/sidebar/ai-sidebar.d.ts +11 -2
- package/dist-lib/components/blocks/nav/sidebar/type.d.ts +83 -1
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +22 -31
- package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +5 -0
- package/dist-lib/components/blocks/scaffold/constants.d.ts +5 -0
- package/dist-lib/components/blocks/scaffold/scaffold-canvas.d.ts +2 -0
- package/dist-lib/components/blocks/scaffold/scaffold-panel.d.ts +1 -1
- package/dist-lib/components/blocks/scaffold/scaffold-tab.d.ts +6 -1
- package/dist-lib/components/blocks/scaffold/type.d.ts +28 -0
- package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +19 -0
- package/dist-lib/components/blocks/scaffold/utils.d.ts +6 -0
- package/dist-lib/components/composed/tree/__tests__/tree.test.d.ts +1 -0
- package/dist-lib/components/composed/tree/__tests__/use-tree.test.d.ts +1 -0
- package/dist-lib/components/composed/tree/constants.d.ts +35 -0
- package/dist-lib/components/composed/tree/index.d.ts +4 -0
- package/dist-lib/components/composed/tree/tree-context.d.ts +4 -0
- package/dist-lib/components/composed/tree/tree-node.d.ts +7 -0
- package/dist-lib/components/composed/tree/tree.d.ts +19 -0
- package/dist-lib/components/composed/tree/type.d.ts +192 -0
- package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +7 -0
- package/dist-lib/components/composed/tree/use-tree.d.ts +20 -0
- package/dist-lib/components/composed/tree/utils.d.ts +28 -0
- package/dist-lib/components/user-avatar/audio-wave.d.ts +2 -0
- package/dist-lib/components/user-avatar/component.d.ts +10 -0
- package/dist-lib/components/user-avatar/fallback.d.ts +5 -0
- package/dist-lib/components/user-avatar/index.d.ts +13 -0
- package/dist-lib/components/user-avatar/type.d.ts +26 -0
- package/dist-lib/components/user-avatar/utils.d.ts +2 -0
- package/dist-lib/index.d.ts +1 -0
- package/dist-lib/index.js +2141 -1139
- package/dist-lib/index.js.map +1 -1
- package/package.json +2 -1
- package/src/components/beste/piece/browser-frame.tsx +9 -6
- package/src/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.tsx +310 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +90 -60
- package/src/components/blocks/nav/sidebar/type.ts +105 -1
- package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +293 -116
- package/src/components/blocks/nav/sidebar/utils.ts +28 -0
- package/src/components/blocks/scaffold/__tests__/scaffold.test.tsx +160 -1
- package/src/components/blocks/scaffold/constants.ts +7 -0
- package/src/components/blocks/scaffold/panel-menu-button.tsx +3 -0
- package/src/components/blocks/scaffold/scaffold-canvas.tsx +48 -5
- package/src/components/blocks/scaffold/scaffold-inspector.tsx +3 -2
- package/src/components/blocks/scaffold/scaffold-main.tsx +1 -1
- package/src/components/blocks/scaffold/scaffold-panel.tsx +41 -5
- package/src/components/blocks/scaffold/scaffold-root.tsx +18 -3
- package/src/components/blocks/scaffold/scaffold-sidebar.tsx +1 -1
- package/src/components/blocks/scaffold/scaffold-tab.tsx +45 -7
- package/src/components/blocks/scaffold/type.ts +28 -0
- package/src/components/blocks/scaffold/use-panel-visibility.ts +180 -0
- package/src/components/blocks/scaffold/utils.ts +19 -0
- package/src/components/composed/tree/__tests__/tree.test.tsx +400 -0
- package/src/components/composed/tree/__tests__/use-tree.test.tsx +332 -0
- package/src/components/composed/tree/constants.ts +60 -0
- package/src/components/composed/tree/index.ts +13 -0
- package/src/components/composed/tree/tree-context.ts +13 -0
- package/src/components/composed/tree/tree-node.tsx +451 -0
- package/src/components/composed/tree/tree.tsx +278 -0
- package/src/components/composed/tree/type.ts +212 -0
- package/src/components/composed/tree/use-tree-dnd.ts +148 -0
- package/src/components/composed/tree/use-tree.ts +306 -0
- package/src/components/composed/tree/utils.ts +172 -0
- package/src/components/composed/user-avatar.tsx +4 -1
- package/src/components/docs/component-preview.tsx +11 -11
- package/src/components/user-avatar/audio-wave.tsx +22 -0
- package/src/components/user-avatar/component.tsx +53 -0
- package/src/components/user-avatar/fallback.tsx +26 -0
- package/src/components/user-avatar/index.ts +15 -0
- package/src/components/user-avatar/type.ts +35 -0
- package/src/components/user-avatar/utils.ts +8 -0
- package/src/index.css +211 -153
- package/src/index.ts +1 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
import { playCue } from "@/lib/sound"
|
|
6
|
+
import type { TreeController, TreeNode, UseTreeOptions } from "./type"
|
|
7
|
+
import { useTreeDnd } from "./use-tree-dnd"
|
|
8
|
+
import {
|
|
9
|
+
ancestorIdsOf,
|
|
10
|
+
expandableIdsOf,
|
|
11
|
+
findNode,
|
|
12
|
+
hasThreeLevels,
|
|
13
|
+
initialExpandedIds,
|
|
14
|
+
moveNode,
|
|
15
|
+
renameNode,
|
|
16
|
+
} from "./utils"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The tree's headless controller — every behaviour the component exposes,
|
|
20
|
+
* callable from outside it: expand/collapse (a node, a section, the whole
|
|
21
|
+
* tree), fold the `sidebar` rail, select a node, rename, reorder.
|
|
22
|
+
*
|
|
23
|
+
* Each piece of state is controlled-or-uncontrolled in the usual React
|
|
24
|
+
* shape: pass `items`/`activeId`/`collapsed` to own it yourself, or
|
|
25
|
+
* `defaultItems`/`defaultActiveId`/`defaultCollapsed` to let the hook. When
|
|
26
|
+
* the hook owns `items` (or you pass `onItemsChange`), `rename` and `move`
|
|
27
|
+
* apply the edit to the data themselves — otherwise they only report it
|
|
28
|
+
* through `onRename`/`onMove` and you reduce the tree yourself.
|
|
29
|
+
*
|
|
30
|
+
* ```tsx
|
|
31
|
+
* const tree = useTree({ variant: "files", defaultItems: files })
|
|
32
|
+
* <Tree tree={tree} />
|
|
33
|
+
* <Button onClick={tree.collapseAll}>Collapse all</Button>
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function useTree(options: UseTreeOptions): TreeController {
|
|
37
|
+
const {
|
|
38
|
+
variant,
|
|
39
|
+
onNavigate,
|
|
40
|
+
onRename,
|
|
41
|
+
onMove,
|
|
42
|
+
onItemsChange,
|
|
43
|
+
onExpandedChange,
|
|
44
|
+
onCollapsedChange,
|
|
45
|
+
sound = true,
|
|
46
|
+
} = options
|
|
47
|
+
|
|
48
|
+
// Data: controlled through `items`, otherwise owned here.
|
|
49
|
+
const [ownItems, setOwnItems] = React.useState<TreeNode[]>(
|
|
50
|
+
() => options.defaultItems ?? []
|
|
51
|
+
)
|
|
52
|
+
const items = options.items ?? ownItems
|
|
53
|
+
// The hook may only rewrite the data when it owns it, or when the
|
|
54
|
+
// consumer asked for the next tree through `onItemsChange`.
|
|
55
|
+
const managesItems =
|
|
56
|
+
options.items === undefined || onItemsChange !== undefined
|
|
57
|
+
|
|
58
|
+
const setItems = React.useCallback(
|
|
59
|
+
(next: TreeNode[]) => {
|
|
60
|
+
if (options.items === undefined) setOwnItems(next)
|
|
61
|
+
onItemsChange?.(next)
|
|
62
|
+
},
|
|
63
|
+
[options.items, onItemsChange]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const sectioned = variant === "navigation" && hasThreeLevels(items)
|
|
67
|
+
|
|
68
|
+
// Selection: controlled through `activeId`, otherwise owned here.
|
|
69
|
+
const [ownActiveId, setOwnActiveId] = React.useState(options.defaultActiveId)
|
|
70
|
+
const activeId = options.activeId ?? ownActiveId
|
|
71
|
+
|
|
72
|
+
const [expandedIds, setExpandedIds] = React.useState<Set<string>>(() => {
|
|
73
|
+
const expanded = options.defaultExpandedIds
|
|
74
|
+
? new Set(options.defaultExpandedIds)
|
|
75
|
+
: initialExpandedIds(items, activeId)
|
|
76
|
+
// Section names default open — they are headings over their run of
|
|
77
|
+
// items, not drawers; `defaultOpen: false` still starts one closed.
|
|
78
|
+
if (sectioned && !options.defaultExpandedIds)
|
|
79
|
+
for (const node of items)
|
|
80
|
+
if (node.defaultOpen !== false) expanded.add(node.id)
|
|
81
|
+
return expanded
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const commitExpanded = React.useCallback(
|
|
85
|
+
(next: Set<string>) => {
|
|
86
|
+
setExpandedIds(next)
|
|
87
|
+
onExpandedChange?.([...next])
|
|
88
|
+
},
|
|
89
|
+
[onExpandedChange]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
// Navigating into a nested entry from elsewhere reveals its ancestors,
|
|
93
|
+
// even ones the reader had collapsed. Adjusted during render so the
|
|
94
|
+
// branch never paints closed for a frame first.
|
|
95
|
+
const [prevActiveId, setPrevActiveId] = React.useState(activeId)
|
|
96
|
+
if (activeId !== prevActiveId) {
|
|
97
|
+
setPrevActiveId(activeId)
|
|
98
|
+
if (activeId !== undefined) {
|
|
99
|
+
const ancestors = ancestorIdsOf(items, activeId)
|
|
100
|
+
if (ancestors.some((id) => !expandedIds.has(id)))
|
|
101
|
+
setExpandedIds(new Set([...expandedIds, ...ancestors]))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Rail fold: controlled through `collapsed`, otherwise owned here. Only
|
|
106
|
+
// `sidebar` has a rail — every other variant reads as expanded.
|
|
107
|
+
const [ownCollapsed, setOwnCollapsed] = React.useState(
|
|
108
|
+
options.defaultCollapsed ?? false
|
|
109
|
+
)
|
|
110
|
+
const collapsed =
|
|
111
|
+
variant === "sidebar" ? (options.collapsed ?? ownCollapsed) : false
|
|
112
|
+
|
|
113
|
+
const [renamingId, setRenamingId] = React.useState<string | null>(null)
|
|
114
|
+
|
|
115
|
+
const canRename =
|
|
116
|
+
variant === "files" && (onRename !== undefined || managesItems)
|
|
117
|
+
const canMove = variant === "files" && (onMove !== undefined || managesItems)
|
|
118
|
+
|
|
119
|
+
const isExpanded = React.useCallback(
|
|
120
|
+
(id: string) => expandedIds.has(id),
|
|
121
|
+
[expandedIds]
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const expand = React.useCallback(
|
|
125
|
+
(id: string) => {
|
|
126
|
+
if (expandedIds.has(id)) return
|
|
127
|
+
commitExpanded(new Set([...expandedIds, id]))
|
|
128
|
+
if (sound) playCue("toggle")
|
|
129
|
+
},
|
|
130
|
+
[commitExpanded, expandedIds, sound]
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const collapse = React.useCallback(
|
|
134
|
+
(id: string) => {
|
|
135
|
+
if (!expandedIds.has(id)) return
|
|
136
|
+
const next = new Set(expandedIds)
|
|
137
|
+
next.delete(id)
|
|
138
|
+
commitExpanded(next)
|
|
139
|
+
if (sound) playCue("toggle")
|
|
140
|
+
},
|
|
141
|
+
[commitExpanded, expandedIds, sound]
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
const toggleExpanded = React.useCallback(
|
|
145
|
+
(id: string) => {
|
|
146
|
+
const next = new Set(expandedIds)
|
|
147
|
+
if (next.has(id)) next.delete(id)
|
|
148
|
+
else next.add(id)
|
|
149
|
+
commitExpanded(next)
|
|
150
|
+
if (sound) playCue("toggle")
|
|
151
|
+
},
|
|
152
|
+
[commitExpanded, expandedIds, sound]
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const expandAll = React.useCallback(() => {
|
|
156
|
+
commitExpanded(new Set(expandableIdsOf(items, variant === "files")))
|
|
157
|
+
}, [commitExpanded, items, variant])
|
|
158
|
+
|
|
159
|
+
const collapseAll = React.useCallback(() => {
|
|
160
|
+
commitExpanded(new Set())
|
|
161
|
+
}, [commitExpanded])
|
|
162
|
+
|
|
163
|
+
/** Open every ancestor of `id` without touching the rest of the tree. */
|
|
164
|
+
const reveal = React.useCallback(
|
|
165
|
+
(id: string) => {
|
|
166
|
+
const ancestors = ancestorIdsOf(items, id)
|
|
167
|
+
if (!ancestors.some((ancestor) => !expandedIds.has(ancestor))) return
|
|
168
|
+
commitExpanded(new Set([...expandedIds, ...ancestors]))
|
|
169
|
+
},
|
|
170
|
+
[commitExpanded, expandedIds, items]
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const setCollapsed = React.useCallback(
|
|
174
|
+
(next: boolean) => {
|
|
175
|
+
if (options.collapsed === undefined) setOwnCollapsed(next)
|
|
176
|
+
onCollapsedChange?.(next)
|
|
177
|
+
if (sound) playCue("toggle")
|
|
178
|
+
},
|
|
179
|
+
[options.collapsed, onCollapsedChange, sound]
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const toggleCollapsed = React.useCallback(
|
|
183
|
+
() => setCollapsed(!collapsed),
|
|
184
|
+
[collapsed, setCollapsed]
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
const getNode = React.useCallback(
|
|
188
|
+
(id: string) => findNode(items, id),
|
|
189
|
+
[items]
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
/** Selecting runs the node's own `onSelect` first, then `onNavigate` —
|
|
193
|
+
* the same order a row press follows. Disabled nodes are inert. */
|
|
194
|
+
const select = React.useCallback(
|
|
195
|
+
(id: string) => {
|
|
196
|
+
const node = findNode(items, id)
|
|
197
|
+
if (!node || node.disabled) return
|
|
198
|
+
if (options.activeId === undefined) setOwnActiveId(id)
|
|
199
|
+
node.onSelect?.()
|
|
200
|
+
onNavigate?.(node)
|
|
201
|
+
},
|
|
202
|
+
[items, options.activeId, onNavigate]
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
const startRename = React.useCallback(
|
|
206
|
+
(id: string) => {
|
|
207
|
+
// An id from elsewhere (a stale selection, another tree) would arm
|
|
208
|
+
// rename mode against a row that never renders.
|
|
209
|
+
if (!canRename || !findNode(items, id)) return
|
|
210
|
+
setRenamingId(id)
|
|
211
|
+
},
|
|
212
|
+
[canRename, items]
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
const cancelRename = React.useCallback(() => setRenamingId(null), [])
|
|
216
|
+
|
|
217
|
+
const rename = React.useCallback(
|
|
218
|
+
(id: string, label: string) => {
|
|
219
|
+
setRenamingId(null)
|
|
220
|
+
const next = label.trim()
|
|
221
|
+
if (!canRename || !next) return
|
|
222
|
+
const node = findNode(items, id)
|
|
223
|
+
if (!node || node.label === next) return
|
|
224
|
+
onRename?.(id, next)
|
|
225
|
+
if (managesItems) setItems(renameNode(items, id, next))
|
|
226
|
+
},
|
|
227
|
+
[canRename, items, managesItems, onRename, setItems]
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
const move = React.useCallback(
|
|
231
|
+
(id: string, parentId: string | null, index: number) => {
|
|
232
|
+
if (!canMove) return
|
|
233
|
+
onMove?.(id, parentId, index)
|
|
234
|
+
if (managesItems) setItems(moveNode(items, id, parentId, index))
|
|
235
|
+
},
|
|
236
|
+
[canMove, items, managesItems, onMove, setItems]
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
const dnd = useTreeDnd(items, canMove ? move : undefined)
|
|
240
|
+
|
|
241
|
+
const sectionIds = React.useMemo(
|
|
242
|
+
() => (sectioned ? items.map((node) => node.id) : []),
|
|
243
|
+
[items, sectioned]
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return React.useMemo<TreeController>(
|
|
247
|
+
() => ({
|
|
248
|
+
variant,
|
|
249
|
+
items,
|
|
250
|
+
sectioned,
|
|
251
|
+
sectionIds,
|
|
252
|
+
sound,
|
|
253
|
+
getNode,
|
|
254
|
+
expandedIds,
|
|
255
|
+
isExpanded,
|
|
256
|
+
expand,
|
|
257
|
+
collapse,
|
|
258
|
+
toggleExpanded,
|
|
259
|
+
expandAll,
|
|
260
|
+
collapseAll,
|
|
261
|
+
reveal,
|
|
262
|
+
collapsed,
|
|
263
|
+
setCollapsed,
|
|
264
|
+
toggleCollapsed,
|
|
265
|
+
activeId,
|
|
266
|
+
select,
|
|
267
|
+
canRename,
|
|
268
|
+
renamingId,
|
|
269
|
+
startRename,
|
|
270
|
+
cancelRename,
|
|
271
|
+
rename,
|
|
272
|
+
canMove,
|
|
273
|
+
move,
|
|
274
|
+
dnd,
|
|
275
|
+
}),
|
|
276
|
+
[
|
|
277
|
+
variant,
|
|
278
|
+
items,
|
|
279
|
+
sectioned,
|
|
280
|
+
sectionIds,
|
|
281
|
+
sound,
|
|
282
|
+
getNode,
|
|
283
|
+
expandedIds,
|
|
284
|
+
isExpanded,
|
|
285
|
+
expand,
|
|
286
|
+
collapse,
|
|
287
|
+
toggleExpanded,
|
|
288
|
+
expandAll,
|
|
289
|
+
collapseAll,
|
|
290
|
+
reveal,
|
|
291
|
+
collapsed,
|
|
292
|
+
setCollapsed,
|
|
293
|
+
toggleCollapsed,
|
|
294
|
+
activeId,
|
|
295
|
+
select,
|
|
296
|
+
canRename,
|
|
297
|
+
renamingId,
|
|
298
|
+
startRename,
|
|
299
|
+
cancelRename,
|
|
300
|
+
rename,
|
|
301
|
+
canMove,
|
|
302
|
+
move,
|
|
303
|
+
dnd,
|
|
304
|
+
]
|
|
305
|
+
)
|
|
306
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { TreeNode } from "./type"
|
|
2
|
+
|
|
3
|
+
/** Depth-0 nodes read as sections only when the data actually nests three
|
|
4
|
+
* levels — a two-level tree keeps its top level as plain link rows. */
|
|
5
|
+
export function hasThreeLevels(items: readonly TreeNode[]): boolean {
|
|
6
|
+
return items.some((node) =>
|
|
7
|
+
node.children?.some((child) => (child.children?.length ?? 0) > 0)
|
|
8
|
+
)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Ids of every ancestor of `id` (nearest last); empty when absent. */
|
|
12
|
+
export function ancestorIdsOf(
|
|
13
|
+
items: readonly TreeNode[],
|
|
14
|
+
id: string
|
|
15
|
+
): string[] {
|
|
16
|
+
const walk = (nodes: readonly TreeNode[], trail: string[]): string[] | null => {
|
|
17
|
+
for (const node of nodes) {
|
|
18
|
+
if (node.id === id) return trail
|
|
19
|
+
if (node.children) {
|
|
20
|
+
const found = walk(node.children, [...trail, node.id])
|
|
21
|
+
if (found) return found
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
return walk(items, []) ?? []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Ids that start expanded: `defaultOpen` nodes plus every ancestor of the
|
|
30
|
+
* active entry, so the current location is never folded away. */
|
|
31
|
+
export function initialExpandedIds(
|
|
32
|
+
items: readonly TreeNode[],
|
|
33
|
+
activeId?: string
|
|
34
|
+
): Set<string> {
|
|
35
|
+
const expanded = new Set<string>()
|
|
36
|
+
const walk = (nodes: readonly TreeNode[]) => {
|
|
37
|
+
for (const node of nodes) {
|
|
38
|
+
if (node.defaultOpen) expanded.add(node.id)
|
|
39
|
+
if (node.children) walk(node.children)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
walk(items)
|
|
43
|
+
if (activeId !== undefined)
|
|
44
|
+
for (const id of ancestorIdsOf(items, activeId)) expanded.add(id)
|
|
45
|
+
return expanded
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Ids of every node that can hold children — the set `expandAll` opens.
|
|
49
|
+
* In `files` an empty `children: []` still counts (it is a folder). */
|
|
50
|
+
export function expandableIdsOf(
|
|
51
|
+
items: readonly TreeNode[],
|
|
52
|
+
filesVariant = false
|
|
53
|
+
): string[] {
|
|
54
|
+
const ids: string[] = []
|
|
55
|
+
const walk = (nodes: readonly TreeNode[]) => {
|
|
56
|
+
for (const node of nodes) {
|
|
57
|
+
const branch = filesVariant
|
|
58
|
+
? node.children !== undefined
|
|
59
|
+
: (node.children?.length ?? 0) > 0
|
|
60
|
+
if (branch) ids.push(node.id)
|
|
61
|
+
if (node.children) walk(node.children)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
walk(items)
|
|
65
|
+
return ids
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Immutably relabel `id`. Returns the same array reference when absent. */
|
|
69
|
+
export function renameNode(
|
|
70
|
+
items: readonly TreeNode[],
|
|
71
|
+
id: string,
|
|
72
|
+
label: string
|
|
73
|
+
): TreeNode[] {
|
|
74
|
+
return items.map((node) => {
|
|
75
|
+
if (node.id === id) return { ...node, label }
|
|
76
|
+
return node.children
|
|
77
|
+
? { ...node, children: renameNode(node.children, id, label) }
|
|
78
|
+
: node
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whether `candidateId` is `id` itself or nests anywhere under it — the
|
|
83
|
+
* guard that keeps a folder from being dropped into its own subtree. */
|
|
84
|
+
export function containsNode(
|
|
85
|
+
items: readonly TreeNode[],
|
|
86
|
+
id: string,
|
|
87
|
+
candidateId: string
|
|
88
|
+
): boolean {
|
|
89
|
+
if (id === candidateId) return true
|
|
90
|
+
const root = findNode(items, id)
|
|
91
|
+
if (!root?.children) return false
|
|
92
|
+
return findNode(root.children, candidateId) !== null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function findNode(
|
|
96
|
+
items: readonly TreeNode[],
|
|
97
|
+
id: string
|
|
98
|
+
): TreeNode | null {
|
|
99
|
+
for (const node of items) {
|
|
100
|
+
if (node.id === id) return node
|
|
101
|
+
if (node.children) {
|
|
102
|
+
const found = findNode(node.children, id)
|
|
103
|
+
if (found) return found
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Parent id (`null` at root) and index of `id` among its siblings. */
|
|
110
|
+
export function locateNode(
|
|
111
|
+
items: readonly TreeNode[],
|
|
112
|
+
id: string
|
|
113
|
+
): { parentId: string | null; index: number } | null {
|
|
114
|
+
const walk = (
|
|
115
|
+
nodes: readonly TreeNode[],
|
|
116
|
+
parentId: string | null
|
|
117
|
+
): { parentId: string | null; index: number } | null => {
|
|
118
|
+
for (const [index, node] of nodes.entries()) {
|
|
119
|
+
if (node.id === id) return { parentId, index }
|
|
120
|
+
if (node.children) {
|
|
121
|
+
const found = walk(node.children, node.id)
|
|
122
|
+
if (found) return found
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
return walk(items, null)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Immutably lift `id` out of the tree and reinsert it under `parentId`
|
|
131
|
+
* (`null` = root) at `index` — the standard `onMove` reducer, exported so
|
|
132
|
+
* consumers don't each rebuild it. `index` addresses the sibling list
|
|
133
|
+
* *after* the node has been lifted out. */
|
|
134
|
+
export function moveNode(
|
|
135
|
+
items: readonly TreeNode[],
|
|
136
|
+
id: string,
|
|
137
|
+
parentId: string | null,
|
|
138
|
+
index: number
|
|
139
|
+
): TreeNode[] {
|
|
140
|
+
// Dropping into the node's own subtree would orphan it — refuse.
|
|
141
|
+
if (parentId !== null && containsNode(items, id, parentId)) return [...items]
|
|
142
|
+
let moved: TreeNode | null = null
|
|
143
|
+
const strip = (nodes: readonly TreeNode[]): TreeNode[] =>
|
|
144
|
+
nodes.flatMap((node) => {
|
|
145
|
+
if (node.id === id) {
|
|
146
|
+
moved = node
|
|
147
|
+
return []
|
|
148
|
+
}
|
|
149
|
+
return node.children
|
|
150
|
+
? [{ ...node, children: strip(node.children) }]
|
|
151
|
+
: [node]
|
|
152
|
+
})
|
|
153
|
+
const stripped = strip(items)
|
|
154
|
+
if (!moved) return [...items]
|
|
155
|
+
const captured = moved as TreeNode
|
|
156
|
+
|
|
157
|
+
if (parentId === null) {
|
|
158
|
+
const next = [...stripped]
|
|
159
|
+
next.splice(Math.min(index, next.length), 0, captured)
|
|
160
|
+
return next
|
|
161
|
+
}
|
|
162
|
+
const insert = (nodes: readonly TreeNode[]): TreeNode[] =>
|
|
163
|
+
nodes.map((node) => {
|
|
164
|
+
if (node.id === parentId) {
|
|
165
|
+
const children = [...(node.children ?? [])]
|
|
166
|
+
children.splice(Math.min(index, children.length), 0, captured)
|
|
167
|
+
return { ...node, children }
|
|
168
|
+
}
|
|
169
|
+
return node.children ? { ...node, children: insert(node.children) } : node
|
|
170
|
+
})
|
|
171
|
+
return insert(stripped)
|
|
172
|
+
}
|
|
@@ -69,7 +69,10 @@ export function UserAvatar({
|
|
|
69
69
|
|
|
70
70
|
return (
|
|
71
71
|
<Avatar
|
|
72
|
-
className={cn(
|
|
72
|
+
className={cn(
|
|
73
|
+
"size-8 border-2 border-white bg-neutral-800 text-xl",
|
|
74
|
+
className
|
|
75
|
+
)}
|
|
73
76
|
data-loading={loading || undefined}
|
|
74
77
|
data-slot="user-avatar"
|
|
75
78
|
{...props}
|
|
@@ -12,18 +12,18 @@ export function ComponentPreview({ entry }: { entry: RegistryEntry }) {
|
|
|
12
12
|
const Demo = entry.preview
|
|
13
13
|
|
|
14
14
|
return (
|
|
15
|
-
<BrowserFrame title={entry.name} titleStyle="hidden">
|
|
15
|
+
<BrowserFrame title={entry.name} titleStyle="hidden" className="min-h-60">
|
|
16
16
|
{Demo ? (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
<React.Suspense
|
|
18
|
+
fallback={<p className="text-sm text-muted-foreground">Loading…</p>}
|
|
19
|
+
>
|
|
20
|
+
<Demo />
|
|
21
|
+
</React.Suspense>
|
|
22
|
+
) : (
|
|
23
|
+
<p className="text-sm text-muted-foreground">
|
|
24
|
+
Preview coming soon ({entry.status}).
|
|
25
|
+
</p>
|
|
26
|
+
)}
|
|
27
27
|
</BrowserFrame>
|
|
28
28
|
)
|
|
29
29
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils"
|
|
2
|
+
import type { IAudioWaveProps } from "./type"
|
|
3
|
+
|
|
4
|
+
// `volume` is accepted but not yet wired to the ring animation.
|
|
5
|
+
export function AudioWave({ className, volume: _volume, children }: IAudioWaveProps) {
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
className={cn(
|
|
9
|
+
"group/avatar relative flex items-center justify-center",
|
|
10
|
+
className
|
|
11
|
+
)}
|
|
12
|
+
>
|
|
13
|
+
{/* Animated Story Ring */}
|
|
14
|
+
<div className="absolute -inset-1 animate-[spin_3s_linear_infinite] rounded-full bg-linear-to-tr from-yellow-400 via-fuchsia-500 to-violet-600 opacity-75 blur-xs transition-all duration-500 group-hover/avatar:opacity-100 group-hover/avatar:blur-sm" />
|
|
15
|
+
|
|
16
|
+
{/* Main Avatar */}
|
|
17
|
+
<div className="size-10 ring-2 ring-background transition-transform duration-500 group-hover/avatar:scale-95">
|
|
18
|
+
{children}
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
import { Avatar, AvatarImage } from "@/components/ui/avatar"
|
|
6
|
+
import { cn } from "@/lib/utils"
|
|
7
|
+
import { Fallback } from "./fallback"
|
|
8
|
+
import type { ImageStatus, UserAvatarProps } from "./type"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Identity avatar: an image when one is given, initials otherwise.
|
|
12
|
+
*
|
|
13
|
+
* A passed `src` is assumed to be remote, so the slot holds a skeleton until
|
|
14
|
+
* the image resolves rather than flashing initials that are about to be
|
|
15
|
+
* replaced. A failed load settles on the initials.
|
|
16
|
+
*/
|
|
17
|
+
export function UserAvatar({
|
|
18
|
+
src,
|
|
19
|
+
name = "",
|
|
20
|
+
initials,
|
|
21
|
+
alt,
|
|
22
|
+
loading: loadingProp,
|
|
23
|
+
className,
|
|
24
|
+
...props
|
|
25
|
+
}: UserAvatarProps): React.ReactElement {
|
|
26
|
+
const [status, setStatus] = React.useState<ImageStatus>("idle")
|
|
27
|
+
const loading =
|
|
28
|
+
loadingProp ??
|
|
29
|
+
(src !== undefined && (status === "idle" || status === "loading"))
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Avatar
|
|
33
|
+
className={cn(
|
|
34
|
+
"size-8 border-2 border-white bg-neutral-800 text-xl",
|
|
35
|
+
className
|
|
36
|
+
)}
|
|
37
|
+
data-loading={loading || undefined}
|
|
38
|
+
data-slot="user-avatar"
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
{/* Held back while forced-loading: a loaded image would hide the
|
|
42
|
+
fallback the skeleton lives in. */}
|
|
43
|
+
{src === undefined || loadingProp === true ? null : (
|
|
44
|
+
<AvatarImage
|
|
45
|
+
alt={alt ?? name}
|
|
46
|
+
onLoadingStatusChange={setStatus}
|
|
47
|
+
src={src}
|
|
48
|
+
/>
|
|
49
|
+
)}
|
|
50
|
+
<Fallback name={name} initials={initials} loading={loading} />
|
|
51
|
+
</Avatar>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { AvatarFallback } from "@/components/ui/avatar"
|
|
2
|
+
import { Skeleton } from "@/components/ui/skeleton"
|
|
3
|
+
import { initialsFrom } from "./utils"
|
|
4
|
+
|
|
5
|
+
export function Fallback({
|
|
6
|
+
name,
|
|
7
|
+
initials,
|
|
8
|
+
loading,
|
|
9
|
+
}: {
|
|
10
|
+
name?: string
|
|
11
|
+
initials?: string
|
|
12
|
+
loading?: boolean
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<AvatarFallback>
|
|
16
|
+
{loading ? (
|
|
17
|
+
<Skeleton
|
|
18
|
+
className="size-full rounded-full"
|
|
19
|
+
data-slot="avatar-skeleton"
|
|
20
|
+
/>
|
|
21
|
+
) : (
|
|
22
|
+
(initials ?? initialsFrom(name ?? ""))
|
|
23
|
+
)}
|
|
24
|
+
</AvatarFallback>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { AudioWave } from "./audio-wave"
|
|
2
|
+
import { UserAvatar } from "./component"
|
|
3
|
+
import { Fallback } from "./fallback"
|
|
4
|
+
|
|
5
|
+
export { AudioWave } from "./audio-wave"
|
|
6
|
+
export { UserAvatar } from "./component"
|
|
7
|
+
export { Fallback } from "./fallback"
|
|
8
|
+
export { initialsFrom } from "./utils"
|
|
9
|
+
export type * from "./type"
|
|
10
|
+
|
|
11
|
+
export const Avatar = {
|
|
12
|
+
AudioWave,
|
|
13
|
+
Component: UserAvatar,
|
|
14
|
+
Fallback,
|
|
15
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type * as React from "react"
|
|
2
|
+
|
|
3
|
+
import type { AvatarPrimitive } from "@/components/ui/avatar"
|
|
4
|
+
|
|
5
|
+
export type ImageStatus = Parameters<
|
|
6
|
+
NonNullable<AvatarPrimitive.Image.Props["onLoadingStatusChange"]>
|
|
7
|
+
>[0]
|
|
8
|
+
|
|
9
|
+
export interface UserAvatarProps extends Omit<
|
|
10
|
+
AvatarPrimitive.Root.Props,
|
|
11
|
+
"children"
|
|
12
|
+
> {
|
|
13
|
+
/** Image URL. Without one the initials show immediately — no skeleton. */
|
|
14
|
+
src?: string
|
|
15
|
+
/** Drives the initials, and the alt text unless `alt` overrides it. */
|
|
16
|
+
name?: string
|
|
17
|
+
/** Overrides the initials derived from `name`. */
|
|
18
|
+
initials?: string
|
|
19
|
+
/**
|
|
20
|
+
* Alt text for the image. Pass "" when adjacent text already names the
|
|
21
|
+
* person — the image is then decorative.
|
|
22
|
+
*/
|
|
23
|
+
alt?: string
|
|
24
|
+
/**
|
|
25
|
+
* Force the skeleton, for when the identity itself is still being fetched.
|
|
26
|
+
* Omitted, it follows the image: a passed `src` skeletons until it resolves.
|
|
27
|
+
*/
|
|
28
|
+
loading?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface IAudioWaveProps {
|
|
32
|
+
className?: string
|
|
33
|
+
children?: React.ReactNode
|
|
34
|
+
volume?: number
|
|
35
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** "Jenny Hamilton" → "JH", "luna" → "L". */
|
|
2
|
+
export function initialsFrom(name: string): string {
|
|
3
|
+
const parts = name.trim().split(/\s+/).filter(Boolean)
|
|
4
|
+
if (parts.length === 0) return ""
|
|
5
|
+
const first = parts[0]?.[0] ?? ""
|
|
6
|
+
const last = parts.length > 1 ? (parts.at(-1)?.[0] ?? "") : ""
|
|
7
|
+
return (first + last).toUpperCase()
|
|
8
|
+
}
|