@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.
Files changed (73) hide show
  1. package/dist-lib/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar.d.ts +11 -2
  3. package/dist-lib/components/blocks/nav/sidebar/type.d.ts +83 -1
  4. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +22 -31
  5. package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +5 -0
  6. package/dist-lib/components/blocks/scaffold/constants.d.ts +5 -0
  7. package/dist-lib/components/blocks/scaffold/scaffold-canvas.d.ts +2 -0
  8. package/dist-lib/components/blocks/scaffold/scaffold-panel.d.ts +1 -1
  9. package/dist-lib/components/blocks/scaffold/scaffold-tab.d.ts +6 -1
  10. package/dist-lib/components/blocks/scaffold/type.d.ts +28 -0
  11. package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +19 -0
  12. package/dist-lib/components/blocks/scaffold/utils.d.ts +6 -0
  13. package/dist-lib/components/composed/tree/__tests__/tree.test.d.ts +1 -0
  14. package/dist-lib/components/composed/tree/__tests__/use-tree.test.d.ts +1 -0
  15. package/dist-lib/components/composed/tree/constants.d.ts +35 -0
  16. package/dist-lib/components/composed/tree/index.d.ts +4 -0
  17. package/dist-lib/components/composed/tree/tree-context.d.ts +4 -0
  18. package/dist-lib/components/composed/tree/tree-node.d.ts +7 -0
  19. package/dist-lib/components/composed/tree/tree.d.ts +19 -0
  20. package/dist-lib/components/composed/tree/type.d.ts +192 -0
  21. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +7 -0
  22. package/dist-lib/components/composed/tree/use-tree.d.ts +20 -0
  23. package/dist-lib/components/composed/tree/utils.d.ts +28 -0
  24. package/dist-lib/components/user-avatar/audio-wave.d.ts +2 -0
  25. package/dist-lib/components/user-avatar/component.d.ts +10 -0
  26. package/dist-lib/components/user-avatar/fallback.d.ts +5 -0
  27. package/dist-lib/components/user-avatar/index.d.ts +13 -0
  28. package/dist-lib/components/user-avatar/type.d.ts +26 -0
  29. package/dist-lib/components/user-avatar/utils.d.ts +2 -0
  30. package/dist-lib/index.d.ts +1 -0
  31. package/dist-lib/index.js +2141 -1139
  32. package/dist-lib/index.js.map +1 -1
  33. package/package.json +2 -1
  34. package/src/components/beste/piece/browser-frame.tsx +9 -6
  35. package/src/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.tsx +310 -0
  36. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +90 -60
  37. package/src/components/blocks/nav/sidebar/type.ts +105 -1
  38. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +293 -116
  39. package/src/components/blocks/nav/sidebar/utils.ts +28 -0
  40. package/src/components/blocks/scaffold/__tests__/scaffold.test.tsx +160 -1
  41. package/src/components/blocks/scaffold/constants.ts +7 -0
  42. package/src/components/blocks/scaffold/panel-menu-button.tsx +3 -0
  43. package/src/components/blocks/scaffold/scaffold-canvas.tsx +48 -5
  44. package/src/components/blocks/scaffold/scaffold-inspector.tsx +3 -2
  45. package/src/components/blocks/scaffold/scaffold-main.tsx +1 -1
  46. package/src/components/blocks/scaffold/scaffold-panel.tsx +41 -5
  47. package/src/components/blocks/scaffold/scaffold-root.tsx +18 -3
  48. package/src/components/blocks/scaffold/scaffold-sidebar.tsx +1 -1
  49. package/src/components/blocks/scaffold/scaffold-tab.tsx +45 -7
  50. package/src/components/blocks/scaffold/type.ts +28 -0
  51. package/src/components/blocks/scaffold/use-panel-visibility.ts +180 -0
  52. package/src/components/blocks/scaffold/utils.ts +19 -0
  53. package/src/components/composed/tree/__tests__/tree.test.tsx +400 -0
  54. package/src/components/composed/tree/__tests__/use-tree.test.tsx +332 -0
  55. package/src/components/composed/tree/constants.ts +60 -0
  56. package/src/components/composed/tree/index.ts +13 -0
  57. package/src/components/composed/tree/tree-context.ts +13 -0
  58. package/src/components/composed/tree/tree-node.tsx +451 -0
  59. package/src/components/composed/tree/tree.tsx +278 -0
  60. package/src/components/composed/tree/type.ts +212 -0
  61. package/src/components/composed/tree/use-tree-dnd.ts +148 -0
  62. package/src/components/composed/tree/use-tree.ts +306 -0
  63. package/src/components/composed/tree/utils.ts +172 -0
  64. package/src/components/composed/user-avatar.tsx +4 -1
  65. package/src/components/docs/component-preview.tsx +11 -11
  66. package/src/components/user-avatar/audio-wave.tsx +22 -0
  67. package/src/components/user-avatar/component.tsx +53 -0
  68. package/src/components/user-avatar/fallback.tsx +26 -0
  69. package/src/components/user-avatar/index.ts +15 -0
  70. package/src/components/user-avatar/type.ts +35 -0
  71. package/src/components/user-avatar/utils.ts +8 -0
  72. package/src/index.css +211 -153
  73. 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("size-8", className)}
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
- <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
- )}
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
+ }